diff --git a/.ci/Dockerfile b/.ci/Dockerfile index d486aaeb0dee79..150e0925ae7bc7 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,7 +1,7 @@ # NOTE: This Dockerfile is ONLY used to run certain tasks in CI. It is not used to run Kibana or as a distributable. # If you're looking for the Kibana Docker image distributable, please see: src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts -ARG NODE_VERSION=16.13.2 +ARG NODE_VERSION=16.14.2 FROM node:${NODE_VERSION} AS base diff --git a/.gitignore b/.gitignore index 4704247e6f548d..588c185b17a0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ npm-debug.log* .vagrant .envrc +## Snyk +.dccache + ## @cypress/snapshot from apm plugin /snapshots.js diff --git a/.node-version b/.node-version index 23d9c36a1187ae..d9f880069dc78a 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16.13.2 +16.14.2 diff --git a/.nvmrc b/.nvmrc index d7cb9ec3a7643c..d9f880069dc78a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.13.2 \ No newline at end of file +16.14.2 diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 04f61b7f950648..c00062734239e0 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -27,14 +27,14 @@ check_rules_nodejs_version(minimum_version_string = "4.0.0") # we can update that rule. node_repositories( node_repositories = { - "16.13.2-darwin_amd64": ("node-v16.13.2-darwin-x64.tar.gz", "node-v16.13.2-darwin-x64", "900a952bb77533d349e738ff8a5179a4344802af694615f36320a888b49b07e6"), - "16.13.2-darwin_arm64": ("node-v16.13.2-darwin-arm64.tar.gz", "node-v16.13.2-darwin-arm64", "09d300008ad58792c12622a5eafdb14c931587bb88713df4df64cdf4ff2188d1"), - "16.13.2-linux_arm64": ("node-v16.13.2-linux-arm64.tar.xz", "node-v16.13.2-linux-arm64", "a3cf8e4e9fbea27573eee6da84720bf7227ddd22842b842d48049d6dfe55fb03"), - "16.13.2-linux_s390x": ("node-v16.13.2-linux-s390x.tar.xz", "node-v16.13.2-linux-s390x", "c4ba46fc19366f7377d28a60a98f741bfa38045d7924306244c51d1660afcc8d"), - "16.13.2-linux_amd64": ("node-v16.13.2-linux-x64.tar.xz", "node-v16.13.2-linux-x64", "7f5e9a42d6e86147867d35643c7b1680c27ccd45db85666fc52798ead5e74421"), - "16.13.2-windows_amd64": ("node-v16.13.2-win-x64.zip", "node-v16.13.2-win-x64", "107e3ece84b7fa1e80b3bdf03181d395246c7867e27b17b6d7e6fa9c7932b467"), + "16.14.2-darwin_amd64": ("node-v16.14.2-darwin-x64.tar.gz", "node-v16.14.2-darwin-x64", "d3076ca7fcc7269c8ff9b03fe7d1c277d913a7e84a46a14eff4af7791ff9d055"), + "16.14.2-darwin_arm64": ("node-v16.14.2-darwin-arm64.tar.gz", "node-v16.14.2-darwin-arm64", "a66d9217d2003bd416d3dd06dfd2c7a044c4c9ff2e43a27865790bd0d59c682d"), + "16.14.2-linux_arm64": ("node-v16.14.2-linux-arm64.tar.xz", "node-v16.14.2-linux-arm64", "f7c5a573c06a520d6c2318f6ae204141b8420386553a692fc359f8ae3d88df96"), + "16.14.2-linux_s390x": ("node-v16.14.2-linux-s390x.tar.xz", "node-v16.14.2-linux-s390x", "3197925919ca357e17a31132dc6ef4e5afae819fa09905cfe9f7ff7924a00bf5"), + "16.14.2-linux_amd64": ("node-v16.14.2-linux-x64.tar.xz", "node-v16.14.2-linux-x64", "e40c6f81bfd078976d85296b5e657be19e06862497741ad82902d0704b34bb1b"), + "16.14.2-windows_amd64": ("node-v16.14.2-win-x64.zip", "node-v16.14.2-win-x64", "4731da4fbb2015d414e871fa9118cabb643bdb6dbdc8a69a3ed563266ac93229"), }, - node_version = "16.13.2", + node_version = "16.14.2", node_urls = [ "https://nodejs.org/dist/v{version}/{filename}", ], diff --git a/dev_docs/contributing/documentation.mdx b/dev_docs/contributing/documentation.mdx index ad9286dd07ab83..caf2f439548bcf 100644 --- a/dev_docs/contributing/documentation.mdx +++ b/dev_docs/contributing/documentation.mdx @@ -24,8 +24,8 @@ node scripts/docs.js --open ## REST APIs REST APIs should be documented using the following formats: -- [API doc template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-ref-ex.asciidoc) -- [API object definition template](https://raw.githubusercontent.com/elastic/docs/main/shared/api-definitions-ex.asciidoc) +- [API doc template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc) +- [API object definition template](https://raw.githubusercontent.com/elastic/docs/master/shared/api-definitions-ex.asciidoc) ## Developer documentation diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index d426ec1a2c91cc..e96fd1c7dfd25a 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -17,33 +17,26 @@ These files must be updated when upgrading Node.js: - {kib-repo}blob/{branch}/WORKSPACE.bazel[`WORKSPACE.bazel`] - The version is specified in the `node_version` property. Besides this property, the list of files under `node_repositories` must be updated along with their respective SHA256 hashes. These can be found on the https://nodejs.org[nodejs.org] website. - Example for Node.js v14.16.1: https://nodejs.org/dist/v14.16.1/SHASUMS256.txt.asc + Example for Node.js v16.14.2: https://nodejs.org/dist/v16.14.2/SHASUMS256.txt.asc -See PR {kib-repo}pull/96382[#96382] for an example of how the Node.js version has been upgraded previously. - -In the 6.8 branch, neither the `.ci/Dockerfile` file nor the `WORKSPACE.bazel` file exists, so when upgrading Node.js in that branch, just skip those files. +See PR {kib-repo}pull/128123[#128123] for an example of how the Node.js version has been upgraded previously. === Backporting The following rules are not set in stone. Use best judgement when backporting. -Currently version 7.11 and newer run Node.js 14, while 7.10 and older run Node.js 10. -Hence, upgrades to either Node.js 14 or Node.js 10 should be done as separate PRs. - ==== Node.js patch upgrades -Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version: +Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version (which currently is all of them, but this might change in the future once Node.js v18 is released and becomes LTS): - - If upgrading Node.js 14, and the current release is 7.11.1, the main PR should target `master` and be backported to `7.x` and `7.11`. - - If upgrading Node.js 10, the main PR should target `6.8` only. + - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17` and `8.1`. ==== Node.js minor upgrades Typically, you want to backport Node.js *minor* upgrades to the next minor {kib} release branch that runs the same *major* Node.js version: - - If upgrading Node.js 14, and the current release is 7.11.1, the main PR should target `master` and be backported to `7.x`, while leaving the `7.11` branch as-is. - - If upgrading Node.js 10, the main PR should target `6.8` only. + - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17`, while leaving the `8.1` branch as-is. === Upgrading installed Node.js version @@ -56,11 +49,11 @@ Run the following to install the new Node.js version. Replace `` with t nvm install ---- -To get the same global npm modules installed with the new version of Node.js as is currently installed, use the `--reinstall-packages-from` command-line argument (optionally replace `14` with the desired source version): +To get the same global npm modules installed with the new version of Node.js as is currently installed, use the `--reinstall-packages-from` command-line argument (optionally replace `16` with the desired source version): [source,bash] ---- -nvm install --reinstall-packages-from=14 +nvm install --reinstall-packages-from=16 ---- If needed, uninstall the old version of Node.js by running the following. Replace `` with the full version number of the version that should be uninstalled: @@ -70,11 +63,11 @@ If needed, uninstall the old version of Node.js by running the following. Replac nvm uninstall ---- -Optionally, tell nvm to always use the "highest" installed Node.js 14 version. Replace `14` if a different major version is desired: +Optionally, tell nvm to always use the "highest" installed Node.js 16 version. Replace `16` if a different major version is desired: [source,bash] ---- -nvm alias default 14 +nvm alias default 16 ---- Alternatively, include the full version number at the end to specify a specific default version. diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 58f61b79f3ba64..58677141ab0c8b 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -213,6 +213,10 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a rule. | `failure` | User is not authorized to access a rule. +.2+| `rule_get_execution_log` +| `success` | User has accessed execution log for a rule. +| `failure` | User is not authorized to access execution log for a rule. + .2+| `rule_find` | `success` | User has accessed a rule as part of a search operation. | `failure` | User is not authorized to search for rules. diff --git a/package.json b/package.json index 589f9d83dacecb..9f1cdfab2e3050 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,12 @@ "url": "https://github.com/elastic/kibana.git" }, "engines": { - "node": "16.13.2", + "node": "16.14.2", "yarn": "^1.21.1" }, "resolutions": { "**/@babel/runtime": "^7.17.2", - "**/@types/node": "16.10.2", + "**/@types/node": "16.11.7", "**/chokidar": "^3.4.3", "**/deepmerge": "^4.2.2", "**/fast-deep-equal": "^3.1.1", @@ -655,7 +655,7 @@ "@types/mustache": "^0.8.31", "@types/ncp": "^2.0.1", "@types/nock": "^10.0.3", - "@types/node": "16.10.2", + "@types/node": "16.11.7", "@types/node-fetch": "^2.6.0", "@types/node-forge": "^1.0.1", "@types/nodemailer": "^6.4.0", diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 9487b3559536e0..49708aa5fafc47 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -45,6 +45,8 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { droppedTransactionSpans: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-spans.html#data-model-dropped-spans`, upgrading: `${APM_DOCS}guide/${DOC_LINK_VERSION}/upgrade.html`, metaData: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-metadata.html`, + overview: `${APM_DOCS}guide/${DOC_LINK_VERSION}/apm-overview.html`, + tailSamplingPolicies: `${APM_DOCS}guide/${DOC_LINK_VERSION}/configure-tail-based-sampling.html`, }, canvas: { guide: `${KIBANA_DOCS}canvas.html`, @@ -65,6 +67,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { discover: { guide: `${KIBANA_DOCS}discover.html`, fieldStatistics: `${KIBANA_DOCS}show-field-statistics.html`, + documentExplorer: `${KIBANA_DOCS}document-explorer.html`, }, filebeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/filebeat/${DOC_LINK_VERSION}`, @@ -396,6 +399,11 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { monitorUptime: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-uptime.html`, tlsCertificate: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/tls-certificate-alert.html`, uptimeDurationAnomaly: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/duration-anomaly-alert.html`, + monitorLogs: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-logs.html`, + analyzeMetrics: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/analyze-metrics.html`, + monitorUptimeSynthetics: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-uptime-synthetics.html`, + userExperience: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/user-experience.html`, + createAlerts: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/create-alerts.html`, }, alerting: { guide: `${KIBANA_DOCS}create-and-manage-rules.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 6d10dadcafaa35..ef3b490bbb0946 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -31,6 +31,8 @@ export interface DocLinks { readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; + readonly overview: string; + readonly tailSamplingPolicies: string; }; readonly canvas: { readonly guide: string; @@ -289,6 +291,11 @@ export interface DocLinks { monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; + monitorLogs: string; + analyzeMetrics: string; + monitorUptimeSynthetics: string; + userExperience: string; + createAlerts: string; }>; readonly alerting: Record; readonly maps: Readonly<{ diff --git a/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts b/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts index ecf5760252abf8..7f40451a34b350 100644 --- a/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts +++ b/packages/kbn-storybook/src/lib/register_theme_switcher_addon.ts @@ -23,7 +23,7 @@ export function registerThemeSwitcherAddon() { 'eui-theme-css' ) as HTMLLinkElement | null; - if (stylesheet) { + if (stylesheet && globals.euiTheme) { stylesheet.href = `kbn-ui-shared-deps-npm.${globals.euiTheme}.css`; } }); diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 5c6ac938525335..eb53b51293bbb7 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -20,7 +20,7 @@ export const CopySource: Task = { 'src/**', '!src/**/*.{test,test.mocks,mock}.{js,ts,tsx}', '!src/**/mocks.ts', // special file who imports .mock files - '!src/**/{target,__tests__,__snapshots__,__mocks__,integration_tests,__fixtures__}/**', + '!src/**/{target,tests,__jest__,test_data,__tests__,__snapshots__,__mocks__,integration_tests,__fixtures__}/**', '!src/core/server/core_app/assets/favicons/favicon.distribution.png', '!src/core/server/core_app/assets/favicons/favicon.distribution.svg', '!src/test_utils/**', @@ -30,6 +30,11 @@ export const CopySource: Task = { '!src/functional_test_runner/**', '!src/dev/**', '!**/jest.config.js', + '!**/jest.integration.config.js', + '!**/mocks.js', + '!**/test_utils.js', + '!**/test_helpers.js', + '!**/*.{md,mdx,asciidoc}', '!src/plugins/telemetry/schema/**', // Skip telemetry schemas // this is the dev-only entry '!src/setup_node_env/index.js', diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx index 7b294c609f31e3..5ee1bbf49f56c8 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.test.tsx @@ -268,6 +268,35 @@ describe('AdvancedSettings', () => { ).toHaveLength(1); }); + it('should should not render a custom setting', async () => { + // The manual mock for the uiSettings client returns false for isConfig, override that + const uiSettings = mockConfig().core.uiSettings; + uiSettings.isCustom = (key) => true; + + const customSettingQuery = 'test:customstring:setting'; + mockQuery(customSettingQuery); + const component = mountWithI18nProvider( + + ); + + expect( + component + .find('Field') + .filterWhere( + (n: ReactWrapper) => + (n.prop('setting') as Record).name === customSettingQuery + ) + ).toEqual({}); + }); + it('should render read-only when saving is disabled', async () => { mockQuery(); const component = mountWithI18nProvider( diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index ba3bb49790627d..e46dfdf50956ba 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -198,6 +198,7 @@ export class AdvancedSettings extends Component !c.readOnly) + .filter((c) => !c.isCustom) // hide any settings that aren't explicitly registered by enabled plugins. .sort(fieldSorter); } diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx index 86193f50b2fe29..6a7663d9d2f015 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { filter } from 'lodash'; import React, { useEffect, useState, useCallback } from 'react'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { @@ -22,11 +21,12 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { DataView, DataViewField } from '../../../../../plugins/data_views/public'; -import { useKibana, toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { useKibana } from '../../../../../plugins/kibana_react/public'; import { IndexPatternManagmentContext } from '../../types'; import { Tabs } from './tabs'; import { IndexHeader } from './index_header'; import { getTags } from '../utils'; +import { removeDataView, RemoveDataViewProps } from './remove_data_view'; export interface EditIndexPatternProps extends RouteComponentProps { indexPattern: DataView; @@ -46,15 +46,6 @@ const mappingConflictHeader = i18n.translate( } ); -const confirmModalOptionsDelete = { - confirmButtonText: i18n.translate('indexPatternManagement.editIndexPattern.deleteButton', { - defaultMessage: 'Delete', - }), - title: i18n.translate('indexPatternManagement.editDataView.deleteHeader', { - defaultMessage: 'Delete data view', - }), -}; - const securityDataView = i18n.translate( 'indexPatternManagement.editIndexPattern.badge.securityDataViewTitle', { @@ -91,47 +82,14 @@ export const EditIndexPattern = withRouter( setDefaultIndex(indexPattern.id || ''); }, [uiSettings, indexPattern.id]); - const removePattern = () => { - async function doRemove() { - if (indexPattern.id === defaultIndex) { - const indexPatterns = await dataViews.getIdsWithTitle(); - uiSettings.remove('defaultIndex'); - const otherPatterns = filter(indexPatterns, (pattern) => { - return pattern.id !== indexPattern.id; - }); - - if (otherPatterns.length) { - uiSettings.set('defaultIndex', otherPatterns[0].id); - } - } - if (indexPattern.id) { - Promise.resolve(dataViews.delete(indexPattern.id)).then(function () { - history.push(''); - }); - } - } - - const warning = - indexPattern.namespaces.length > 1 ? ( - {indexPattern.title}, - }} - /> - ) : ( - '' - ); - - overlays - .openConfirm(toMountPoint(
{warning}
), confirmModalOptionsDelete) - .then((isConfirmed) => { - if (isConfirmed) { - doRemove(); - } - }); - }; + const removeHandler = removeDataView({ + dataViews, + uiSettings, + overlays, + onDelete: () => { + history.push(''); + }, + }); const timeFilterHeader = i18n.translate( 'indexPatternManagement.editIndexPattern.timeFilterHeader', @@ -161,12 +119,34 @@ export const EditIndexPattern = withRouter( const docsUrl = kibana.services.docLinks!.links.elasticsearch.mapping; const userEditPermission = dataViews.getCanSaveSync(); + const warning = + (indexPattern.namespaces && indexPattern.namespaces.length > 1) || + indexPattern.namespaces.includes('*') ? ( + {indexPattern.title}, + }} + /> + ) : ( + {indexPattern.title}, + }} + /> + ); + return (
+ removeHandler([indexPattern as RemoveDataViewProps],
{warning}
) + } defaultIndex={defaultIndex} canSave={userEditPermission} > diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx index 7c8b4e5eef31fc..71903ae6d1fb1c 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/index.tsx @@ -10,3 +10,5 @@ export { EditIndexPattern } from './edit_index_pattern'; export { EditIndexPatternContainer } from './edit_index_pattern_container'; export { CreateEditField } from './create_edit_field'; export { CreateEditFieldContainer } from './create_edit_field/create_edit_field_container'; +export type { RemoveDataViewProps } from './remove_data_view'; +export { removeDataView } from './remove_data_view'; diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx new file mode 100644 index 00000000000000..9db99e04284637 --- /dev/null +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/remove_data_view.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import type { IUiSettingsClient, OverlayStart } from 'src/core/public'; +import { asyncForEach } from '@kbn/std'; +import { EuiConfirmModalProps } from '@elastic/eui'; +import { toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { DataViewsPublicPluginStart } from '../../../../../plugins/data_views/public'; + +const confirmModalOptionsDelete = { + confirmButtonText: i18n.translate('indexPatternManagement.editIndexPattern.deleteButton', { + defaultMessage: 'Delete', + }), + title: i18n.translate('indexPatternManagement.editDataView.deleteHeader', { + defaultMessage: 'Delete data view', + }), + buttonColor: 'danger' as EuiConfirmModalProps['buttonColor'], +}; + +export interface RemoveDataViewProps { + id: string; + title: string; + namespaces?: string[] | undefined; +} + +interface RemoveDataViewDeps { + dataViews: DataViewsPublicPluginStart; + uiSettings: IUiSettingsClient; + overlays: OverlayStart; + onDelete: () => void; +} + +export const removeDataView = + ({ dataViews, overlays, onDelete }: RemoveDataViewDeps) => + (dataViewArray: RemoveDataViewProps[], msg: JSX.Element) => { + overlays.openConfirm(toMountPoint(msg), confirmModalOptionsDelete).then(async (isConfirmed) => { + if (isConfirmed) { + await asyncForEach(dataViewArray, async ({ id }) => dataViews.delete(id)); + onDelete(); + } + }); + }; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap b/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap new file mode 100644 index 00000000000000..233c859f0a15ec --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/__snapshots__/delete_modal_msg.test.tsx.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`delete modal content render 1`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 2`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 3`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; + +exports[`delete modal content render 4`] = ` +
+ + +
+ +
+ + + } + responsive={true} + tableCaption="Data views selected for deletion" + tableLayout="fixed" + /> +
+`; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx new file mode 100644 index 00000000000000..c6084cb219b0db --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { deleteModalMsg } from './delete_modal_msg'; + +describe('delete modal content', () => { + const toDVProps = (title: string, namespaces: string[]) => { + return { + id: '1', + title, + namespaces, + }; + }; + + test('render', () => { + expect(deleteModalMsg([toDVProps('logstash-*', ['a', 'b', 'c'])], true)).toMatchSnapshot(); + expect(deleteModalMsg([toDVProps('logstash-*', ['a', 'b', 'c'])], false)).toMatchSnapshot(); + expect(deleteModalMsg([toDVProps('logstash-*', ['*'])], true)).toMatchSnapshot(); + expect( + deleteModalMsg([toDVProps('logstash-*', ['*']), toDVProps('log*', ['a', 'b', 'c'])], true) + ).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx new file mode 100644 index 00000000000000..55716c5aecdc44 --- /dev/null +++ b/src/plugins/data_view_management/public/components/index_pattern_table/delete_modal_msg.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiCallOut, EuiTableFieldDataColumnType, EuiBasicTable, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { RemoveDataViewProps } from '../edit_index_pattern'; + +const all = i18n.translate('indexPatternManagement.dataViewTable.spaceCountAll', { + defaultMessage: 'all', +}); + +const dataViewColumnName = i18n.translate( + 'indexPatternManagement.dataViewTable.dataViewColumnName', + { + defaultMessage: 'Data view', + } +); + +const spacesColumnName = i18n.translate('indexPatternManagement.dataViewTable.spacesColumnName', { + defaultMessage: 'Spaces', +}); + +const tableTitle = i18n.translate('indexPatternManagement.dataViewTable.tableTitle', { + defaultMessage: 'Data views selected for deletion', +}); + +export const deleteModalMsg = (views: RemoveDataViewProps[], hasSpaces: boolean) => { + const columns: Array> = [ + { + field: 'title', + name: dataViewColumnName, + sortable: true, + }, + ]; + if (hasSpaces) { + columns.push({ + field: 'namespaces', + name: spacesColumnName, + sortable: true, + width: '100px', + align: 'right', + render: (namespaces: string[]) => (namespaces.indexOf('*') !== -1 ? all : namespaces.length), + }); + } + + return ( +
+ + +
+ +
+ + +
+ ); +}; diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx index ed050261f12708..a07be274f34baf 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -13,6 +13,7 @@ import { EuiInMemoryTable, EuiPageHeader, EuiSpacer, + EuiBasicTableColumn, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { RouteComponentProps, withRouter, useLocation } from 'react-router-dom'; @@ -25,6 +26,8 @@ import { getIndexPatterns } from '../utils'; import { getListBreadcrumbs } from '../breadcrumbs'; import { SpacesList } from './spaces_list'; import type { SpacesContextProps } from '../../../../../../x-pack/plugins/spaces/public'; +import { removeDataView, RemoveDataViewProps } from '../edit_index_pattern'; +import { deleteModalMsg } from './delete_modal_msg'; const pagination = { initialPageSize: 10, @@ -71,11 +74,13 @@ export const IndexPatternTable = ({ dataViews, IndexPatternEditor, spaces, + overlays, } = useKibana().services; const [query, setQuery] = useState(''); const [indexPatterns, setIndexPatterns] = useState([]); const [isLoadingIndexPatterns, setIsLoadingIndexPatterns] = useState(true); const [showCreateDialog, setShowCreateDialog] = useState(showCreateDialogProp); + const [selectedItems, setSelectedItems] = useState([]); const handleOnChange = ({ queryText, error }: { queryText: string; error: unknown }) => { if (!error) { @@ -83,7 +88,38 @@ export const IndexPatternTable = ({ } }; + const renderDeleteButton = () => { + const clickHandler = removeDataView({ + dataViews, + overlays, + uiSettings, + onDelete: () => loadDataViews(), + }); + if (selectedItems.length === 0) { + return; + } + return ( + clickHandler(selectedItems, deleteModalMsg(selectedItems, !!spaces))} + > + + + ); + }; + + const deleteButton = renderDeleteButton(); + const search = { + toolsLeft: deleteButton && [deleteButton], query, onChange: handleOnChange, box: { @@ -127,12 +163,46 @@ export const IndexPatternTable = ({ [spaces] ); - const columns = [ + const removeHandler = removeDataView({ + dataViews, + uiSettings, + overlays, + onDelete: () => loadDataViews(), + }); + + const alertColumn = { + name: 'Actions', + field: 'id', + width: '10%', + actions: [ + { + name: i18n.translate('indexPatternManagement.dataViewTable.columnDelete', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'indexPatternManagement.dataViewTable.columnDeleteDescription', + { + defaultMessage: 'Delete this data view', + } + ), + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: (dataView: RemoveDataViewProps) => + removeHandler([dataView], deleteModalMsg([dataView], !!spaces)), + isPrimary: true, + 'data-test-subj': 'action-delete', + }, + ], + }; + + const columns: Array> = [ { field: 'title', name: i18n.translate('indexPatternManagement.dataViewTable.nameColumn', { defaultMessage: 'Name', }), + width: '70%', render: (name: string, dataView: IndexPatternTableItem) => (
{name} @@ -153,7 +223,10 @@ export const IndexPatternTable = ({ }, { field: 'namespaces', - name: 'spaces', + name: i18n.translate('indexPatternManagement.dataViewTable.spacesColumn', { + defaultMessage: 'Spaces', + }), + width: '20%', render: (name: string, dataView: IndexPatternTableItem) => { return spaces ? ( { + dataViews.clearCache(dataView.id); + loadDataViews(); + }} /> ) : ( <> @@ -170,6 +246,10 @@ export const IndexPatternTable = ({ }, ]; + if (dataViews.getCanSaveSync()) { + columns.push(alertColumn); + } + const createButton = canSave ? ( ); + const selection = { + onSelectionChange: setSelectedItems, + }; + return (
{displayIndexPatternEditor} diff --git a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx similarity index 96% rename from src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx rename to src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx index 3e6b8e3973001f..cfe04094fd6f60 100644 --- a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorere_callout.test.tsx +++ b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.test.tsx @@ -15,6 +15,7 @@ import { DiscoverServices } from '../../../../build_services'; const defaultServices = { addBasePath: () => '', + docLinks: { links: { discover: { documentExplorer: '' } } }, capabilities: { advancedSettings: { save: true } }, storage: new LocalStorageMock({ [CALLOUT_STATE_KEY]: false }), } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx index 6a256ef4e24c90..73fc7cdf9a1052 100644 --- a/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx +++ b/src/plugins/discover/public/application/main/components/document_explorer_callout/document_explorer_callout.tsx @@ -10,7 +10,14 @@ import React, { useCallback, useState } from 'react'; import './document_explorer_callout.scss'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiButtonIcon, EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { DOC_TABLE_LEGACY } from '../../../../../common'; import { Storage } from '../../../../../../kibana_utils/public'; @@ -26,7 +33,7 @@ const updateStoredCalloutState = (newState: boolean, storage: Storage) => { }; export const DocumentExplorerCallout = () => { - const { storage, capabilities, addBasePath } = useDiscoverServices(); + const { storage, capabilities, docLinks, addBasePath } = useDiscoverServices(); const [calloutClosed, setCalloutClosed] = useState(getStoredCalloutState(storage)); const onCloseCallout = useCallback(() => { @@ -50,18 +57,33 @@ export const DocumentExplorerCallout = () => { defaultMessage="Quickly sort, select, and compare data, resize columns, and view documents in fullscreen with the Document Explorer." />

-

- - - -

+ + + + + + + + + + + + ); }; diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx new file mode 100644 index 00000000000000..5b641cced51637 --- /dev/null +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { monaco } from '@kbn/monaco'; +import { getHeight } from './get_height'; + +describe('getHeight', () => { + window.innerHeight = 500; + const getMonacoMock = (lineCount: number) => { + return { + getDomNode: jest.fn(() => { + return { + getBoundingClientRect: jest.fn(() => { + return { + top: 200, + }; + }), + }; + }), + getOption: jest.fn(() => 10), + getModel: jest.fn(() => ({ getLineCount: jest.fn(() => lineCount) })), + getTopForLineNumber: jest.fn((line) => line * 10), + } as unknown as monaco.editor.IStandaloneCodeEditor; + }; + test('when using document explorer, returning the available height in the flyout', () => { + const monacoMock = getMonacoMock(500); + + const height = getHeight(monacoMock, true); + expect(height).toBe(275); + }); + + test('when using classic table, its displayed inline without scrolling', () => { + const monacoMock = getMonacoMock(100); + + const height = getHeight(monacoMock, false); + expect(height).toBe(1020); + }); + + test('when using classic table, limited height > 500 lines to allow scrolling', () => { + const monacoMock = getMonacoMock(1000); + + const height = getHeight(monacoMock, false); + expect(height).toBe(5020); + }); +}); diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx new file mode 100644 index 00000000000000..0dcabc8ae951d8 --- /dev/null +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/get_height.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { monaco } from '@kbn/monaco'; +import { MARGIN_BOTTOM, MAX_LINES_CLASSIC_TABLE } from './source'; + +export function getHeight(editor: monaco.editor.IStandaloneCodeEditor, useDocExplorer: boolean) { + const editorElement = editor?.getDomNode(); + if (!editorElement) { + return 0; + } + + let result; + if (useDocExplorer) { + // assign a good height filling the available space of the document flyout + const position = editorElement.getBoundingClientRect(); + result = window.innerHeight - position.top - MARGIN_BOTTOM; + } else { + // takes care of the classic table, display a maximum of 500 lines + // why not display it all? Due to performance issues when the browser needs to render it all + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const displayedLineCount = + lineCount > MAX_LINES_CLASSIC_TABLE ? MAX_LINES_CLASSIC_TABLE : lineCount; + result = editor.getTopForLineNumber(displayedLineCount + 1) + lineHeight; + } + return result > 0 ? result : 0; +} diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx index 9199903d2c084f..327547f232265d 100644 --- a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/source.tsx @@ -14,10 +14,11 @@ import { EuiButton, EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from import { i18n } from '@kbn/i18n'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { JSONCodeEditorCommonMemoized } from '../../../../components/json_code_editor/json_code_editor_common'; -import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common'; +import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common'; import { useEsDocSearch } from '../../../../utils/use_es_doc_search'; import { DataView } from '../../../../../../data_views/public'; import { ElasticRequestState } from '../../../../application/doc/types'; +import { getHeight } from './get_height'; interface SourceViewerProps { id: string; @@ -27,6 +28,12 @@ interface SourceViewerProps { width?: number; } +// Ihe number of lines displayed without scrolling used for classic table, which renders the component +// inline limitation was necessary to enable virtualized scrolling, which improves performance +export const MAX_LINES_CLASSIC_TABLE = 500; +// Displayed margin of the code editor to the window bottom when rendered in the document explorer flyout +export const MARGIN_BOTTOM = 25; + export const DocViewerSource = ({ id, index, @@ -38,6 +45,7 @@ export const DocViewerSource = ({ const [jsonValue, setJsonValue] = useState(''); const { uiSettings } = useDiscoverServices(); const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const useDocExplorer = !uiSettings.get(DOC_TABLE_LEGACY); const [reqState, hit, requestData] = useEsDocSearch({ id, index, @@ -62,16 +70,18 @@ export const DocViewerSource = ({ return; } - const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); - const lineCount = editor.getModel()?.getLineCount() || 1; - const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + const height = getHeight(editor, useDocExplorer); + if (height === 0) { + return; + } + if (!jsonValue || jsonValue === '') { editorElement.style.height = '0px'; } else { editorElement.style.height = `${height}px`; } editor.layout(); - }, [editor, jsonValue]); + }, [editor, jsonValue, useDocExplorer]); const loadingState = (
diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index afd70cc5bbee7d..b3e955c592ad2f 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -167,8 +167,17 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record` + + i18n.translate('discover.advancedSettings.documentExplorerLinkText', { + defaultMessage: 'Document Explorer', + }) + + '', + }, }), category: ['discover'], schema: schema.boolean(), diff --git a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap index 5889338c37d8c8..4f2fd7cee4a5a5 100644 --- a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap @@ -112,25 +112,7 @@ exports[`KibanaPageTemplate render default empty prompt 1`] = ` `; exports[`KibanaPageTemplate render noDataContent 1`] = ` - { - const { - className, - noDataConfig, - ...rest - } = props; - - if (!noDataConfig) { - return null; - } - - const template = _util.NO_DATA_PAGE_TEMPLATE_PROPS.template; - const classes = (0, _util.getClasses)(template, className); - return /*#__PURE__*/_react.default.createElement(_eui.EuiPageTemplate, (0, _extends2.default)({ - "data-test-subj": props['data-test-subj'], - template: template, - className: classes - }, rest, _util.NO_DATA_PAGE_TEMPLATE_PROPS), /*#__PURE__*/_react.default.createElement(_no_data_page.NoDataPage, noDataConfig)); -} + { expect(component.find('div.kbnPageTemplate__pageSideBar').length).toBe(1); }); - // https://github.com/elastic/kibana/issues/127951 - test.skip('render noDataContent', () => { + test('render noDataContent', () => { const component = shallow( ) { + return Component.displayName || Component.name || 'UnnamedComponent'; +} + type SolutionNavProps = KibanaPageTemplateProps & { solutionNav: KibanaPageTemplateSolutionNavProps; }; @@ -70,6 +75,6 @@ export const withSolutionNav = (WrappedComponent: ComponentType ); }; - WithSolutionNav.displayName = `WithSolutionNavBar${WrappedComponent}`; + WithSolutionNav.displayName = `WithSolutionNavBar(${getDisplayName(WrappedComponent)})`; return WithSolutionNav; }; diff --git a/src/plugins/vis_types/timelion/kibana.json b/src/plugins/vis_types/timelion/kibana.json index 7f40f965e754b8..d1af2e9cd792ba 100644 --- a/src/plugins/vis_types/timelion/kibana.json +++ b/src/plugins/vis_types/timelion/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions", "charts"], + "requiredPlugins": ["visualizations", "data", "expressions", "charts", "dataViews"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { "name": "Vis Editors", diff --git a/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts b/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts index a25c3484d9ee8f..701da1d6a6fb7e 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts +++ b/src/plugins/vis_types/timelion/public/components/timelion_expression_input_helpers.test.ts @@ -9,11 +9,11 @@ import { SUGGESTION_TYPE, suggest } from './timelion_expression_input_helpers'; import { getArgValueSuggestions } from '../helpers/arg_value_suggestions'; import { setIndexPatterns } from '../helpers/plugin_services'; -import { IndexPatternsContract } from 'src/plugins/data/public'; +import { DataViewsContract } from 'src/plugins/data_views/public'; import { ITimelionFunction } from '../../common/types'; describe('Timelion expression suggestions', () => { - setIndexPatterns({} as IndexPatternsContract); + setIndexPatterns({} as DataViewsContract); const argValueSuggestions = getArgValueSuggestions(); diff --git a/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts index b2f60b4092bf7b..494135280343ee 100644 --- a/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_types/timelion/public/helpers/arg_value_suggestions.ts @@ -7,10 +7,11 @@ */ import { get } from 'lodash'; +import { isNestedField } from '../../../..//data_views/common'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; -import { indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES } from '../../../../data/public'; +import { KBN_FIELD_TYPES } from '../../../../data/public'; export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); @@ -71,9 +72,7 @@ export function getArgValueSuggestions() { .getByType(KBN_FIELD_TYPES.NUMBER) .filter( (field) => - field.aggregatable && - containsFieldName(valueSplit[1], field) && - !indexPatternsUtils.isNestedField(field) + field.aggregatable && containsFieldName(valueSplit[1], field) && !isNestedField(field) ) .map((field) => { const suggestionValue = field.name.replaceAll(':', '\\:'); @@ -103,7 +102,7 @@ export function getArgValueSuggestions() { KBN_FIELD_TYPES.STRING, ].includes(field.type as KBN_FIELD_TYPES) && containsFieldName(partial, field) && - !indexPatternsUtils.isNestedField(field) + !isNestedField(field) ) .map((field) => ({ name: field.name, help: field.type, insertText: field.name })); }, @@ -115,9 +114,7 @@ export function getArgValueSuggestions() { return indexPattern.fields .getByType(KBN_FIELD_TYPES.DATE) - .filter( - (field) => containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) - ) + .filter((field) => containsFieldName(partial, field) && !isNestedField(field)) .map((field) => ({ name: field.name, insertText: field.name })); }, }, diff --git a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts index c88097efa9bf1f..bcd618e6e832b2 100644 --- a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts +++ b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts @@ -6,12 +6,13 @@ * Side Public License, v 1. */ -import type { IndexPatternsContract, ISearchStart } from 'src/plugins/data/public'; +import type { ISearchStart } from 'src/plugins/data/public'; +import type { DataViewsContract } from 'src/plugins/data_views/public'; import type { ChartsPluginStart } from 'src/plugins/charts/public'; import { createGetterSetter } from '../../../../kibana_utils/public'; export const [getIndexPatterns, setIndexPatterns] = - createGetterSetter('IndexPatterns'); + createGetterSetter('dataViews'); export const [getDataSearch, setDataSearch] = createGetterSetter('Search'); diff --git a/src/plugins/vis_types/timelion/public/plugin.ts b/src/plugins/vis_types/timelion/public/plugin.ts index fb2b1df6f522e0..20a6857771cc43 100644 --- a/src/plugins/vis_types/timelion/public/plugin.ts +++ b/src/plugins/vis_types/timelion/public/plugin.ts @@ -21,6 +21,7 @@ import type { DataPublicPluginStart, TimefilterContract, } from 'src/plugins/data/public'; +import type { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import type { VisualizationsSetup } from 'src/plugins/visualizations/public'; import type { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; @@ -52,6 +53,7 @@ export interface TimelionVisSetupDependencies { /** @internal */ export interface TimelionVisStartDependencies { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; charts: ChartsPluginStart; } @@ -88,8 +90,8 @@ export class TimelionVisPlugin visualizations.createBaseVisualization(getTimelionVisDefinition(dependencies)); } - public start(core: CoreStart, { data, charts }: TimelionVisStartDependencies) { - setIndexPatterns(data.indexPatterns); + public start(core: CoreStart, { data, charts, dataViews }: TimelionVisStartDependencies) { + setIndexPatterns(dataViews); setDataSearch(data.search); setCharts(charts); diff --git a/src/plugins/vis_types/timelion/server/plugin.ts b/src/plugins/vis_types/timelion/server/plugin.ts index 37308e337ef465..12c2dabf62b58f 100644 --- a/src/plugins/vis_types/timelion/server/plugin.ts +++ b/src/plugins/vis_types/timelion/server/plugin.ts @@ -13,6 +13,7 @@ import type { PluginStart, DataRequestHandlerContext, } from '../../../../../src/plugins/data/server'; +import type { PluginStart as DataViewPluginStart } from '../../../../../src/plugins/data_views/server'; import { CoreSetup, PluginInitializerContext, Plugin } from '../../../../../src/core/server'; import { configSchema } from '../config'; import loadFunctions from './lib/load_functions'; @@ -23,6 +24,7 @@ import { getUiSettings } from './ui_settings'; export interface TimelionPluginStartDeps { data: PluginStart; + dataViews: DataViewPluginStart; } /** diff --git a/src/plugins/vis_types/timelion/server/routes/run.ts b/src/plugins/vis_types/timelion/server/routes/run.ts index d615302253a1d1..325c675011d13a 100644 --- a/src/plugins/vis_types/timelion/server/routes/run.ts +++ b/src/plugins/vis_types/timelion/server/routes/run.ts @@ -76,9 +76,9 @@ export function runRoute( }, }, router.handleLegacyErrors(async (context, request, response) => { - const [, { data }] = await core.getStartServices(); + const [, { dataViews }] = await core.getStartServices(); const uiSettings = await context.core.uiSettings.client.getAll(); - const indexPatternsService = await data.indexPatterns.indexPatternsServiceFactory( + const indexPatternsService = await dataViews.dataViewsServiceFactory( context.core.savedObjects.client, context.core.elasticsearch.client.asCurrentUser ); diff --git a/src/plugins/vis_types/timelion/tsconfig.json b/src/plugins/vis_types/timelion/tsconfig.json index 8613f381e5e4f9..3ce35e4ff1f5e8 100644 --- a/src/plugins/vis_types/timelion/tsconfig.json +++ b/src/plugins/vis_types/timelion/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../../core/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, { "path": "../../kibana_react/tsconfig.json" }, diff --git a/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts index e9f3be64079ac2..0d4b293a3242bb 100644 --- a/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts +++ b/src/plugins/vis_types/timeseries/common/index_patterns_utils.test.ts @@ -12,7 +12,7 @@ import { fetchIndexPattern, } from './index_patterns_utils'; import { Panel } from './types'; -import { IndexPattern, IndexPatternsService } from '../../../data/common'; +import type { DataView, DataViewsService } from '../../../data_views/public'; describe('isStringTypeIndexPattern', () => { test('should returns true on string-based index', () => { @@ -54,8 +54,8 @@ describe('extractIndexPatterns', () => { }); describe('fetchIndexPattern', () => { - let mockedIndices: IndexPattern[] | []; - let indexPatternsService: IndexPatternsService; + let mockedIndices: DataView[] | []; + let indexPatternsService: DataViewsService; beforeEach(() => { mockedIndices = []; @@ -64,7 +64,7 @@ describe('fetchIndexPattern', () => { getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), get: jest.fn(() => Promise.resolve(mockedIndices[0])), find: jest.fn(() => Promise.resolve(mockedIndices || [])), - } as unknown as IndexPatternsService; + } as unknown as DataViewsService; }); test('should return default index on no input value', async () => { @@ -87,7 +87,7 @@ describe('fetchIndexPattern', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await fetchIndexPattern('indexTitle', indexPatternsService, { fetchKibanaIndexForStringIndexes: true, @@ -125,7 +125,7 @@ describe('fetchIndexPattern', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await fetchIndexPattern({ id: 'indexId' }, indexPatternsService); diff --git a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts index 0a65e9e16d130b..4c4abf0023de03 100644 --- a/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_types/timeseries/common/index_patterns_utils.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import type { Panel, IndexPatternValue, FetchedIndexPattern } from '../common/types'; -import { IndexPatternsService } from '../../../data/common'; +import { DataViewsService } from '../../../data_views/common'; export const isStringTypeIndexPattern = ( indexPatternValue: IndexPatternValue @@ -45,7 +45,7 @@ export const extractIndexPatternValues = (panel: Panel, defaultIndexId?: string) export const fetchIndexPattern = async ( indexPatternValue: IndexPatternValue | undefined, - indexPatternsService: Pick, + indexPatternsService: Pick, options: { fetchKibanaIndexForStringIndexes: boolean; } = { diff --git a/src/plugins/vis_types/timeseries/common/types/index.ts b/src/plugins/vis_types/timeseries/common/types/index.ts index 001ea02eb355aa..2fa775765b4960 100644 --- a/src/plugins/vis_types/timeseries/common/types/index.ts +++ b/src/plugins/vis_types/timeseries/common/types/index.ts @@ -7,7 +7,8 @@ */ import { Filter } from '@kbn/es-query'; -import { IndexPattern, KBN_FIELD_TYPES, Query } from '../../../../data/common'; +import { KBN_FIELD_TYPES, Query } from '../../../../data/common'; +import type { DataView } from '../../../../data_views/public'; import { Panel } from './panel_model'; export type { Metric, Series, Panel, MetricType } from './panel_model'; @@ -22,7 +23,7 @@ export type { } from './vis_data'; export interface FetchedIndexPattern { - indexPattern: IndexPattern | undefined | null; + indexPattern: DataView | undefined | null; indexPatternString: string | undefined; } diff --git a/src/plugins/vis_types/timeseries/kibana.json b/src/plugins/vis_types/timeseries/kibana.json index 66c5b416a0d962..f5d808ab152d2e 100644 --- a/src/plugins/vis_types/timeseries/kibana.json +++ b/src/plugins/vis_types/timeseries/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "inspector"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "inspector", "dataViews"], "optionalPlugins": ["home","usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact", "fieldFormats"], "owner": { diff --git a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx index 562fb75089e194..b571c958e1a084 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/annotation_row.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { KBN_FIELD_TYPES, Query } from '../../../../../../plugins/data/public'; import { AddDeleteButtons } from './add_delete_buttons'; @@ -72,7 +72,7 @@ export const AnnotationRow = ({ useEffect(() => { const updateFetchedIndex = async (index: IndexPatternValue) => { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); let fetchedIndexPattern: IndexPatternSelectProps['fetchedIndex'] = { indexPattern: undefined, indexPatternString: undefined, @@ -80,12 +80,12 @@ export const AnnotationRow = ({ try { fetchedIndexPattern = index - ? await fetchIndexPattern(index, indexPatterns, { + ? await fetchIndexPattern(index, dataViews, { fetchKibanaIndexForStringIndexes: true, }) : { ...fetchedIndexPattern, - defaultIndex: await indexPatterns.getDefault(), + defaultIndex: await dataViews.getDefault(), }; } catch { // nothing to be here diff --git a/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx index 12e69e9043aae3..006682d4bfa376 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/annotations_editor.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; import { EuiSpacer, EuiTitle, EuiButton, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import { AnnotationRow } from './annotation_row'; import { collectionActions, CollectionActionsProps } from './lib/collection_actions'; @@ -22,10 +22,10 @@ interface AnnotationsEditorProps { fields: VisFields; model: Panel; onChange: (partialModel: Partial) => void; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } -export const newAnnotation = (defaultIndexPattern?: IndexPattern) => () => ({ +export const newAnnotation = (defaultIndexPattern?: DataView) => () => ({ id: uuid.v1(), color: '#F00', index_pattern: diff --git a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js index 7b3ae5f3e16ef7..26d8a91824a83c 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_types/timeseries/public/application/components/index_pattern.js @@ -36,7 +36,7 @@ import { isTimerangeModeEnabled } from '../../../common/check_ui_restrictions'; import { VisDataContext } from '../contexts/vis_data_context'; import { PanelModelContext } from '../contexts/panel_model_context'; import { FormValidationContext } from '../contexts/form_validation_context'; -import { getDataStart, getUISettings } from '../../services'; +import { getUISettings, getDataViewsStart } from '../../services'; import { UI_SETTINGS } from '../../../../../data/common'; import { fetchIndexPattern } from '../../../common/index_patterns_utils'; @@ -143,7 +143,7 @@ export const IndexPattern = ({ useEffect(() => { async function fetchIndex() { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); let fetchedIndexPattern = { indexPattern: undefined, indexPatternString: undefined, @@ -153,12 +153,12 @@ export const IndexPattern = ({ try { fetchedIndexPattern = indexPatternToFetch - ? await fetchIndexPattern(indexPatternToFetch, indexPatterns, { + ? await fetchIndexPattern(indexPatternToFetch, dataViews, { fetchKibanaIndexForStringIndexes: true, }) : { ...fetchedIndexPattern, - defaultIndex: await indexPatterns.getDefault(), + defaultIndex: await dataViews.getDefault(), }; } catch { // nothing to be here diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts index 08ee275d144ea3..8172d5d1a211b3 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.test.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; +import { DataView, DataViewField } from 'src/plugins/data_views/public'; import { PanelData } from '../../../../common/types'; import { TimeseriesVisParams } from '../../../types'; import { convertSeriesToDataTable, addMetaToColumns } from './convert_series_to_datatable'; @@ -14,7 +14,6 @@ jest.mock('../../../services', () => { return { getDataStart: jest.fn(() => { return { - indexPatterns: jest.fn(), query: { timefilter: { timefilter: { @@ -29,29 +28,30 @@ jest.mock('../../../services', () => { }, }; }), + getDataViewsStart: jest.fn(), }; }); describe('convert series to datatables', () => { - let indexPattern: IndexPattern; + let indexPattern: DataView; beforeEach(() => { - const fieldMap: Record = { - test1: { name: 'test1', spec: { type: 'date', name: 'test1' } } as IndexPatternField, + const fieldMap: Record = { + test1: { name: 'test1', spec: { type: 'date', name: 'test1' } } as DataViewField, test2: { name: 'test2', spec: { type: 'number', name: 'Average of test2' }, - } as IndexPatternField, - test3: { name: 'test3', spec: { type: 'boolean', name: 'test3' } } as IndexPatternField, + } as DataViewField, + test3: { name: 'test3', spec: { type: 'boolean', name: 'test3' } } as DataViewField, }; - const getFieldByName = (name: string): IndexPatternField | undefined => fieldMap[name]; + const getFieldByName = (name: string): DataViewField | undefined => fieldMap[name]; indexPattern = { id: 'index1', title: 'index1', timeFieldName: 'timestamp', getFieldByName, - } as IndexPattern; + } as DataView; }); describe('addMetaColumns()', () => { diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts index 62397e3b1d8c2b..3d4c1cb2a98702 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/convert_series_to_datatable.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from 'src/plugins/data_views/public'; import { DatatableRow, DatatableColumn, DatatableColumnType } from 'src/plugins/expressions/public'; import { Query } from 'src/plugins/data/common'; import { TimeseriesVisParams } from '../../../types'; @@ -13,7 +13,7 @@ import type { PanelData, Metric } from '../../../../common/types'; import { getMultiFieldLabel, getFieldsForTerms } from '../../../../common/fields_utils'; import { BUCKET_TYPES, TSVB_METRIC_TYPES } from '../../../../common/enums'; import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; -import { getDataStart } from '../../../services'; +import { getDataStart, getDataViewsStart } from '../../../services'; import { X_ACCESSOR_INDEX } from '../../visualizations/constants'; import type { TSVBTables } from './types'; @@ -33,7 +33,7 @@ interface TSVBColumns { export const addMetaToColumns = ( columns: TSVBColumns[], - indexPattern: IndexPattern + indexPattern: DataView ): DatatableColumn[] => { return columns.map((column) => { const field = indexPattern.getFieldByName(column.name); @@ -86,17 +86,17 @@ const hasSeriesAgg = (metrics: Metric[]) => { export const convertSeriesToDataTable = async ( model: TimeseriesVisParams, series: PanelData[], - initialIndexPattern: IndexPattern + initialIndexPattern: DataView ) => { const tables: TSVBTables = {}; - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { const layer = model.series[layerIdx]; let usedIndexPattern = initialIndexPattern; // The user can overwrite the index pattern of a layer. // In that case, the index pattern should be fetched again. if (layer.override_index_pattern) { - const { indexPattern } = await fetchIndexPattern(layer.series_index_pattern, indexPatterns); + const { indexPattern } = await fetchIndexPattern(layer.series_index_pattern, dataViews); if (indexPattern) { usedIndexPattern = indexPattern; } diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx index 6209de68cea8b4..be32104289d096 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/combo_box_select.tsx @@ -8,16 +8,16 @@ import React, { useCallback, useState, useEffect } from 'react'; import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui'; -import { getDataStart } from '../../../../services'; +import { getDataViewsStart } from '../../../../services'; import { SwitchModePopover } from './switch_mode_popover'; import type { SelectIndexComponentProps } from './types'; import type { IndexPatternValue } from '../../../../../common/types'; -import type { IndexPatternsService } from '../../../../../../../data/public'; +import type { DataViewsService } from '../../../../../../../data_views/public'; /** @internal **/ -type IdsWithTitle = Awaited>; +type IdsWithTitle = Awaited>; /** @internal **/ type SelectedOptions = EuiComboBoxProps['selectedOptions']; @@ -65,7 +65,7 @@ export const ComboBoxSelect = ({ useEffect(() => { async function fetchIndexes() { - setAvailableIndexes(await getDataStart().indexPatterns.getIdsWithTitle()); + setAvailableIndexes(await getDataViewsStart().getIdsWithTitle()); } fetchIndexes(); diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx index 6c095a9074bb70..6cc1c8d1e69681 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -19,7 +19,7 @@ import { ComboBoxSelect } from './combo_box_select'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; import { USE_KIBANA_INDEXES_KEY } from '../../../../../common/constants'; -import { IndexPattern } from '../../../../../../../data/common'; +import type { DataView } from '../../../../../../../data_views/public'; export interface IndexPatternSelectProps { indexPatternName: string; @@ -28,7 +28,7 @@ export interface IndexPatternSelectProps { allowIndexSwitchingMode?: boolean; fetchedIndex: | (FetchedIndexPattern & { - defaultIndex?: IndexPattern | null; + defaultIndex?: DataView | null; }) | null; } diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts index 244e95e8db9dd9..e922fd6c7ed0a2 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -7,12 +7,12 @@ */ import type { Assign } from '@kbn/utility-types'; import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; -import type { IndexPattern } from '../../../../../../../data/common'; +import type { DataView } from '../../../../../../../data_views/public'; /** @internal **/ export interface SelectIndexComponentProps { fetchedIndex: FetchedIndexPattern & { - defaultIndex?: IndexPattern | null; + defaultIndex?: DataView | null; }; onIndexChange: (value: IndexPatternValue) => void; onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; diff --git a/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js b/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js index 27a6f75e7f1f72..8dd92b97e0ee7e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js +++ b/src/plugins/vis_types/timeseries/public/application/components/markdown_editor.js @@ -20,7 +20,7 @@ import { CodeEditor, MarkdownLang } from '../../../../../kibana_react/public'; import { EuiText, EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { fetchIndexPattern } from '../../../common/index_patterns_utils'; export class MarkdownEditor extends Component { @@ -46,8 +46,8 @@ export class MarkdownEditor extends Component { }; async componentDidMount() { - const { indexPatterns } = getDataStart(); - const { indexPattern } = await fetchIndexPattern(this.props.model.index_pattern, indexPatterns); + const dataViews = getDataViewsStart(); + const { indexPattern } = await fetchIndexPattern(this.props.model.index_pattern, dataViews); this.setState({ fieldFormatMap: indexPattern?.fieldFormatMap }); } diff --git a/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts b/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts index ecbc5af601be7c..ec55121a14e9f2 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/panel_config/types.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; import { IUiSettingsClient } from 'kibana/public'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import type { TimeseriesVisData } from '../../../../common/types'; import { TimeseriesVisParams } from '../../../types'; import { VisFields } from '../../lib/fetch_fields'; @@ -19,7 +19,7 @@ export interface PanelConfigProps { visData$: Observable; getConfig: IUiSettingsClient['get']; onChange: (partialModel: Partial) => void; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } export enum PANEL_CONFIG_TABS { diff --git a/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx b/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx index 849415838c5e6a..ad6abe46ac859d 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/query_bar_wrapper.tsx @@ -12,7 +12,7 @@ import { CoreStartContext } from '../contexts/query_input_bar_context'; import type { IndexPatternValue } from '../../../common/types'; import { QueryStringInput, QueryStringInputProps } from '../../../../../../plugins/data/public'; -import { getDataStart } from '../../services'; +import { getDataViewsStart } from '../../services'; import { fetchIndexPattern, isStringTypeIndexPattern } from '../../../common/index_patterns_utils'; type QueryBarWrapperProps = Pick & { @@ -27,7 +27,7 @@ export function QueryBarWrapper({ indexPatterns, 'data-test-subj': dataTestSubj, }: QueryBarWrapperProps) { - const { indexPatterns: indexPatternsService } = getDataStart(); + const dataViews = getDataViewsStart(); const [indexes, setIndexes] = useState([]); const coreStartContext = useContext(CoreStartContext); @@ -41,14 +41,14 @@ export function QueryBarWrapper({ if (isStringTypeIndexPattern(index)) { i.push(index); } else if (index?.id) { - const { indexPattern } = await fetchIndexPattern(index, indexPatternsService); + const { indexPattern } = await fetchIndexPattern(index, dataViews); if (indexPattern) { i.push(indexPattern); } } } else { - const defaultIndex = await indexPatternsService.getDefault(); + const defaultIndex = await dataViews.getDefault(); if (defaultIndex) { i.push(defaultIndex); @@ -59,7 +59,7 @@ export function QueryBarWrapper({ } fetchIndexes(); - }, [indexPatterns, indexPatternsService]); + }, [indexPatterns, dataViews]); return ( { - fetchIndexPattern(model.index_pattern, getDataStart().indexPatterns).then( - (fetchedIndexPattern) => setIndexPattern(fetchedIndexPattern.indexPattern) + fetchIndexPattern(model.index_pattern, getDataViewsStart()).then((fetchedIndexPattern) => + setIndexPattern(fetchedIndexPattern.indexPattern) ); }, [model.index_pattern]); diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx index 59710cbcff6164..4b3d51ad5d42a7 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx @@ -12,7 +12,7 @@ import { share } from 'rxjs/operators'; import { isEqual, isEmpty, debounce } from 'lodash'; import { EventEmitter } from 'events'; import type { IUiSettingsClient } from 'kibana/public'; -import type { IndexPattern } from 'src/plugins/data/public'; +import type { DataView } from 'src/plugins/data_views/public'; import type { Vis, VisualizeEmbeddableContract, @@ -47,7 +47,7 @@ export interface TimeseriesEditorProps { query: EditorRenderProps['query']; uiState: EditorRenderProps['uiState']; vis: Vis; - defaultIndexPattern?: IndexPattern; + defaultIndexPattern?: DataView; } interface TimeseriesEditorState { diff --git a/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx b/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx index bdf265b37b26c2..01958e77c78d5b 100644 --- a/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx +++ b/src/plugins/vis_types/timeseries/public/application/editor_controller.tsx @@ -15,7 +15,7 @@ import type { IEditorController, EditorRenderProps, } from 'src/plugins/visualizations/public'; -import { getUISettings, getI18n, getCoreStart, getDataStart } from '../services'; +import { getUISettings, getI18n, getCoreStart, getDataViewsStart } from '../services'; import { VisEditor } from './components/vis_editor_lazy'; import type { TimeseriesVisParams } from '../types'; import { KibanaThemeProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -32,7 +32,7 @@ export class EditorController implements IEditorController { async render({ timeRange, uiState, filters, query }: EditorRenderProps) { const I18nContext = getI18n().Context; - const defaultIndexPattern = (await getDataStart().dataViews.getDefault()) || undefined; + const defaultIndexPattern = (await getDataViewsStart().getDefault()) || undefined; render( diff --git a/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts index 71e38be3025794..eaf913a8a85338 100644 --- a/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_types/timeseries/public/application/lib/fetch_fields.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { getCoreStart, getDataStart } from '../../services'; +import { getCoreStart, getDataViewsStart } from '../../services'; import { ROUTES } from '../../../common/constants'; import type { SanitizedFieldType, IndexPatternValue } from '../../../common/types'; import { getIndexPatternKey } from '../../../common/index_patterns_utils'; @@ -21,7 +21,6 @@ export async function fetchFields( ): Promise { const patterns = Array.isArray(indexes) ? indexes : [indexes]; const coreStart = getCoreStart(); - const dataStart = getDataStart(); const defaultIndex = coreStart.uiSettings.get('defaultIndex'); try { @@ -29,7 +28,7 @@ export async function fetchFields( patterns.map(async (pattern) => { if (typeof pattern !== 'string' && pattern?.id) { return toSanitizedFieldType( - (await dataStart.indexPatterns.get(pattern.id)).getNonScriptedFields() + (await getDataViewsStart().get(pattern.id)).getNonScriptedFields() ); } else { return coreStart.http.get(ROUTES.FIELDS, { diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.test.ts b/src/plugins/vis_types/timeseries/public/metrics_type.test.ts index f9eda5a18b79d9..d87dfa23f7dee7 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.test.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.test.ts @@ -7,36 +7,34 @@ */ import { cloneDeep } from 'lodash'; -import { DataViewsContract, IndexPattern } from 'src/plugins/data_views/public'; -import { setDataStart } from './services'; +import { DataView } from 'src/plugins/data_views/public'; +import { setDataViewsStart } from './services'; import type { TimeseriesVisParams } from './types'; import type { Vis } from 'src/plugins/visualizations/public'; import { metricsVisDefinition } from './metrics_type'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; describe('metricsVisDefinition', () => { describe('getUsedIndexPattern', () => { - const indexPattern1 = { id: '1', title: 'pattern1' } as unknown as IndexPattern; - const indexPattern2 = { id: '2', title: 'pattern2' } as unknown as IndexPattern; + const indexPattern1 = { id: '1', title: 'pattern1' } as unknown as DataView; + const indexPattern2 = { id: '2', title: 'pattern2' } as unknown as DataView; let defaultParams: TimeseriesVisParams; beforeEach(async () => { - setDataStart({ - indexPatterns: { - async getDefault() { - return indexPattern1; - }, - async find(title: string) { - if (title === 'pattern1') return [indexPattern1]; - if (title === 'pattern2') return [indexPattern2]; - return []; - }, - async get(id: string) { - if (id === '1') return indexPattern1; - if (id === '2') return indexPattern2; - throw new Error(); - }, - } as unknown as DataViewsContract, - } as DataPublicPluginStart); + setDataViewsStart({ + async getDefault() { + return indexPattern1; + }, + async find(title: string) { + if (title === 'pattern1') return [indexPattern1]; + if (title === 'pattern2') return [indexPattern2]; + return []; + }, + async get(id: string) { + if (id === '1') return indexPattern1; + if (id === '2') return indexPattern2; + throw new Error(); + }, + } as DataViewsPublicPluginStart); defaultParams = ( await metricsVisDefinition.setup!({ params: cloneDeep(metricsVisDefinition.visConfig.defaults), diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index 0c5b365938058f..7b1f6e4bd5eb35 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; -import { DataViewsContract, IndexPattern } from 'src/plugins/data_views/public'; +import type { DataViewsContract, DataView } from 'src/plugins/data_views/public'; import { TSVB_EDITOR_NAME } from './application/editor_controller'; import { PANEL_TYPES, TOOLTIP_MODES } from '../common/enums'; import { @@ -24,7 +24,7 @@ import { VisParams, VisTypeDefinition, } from '../../../visualizations/public'; -import { getDataStart } from './services'; +import { getDataViewsStart } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; import type { IndexPatternValue, Panel } from '../common/types'; import { RequestAdapter } from '../../../inspector/public'; @@ -55,9 +55,9 @@ export const withReplacedIds = ( async function withDefaultIndexPattern( vis: Vis ): Promise> { - const { indexPatterns } = getDataStart(); + const dataViews = getDataViewsStart(); - const defaultIndex = await indexPatterns.getDefault(); + const defaultIndex = await dataViews.getDefault(); if (!defaultIndex || !defaultIndex.id || vis.params.index_pattern) return vis; vis.params.index_pattern = { id: defaultIndex.id, @@ -79,16 +79,16 @@ async function resolveIndexPattern( } } -async function getUsedIndexPatterns(params: VisParams): Promise { - const { indexPatterns } = getDataStart(); +async function getUsedIndexPatterns(params: VisParams): Promise { + const dataViews = getDataViewsStart(); - const defaultIndex = await indexPatterns.getDefault(); - const resolvedIndexPatterns: IndexPattern[] = []; + const defaultIndex = await dataViews.getDefault(); + const resolvedIndexPatterns: DataView[] = []; const indexPatternValues = extractIndexPatternValues(params as Panel, defaultIndex?.id); ( await Promise.all( indexPatternValues.map((indexPatternValue) => - resolveIndexPattern(indexPatternValue, indexPatterns) + resolveIndexPattern(indexPatternValue, dataViews) ) ) ).forEach((patterns) => patterns && resolvedIndexPatterns.push(...patterns)); diff --git a/src/plugins/vis_types/timeseries/public/plugin.ts b/src/plugins/vis_types/timeseries/public/plugin.ts index 3370cc8a29275e..e669e9132b47a1 100644 --- a/src/plugins/vis_types/timeseries/public/plugin.ts +++ b/src/plugins/vis_types/timeseries/public/plugin.ts @@ -19,9 +19,11 @@ import { setFieldFormats, setCoreStart, setDataStart, + setDataViewsStart, setCharts, } from './services'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; import { ChartsPluginStart } from '../../../charts/public'; import { getTimeseriesVisRenderer } from './timeseries_vis_renderer'; @@ -34,6 +36,7 @@ export interface MetricsPluginSetupDependencies { /** @internal */ export interface MetricsPluginStartDependencies { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; charts: ChartsPluginStart; } @@ -58,11 +61,12 @@ export class MetricsPlugin implements Plugin { visualizations.createBaseVisualization(metricsVisDefinition); } - public start(core: CoreStart, { data, charts }: MetricsPluginStartDependencies) { + public start(core: CoreStart, { data, charts, dataViews }: MetricsPluginStartDependencies) { setCharts(charts); setI18n(core.i18n); setFieldFormats(data.fieldFormats); setDataStart(data); + setDataViewsStart(dataViews); setCoreStart(core); } } diff --git a/src/plugins/vis_types/timeseries/public/services.ts b/src/plugins/vis_types/timeseries/public/services.ts index f76a9ed7c63898..329d3642e0ce4b 100644 --- a/src/plugins/vis_types/timeseries/public/services.ts +++ b/src/plugins/vis_types/timeseries/public/services.ts @@ -10,6 +10,7 @@ import { I18nStart, IUiSettingsClient, CoreStart } from 'src/core/public'; import { createGetterSetter } from '../../../kibana_utils/public'; import { ChartsPluginStart } from '../../../charts/public'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -20,6 +21,9 @@ export const [getCoreStart, setCoreStart] = createGetterSetter('CoreS export const [getDataStart, setDataStart] = createGetterSetter('DataStart'); +export const [getDataViewsStart, setDataViewsStart] = + createGetterSetter('dataViews'); + export const [getI18n, setI18n] = createGetterSetter('I18n'); export const [getCharts, setCharts] = createGetterSetter('ChartsPluginStart'); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts index 5a3c545d80aa04..f9b02823804dff 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts @@ -20,14 +20,12 @@ const dataViewsMap: Record = { const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; jest.mock('../services', () => { return { - getDataStart: jest.fn(() => { + getDataViewsStart: jest.fn(() => { return { - dataViews: { - getDefault: jest.fn(() => { - return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; - }), - get: getDataview, - }, + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, }; }), }; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts index 0b4d6e6eacd3ab..b5c9addd814350 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts @@ -7,7 +7,7 @@ */ import { fetchIndexPattern, isStringTypeIndexPattern } from '../../common/index_patterns_utils'; import type { IndexPatternValue } from '../../common/types'; -import { getDataStart } from '../services'; +import { getDataViewsStart } from '../services'; export const getDataSourceInfo = async ( modelIndexPattern: IndexPatternValue, @@ -15,7 +15,7 @@ export const getDataSourceInfo = async ( isOverwritten: boolean, overwrittenIndexPattern: IndexPatternValue | undefined ) => { - const { dataViews } = getDataStart(); + const dataViews = getDataViewsStart(); let indexPatternId = modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : ''; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts index c71955942c91ca..9e508f895e9141 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts @@ -5,10 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { getDataStart } from '../services'; +import { getDataViewsStart } from '../services'; export const getFieldType = async (indexPatternId: string, fieldName: string) => { - const { dataViews } = getDataStart(); + const dataViews = getDataViewsStart(); const dataView = await dataViews.get(indexPatternId); const field = await dataView.getFieldByName(fieldName); return field?.type; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts index e12077c0239e5b..97189e4f9c8262 100644 --- a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts @@ -22,14 +22,12 @@ const dataViewsMap: Record = { const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; jest.mock('../services', () => { return { - getDataStart: jest.fn(() => { + getDataViewsStart: jest.fn(() => { return { - dataViews: { - getDefault: jest.fn(() => { - return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; - }), - get: getDataview, - }, + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, }; }), }; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts index fe2c4604c48f62..f407b224ac7cd9 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { DataView, DataViewsService } from 'src/plugins/data_views/common'; import { fetchIndexPattern } from '../../../../common/index_patterns_utils'; import { getCachedIndexPatternFetcher, @@ -16,7 +16,7 @@ import { jest.mock('../../../../common/index_patterns_utils'); describe('CachedIndexPatternFetcher', () => { - let mockedIndices: IndexPattern[] | []; + let mockedIndices: DataView[] | []; let cachedIndexPatternFetcher: CachedIndexPatternFetcher; beforeEach(() => { @@ -26,7 +26,7 @@ describe('CachedIndexPatternFetcher', () => { getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), get: jest.fn(() => Promise.resolve(mockedIndices[0])), find: jest.fn(() => Promise.resolve(mockedIndices || [])), - } as unknown as IndexPatternsService; + } as unknown as DataViewsService; (fetchIndexPattern as jest.Mock).mockClear(); @@ -74,7 +74,7 @@ describe('CachedIndexPatternFetcher', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; const value = await cachedIndexPatternFetcher({ id: 'indexId' }); @@ -106,7 +106,7 @@ describe('CachedIndexPatternFetcher', () => { id: 'indexId', title: 'indexTitle', }, - ] as IndexPattern[]; + ] as DataView[]; await cachedIndexPatternFetcher({ id: 'indexId' }); await cachedIndexPatternFetcher({ id: 'indexId' }); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts index 2a3738878c97ad..9bab0e520e74bd 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -8,11 +8,11 @@ import { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_patterns_utils'; -import type { IndexPatternsService } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; export const getCachedIndexPatternFetcher = ( - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, globalOptions: { fetchKibanaIndexForStringIndexes: boolean; } = { diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index cf7bc42fc6db38..788ce2ff44a3c5 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -10,12 +10,12 @@ import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; import type { SearchStrategy, SearchCapabilities } from '../index'; -import type { IndexPatternsService } from '../../../../../../data/common'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; import type { IndexPatternValue } from '../../../../common/types'; export interface FieldsFetcherServices { - indexPatternsService: IndexPatternsService; + indexPatternsService: DataViewsService; cachedIndexPatternFetcher: CachedIndexPatternFetcher; searchStrategy: SearchStrategy; capabilities: SearchCapabilities; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index f52d1bd9b74273..8677a44941c439 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { IndexPatternsService } from '../../../../../../data/common'; +import { DataViewsService } from '../../../../../../data_views/common'; import { from } from 'rxjs'; import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; @@ -56,7 +56,7 @@ describe('AbstractSearchStrategy', () => { { getDefault: jest.fn(), getFieldsForWildcard: jest.fn(() => Promise.resolve(mockedFields)), - } as unknown as IndexPatternsService, + } as unknown as DataViewsService, (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher ); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 58c67f84a9373f..de770b30fb823d 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -8,7 +8,7 @@ import { tap } from 'rxjs/operators'; import { omit } from 'lodash'; import type { Observable } from 'rxjs'; -import { IndexPatternsService } from '../../../../../../data/server'; +import { DataViewsService } from '../../../../../../data_views/common'; import { toSanitizedFieldType } from '../../../../common/fields_utils'; import type { FetchedIndexPattern, TrackedEsSearches } from '../../../../common/types'; @@ -90,7 +90,7 @@ export abstract class AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, capabilities?: unknown, options?: Partial<{ type: string; diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index ff1c3c0ac71ee9..9b683b87c90c79 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -9,7 +9,7 @@ import { AbstractSearchStrategy } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; -import type { IndexPatternsService } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { FetchedIndexPattern } from '../../../../common/types'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -36,7 +36,7 @@ export class DefaultSearchStrategy extends AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, capabilities?: unknown ) { return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities); diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index b74bb97c3d649f..32adce54fc597e 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -8,7 +8,7 @@ import { RollupSearchStrategy } from './rollup_search_strategy'; -import type { IndexPatternsService } from '../../../../../../data/common'; +import type { DataViewsService } from '../../../../../../data_views/common'; import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -156,7 +156,7 @@ describe('Rollup Search Strategy', () => { test('should return fields for wildcard', async () => { const fields = await rollupSearchStrategy.getFieldsForWildcard( { indexPatternString: 'indexPattern', indexPattern: undefined }, - {} as IndexPatternsService, + {} as DataViewsService, (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher, { fieldsCapabilities, diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 4d6d9d832a2e00..32c9952e235d59 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import { - getCapabilitiesForRollupIndices, - IndexPatternsService, -} from '../../../../../../data/server'; +import { getCapabilitiesForRollupIndices } from '../../../../../../data/server'; +import type { DataViewsService } from '../../../../../../data_views/common'; import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; @@ -93,7 +91,7 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { async getFieldsForWildcard( fetchedIndexPattern: FetchedIndexPattern, - indexPatternsService: IndexPatternsService, + indexPatternsService: DataViewsService, getCachedIndexPatternFetcher: CachedIndexPatternFetcher, capabilities?: unknown ) { diff --git a/src/plugins/vis_types/timeseries/server/plugin.ts b/src/plugins/vis_types/timeseries/server/plugin.ts index 248016f1a98369..36c8558afcd080 100644 --- a/src/plugins/vis_types/timeseries/server/plugin.ts +++ b/src/plugins/vis_types/timeseries/server/plugin.ts @@ -23,7 +23,8 @@ import { getVisData } from './lib/get_vis_data'; import { UsageCollectionSetup } from '../../../usage_collection/server'; import { HomeServerPluginSetup } from '../../../home/server'; import { PluginStart } from '../../../data/server'; -import { IndexPatternsService } from '../../../data/common'; +import type { DataViewsService } from '../../../data_views/common'; +import type { PluginStart as DataViewsPublicPluginStart } from '../../../data_views/server'; import { visDataRoutes } from './routes/vis'; import { fieldsRoutes } from './routes/fields'; import { getUiSettings } from './ui_settings'; @@ -53,6 +54,7 @@ interface VisTypeTimeseriesPluginSetupDependencies { interface VisTypeTimeseriesPluginStartDependencies { data: PluginStart; + dataViews: DataViewsPublicPluginStart; } export interface VisTypeTimeseriesSetup { @@ -73,7 +75,7 @@ export interface Framework { searchStrategyRegistry: SearchStrategyRegistry; getIndexPatternsService: ( requestContext: VisTypeTimeseriesRequestHandlerContext - ) => Promise; + ) => Promise; getFieldFormatsService: (uiSettings: IUiSettingsClient) => Promise; getEsShardTimeout: () => Promise; } @@ -109,9 +111,9 @@ export class VisTypeTimeseriesPlugin implements Plugin { ) .toPromise(), getIndexPatternsService: async (requestContext) => { - const [, { data }] = await core.getStartServices(); + const [, { dataViews }] = await core.getStartServices(); - return await data.indexPatterns.indexPatternsServiceFactory( + return await dataViews.dataViewsServiceFactory( requestContext.core.savedObjects.client, requestContext.core.elasticsearch.client.asCurrentUser ); diff --git a/src/plugins/vis_types/timeseries/server/types.ts b/src/plugins/vis_types/timeseries/server/types.ts index ab01f09c75f1ed..8eeb4b5a68f894 100644 --- a/src/plugins/vis_types/timeseries/server/types.ts +++ b/src/plugins/vis_types/timeseries/server/types.ts @@ -10,7 +10,8 @@ import { Observable } from 'rxjs'; import { EsQueryConfig } from '@kbn/es-query'; import { SharedGlobalConfig } from 'kibana/server'; import type { IRouter, IUiSettingsClient, KibanaRequest } from 'src/core/server'; -import type { DataRequestHandlerContext, IndexPatternsService } from '../../../data/server'; +import type { DataViewsService } from '../../../data_views/common'; +import type { DataRequestHandlerContext } from '../../../data/server'; import type { FieldFormatsRegistry } from '../../../field_formats/common'; import type { Series, VisPayload } from '../common/types'; import type { SearchStrategyRegistry } from './lib/search_strategies'; @@ -31,7 +32,7 @@ export interface VisTypeTimeseriesRequestServices { esShardTimeout: number; esQueryConfig: EsQueryConfig; uiSettings: IUiSettingsClient; - indexPatternsService: IndexPatternsService; + indexPatternsService: DataViewsService; searchStrategyRegistry: SearchStrategyRegistry; cachedIndexPatternFetcher: CachedIndexPatternFetcher; fieldFormatService: FieldFormatsRegistry; diff --git a/src/plugins/vis_types/timeseries/tsconfig.json b/src/plugins/vis_types/timeseries/tsconfig.json index 4462beae8c7be4..87d85626c7ab1e 100644 --- a/src/plugins/vis_types/timeseries/tsconfig.json +++ b/src/plugins/vis_types/timeseries/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../../core/tsconfig.json" }, { "path": "../../charts/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../dashboard/tsconfig.json" }, diff --git a/src/plugins/vis_types/vega/kibana.json b/src/plugins/vis_types/vega/kibana.json index cedd73cc6d3989..d3e0da54d848f4 100644 --- a/src/plugins/vis_types/vega/kibana.json +++ b/src/plugins/vis_types/vega/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "visualizations", "mapsEms", "expressions", "inspector"], + "requiredPlugins": ["data", "visualizations", "mapsEms", "expressions", "inspector", "dataViews"], "optionalPlugins": ["home","usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.test.ts b/src/plugins/vis_types/vega/public/data_model/search_api.test.ts index 979f7f05cdf1d8..4ca87386e4054b 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.test.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.test.ts @@ -7,16 +7,17 @@ */ import { extendSearchParamsWithRuntimeFields } from './search_api'; -import { dataPluginMock } from '../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../data_views/public/mocks'; -import { getSearchParamsFromRequest, DataPublicPluginStart } from '../../../../data/public'; +import { getSearchParamsFromRequest } from '../../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../../data_views/public'; const mockComputedFields = ( - dataStart: DataPublicPluginStart, + dataViewsStart: DataViewsPublicPluginStart, index: string, runtimeFields: Record ) => { - dataStart.indexPatterns.find = jest.fn().mockReturnValue([ + dataViewsStart.find = jest.fn().mockReturnValue([ { title: index, getComputedFields: () => ({ @@ -28,21 +29,20 @@ const mockComputedFields = ( }; describe('extendSearchParamsWithRuntimeFields', () => { - let dataStart: DataPublicPluginStart; + let dataViewsStart: DataViewsPublicPluginStart; beforeEach(() => { - dataStart = dataPluginMock.createStartContract(); + dataViewsStart = dataViewPluginMocks.createStartContract(); }); test('should inject default runtime_mappings for known indexes', async () => { const requestParams = {}; const runtimeFields = { foo: {} }; - mockComputedFields(dataStart, 'index', runtimeFields); + mockComputedFields(dataViewsStart, 'index', runtimeFields); - expect( - await extendSearchParamsWithRuntimeFields(dataStart.indexPatterns, requestParams, 'index') - ).toMatchInlineSnapshot(` + expect(await extendSearchParamsWithRuntimeFields(dataViewsStart, requestParams, 'index')) + .toMatchInlineSnapshot(` Object { "body": Object { "runtime_mappings": Object { @@ -63,11 +63,10 @@ describe('extendSearchParamsWithRuntimeFields', () => { } as unknown as ReturnType; const runtimeFields = { foo: {} }; - mockComputedFields(dataStart, 'index', runtimeFields); + mockComputedFields(dataViewsStart, 'index', runtimeFields); - expect( - await extendSearchParamsWithRuntimeFields(dataStart.indexPatterns, requestParams, 'index') - ).toMatchInlineSnapshot(` + expect(await extendSearchParamsWithRuntimeFields(dataViewsStart, requestParams, 'index')) + .toMatchInlineSnapshot(` Object { "body": Object { "runtime_mappings": Object { diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index 6a7ee55b299d01..19889edf11a82a 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -15,6 +15,7 @@ import { DataPublicPluginStart, IEsSearchResponse, } from '../../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../../data_views/public'; import { search as dataPluginSearch } from '../../../../data/public'; import type { VegaInspectorAdapters } from '../vega_inspector'; import type { RequestResponder } from '../../../../inspector/public'; @@ -48,7 +49,7 @@ export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; injectedMetadata: CoreStart['injectedMetadata']; search: DataPublicPluginStart['search']; - indexPatterns: DataPublicPluginStart['indexPatterns']; + indexPatterns: DataViewsPublicPluginStart; } export class SearchAPI { diff --git a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts index 8d9ab0e10ebc98..70a03a5de995fd 100644 --- a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts +++ b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.test.ts @@ -6,19 +6,19 @@ * Side Public License, v 1. */ -import { dataPluginMock } from '../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../data_views/public/mocks'; import { extractIndexPatternsFromSpec } from './extract_index_pattern'; -import { setData } from '../services'; +import { setDataViews } from '../services'; import type { VegaSpec } from '../data_model/types'; const getMockedSpec = (mockedObj: any) => mockedObj as unknown as VegaSpec; describe('extractIndexPatternsFromSpec', () => { - const dataStart = dataPluginMock.createStartContract(); + const dataViewsStart = dataViewPluginMocks.createStartContract(); beforeAll(() => { - setData(dataStart); + setDataViews(dataViewsStart); }); test('should not throw errors if no index is specified', async () => { diff --git a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts index 0d25db665ce7f5..a2d22dce614cb7 100644 --- a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts +++ b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts @@ -7,13 +7,13 @@ */ import { flatten } from 'lodash'; -import { getData } from '../services'; +import { getDataViews } from '../services'; import type { Data, VegaSpec } from '../data_model/types'; -import type { IndexPattern } from '../../../../data/public'; +import type { DataView } from '../../../../data_views/public'; export const extractIndexPatternsFromSpec = async (spec: VegaSpec) => { - const { indexPatterns } = getData(); + const dataViews = getDataViews(); let data: Data[] = []; if (Array.isArray(spec.data)) { @@ -22,11 +22,11 @@ export const extractIndexPatternsFromSpec = async (spec: VegaSpec) => { data = [spec.data]; } - return flatten( + return flatten( await Promise.all( - data.reduce>>((accumulator, currentValue) => { + data.reduce>>((accumulator, currentValue) => { if (currentValue.url?.index) { - accumulator.push(indexPatterns.find(currentValue.url.index)); + accumulator.push(dataViews.find(currentValue.url.index)); } return accumulator; diff --git a/src/plugins/vis_types/vega/public/plugin.ts b/src/plugins/vis_types/vega/public/plugin.ts index bf08b464adccd6..e33f6141e7358a 100644 --- a/src/plugins/vis_types/vega/public/plugin.ts +++ b/src/plugins/vis_types/vega/public/plugin.ts @@ -9,12 +9,14 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; import { Plugin as ExpressionsPublicPlugin } from '../../../expressions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../data/public'; +import type { DataViewsPublicPluginStart } from '../../../data_views/public'; import { VisualizationsSetup } from '../../../visualizations/public'; import { Setup as InspectorSetup } from '../../../inspector/public'; import { setNotifications, setData, + setDataViews, setInjectedVars, setUISettings, setInjectedMetadata, @@ -54,6 +56,7 @@ export interface VegaPluginSetupDependencies { export interface VegaPluginStartDependencies { data: DataPublicPluginStart; mapsEms: MapsEmsPluginPublicStart; + dataViews: DataViewsPublicPluginStart; } /** @internal */ @@ -91,9 +94,10 @@ export class VegaPlugin implements Plugin { visualizations.createBaseVisualization(createVegaTypeDefinition()); } - public start(core: CoreStart, { data, mapsEms }: VegaPluginStartDependencies) { + public start(core: CoreStart, { data, mapsEms, dataViews }: VegaPluginStartDependencies) { setNotifications(core.notifications); setData(data); + setDataViews(dataViews); setInjectedMetadata(core.injectedMetadata); setDocLinks(core.docLinks); setMapsEms(mapsEms); diff --git a/src/plugins/vis_types/vega/public/services.ts b/src/plugins/vis_types/vega/public/services.ts index 5f340c1fb972bf..af162c204acda9 100644 --- a/src/plugins/vis_types/vega/public/services.ts +++ b/src/plugins/vis_types/vega/public/services.ts @@ -9,11 +9,15 @@ import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; +import { DataViewsPublicPluginStart } from '../../../data_views/public'; import { createGetterSetter } from '../../../kibana_utils/public'; import type { MapsEmsPluginPublicStart } from '../../../maps_ems/public'; export const [getData, setData] = createGetterSetter('Data'); +export const [getDataViews, setDataViews] = + createGetterSetter('DataViews'); + export const [getNotifications, setNotifications] = createGetterSetter('Notifications'); diff --git a/src/plugins/vis_types/vega/public/vega_request_handler.ts b/src/plugins/vis_types/vega/public/vega_request_handler.ts index bf4aeb790c9f1e..f632c8e93965c9 100644 --- a/src/plugins/vis_types/vega/public/vega_request_handler.ts +++ b/src/plugins/vis_types/vega/public/vega_request_handler.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import type { KibanaExecutionContext } from 'src/core/public'; -import { DataView } from 'src/plugins/data/common'; +import type { DataView } from 'src/plugins/data_views/common'; import { Filter, buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig, TimeRange, Query } from '../../../data/public'; @@ -15,7 +15,7 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; -import { getData, getInjectedMetadata } from './services'; +import { getData, getInjectedMetadata, getDataViews } from './services'; import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { @@ -48,7 +48,8 @@ export function createVegaRequestHandler( searchSessionId, executionContext, }: VegaRequestHandlerParams) { - const { dataViews, search } = getData(); + const { search } = getData(); + const dataViews = getDataViews(); if (!searchAPI) { searchAPI = new SearchAPI( diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js index c6ec924f07d9d3..de2e0f57a111b7 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js @@ -18,7 +18,7 @@ import { i18n } from '@kbn/i18n'; import { buildQueryFilter, compareFilters } from '@kbn/es-query'; import { TooltipHandler } from './vega_tooltip'; -import { getEnableExternalUrls, getData } from '../services'; +import { getEnableExternalUrls, getDataViews } from '../services'; import { extractIndexPatternsFromSpec } from '../lib/extract_index_pattern'; scheme('elastic', euiPaletteColorBlind()); @@ -156,11 +156,11 @@ export class VegaBaseView { * @returns {Promise} index id */ async findIndex(index) { - const { indexPatterns } = getData(); + const dataViews = getDataViews(); let idxObj; if (index) { - [idxObj] = await indexPatterns.find(index); + [idxObj] = await dataViews.find(index); if (!idxObj) { throw new Error( i18n.translate('visTypeVega.vegaParser.baseView.indexNotFoundErrorMessage', { @@ -175,7 +175,7 @@ export class VegaBaseView { ); if (!idxObj) { - const defaultIdx = await indexPatterns.getDefault(); + const defaultIdx = await dataViews.getDefault(); if (defaultIdx) { idxObj = defaultIdx; diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts index 963fc1751396a2..a09e92fe7dd802 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts @@ -16,9 +16,17 @@ import { SearchAPI } from '../../data_model/search_api'; import vegaMap from '../../test_utils/vega_map_test.json'; import { coreMock } from '../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../../../data_views/public/mocks'; + import type { IServiceSettings } from '../vega_map_view/service_settings/service_settings_types'; -import { setInjectedVars, setData, setNotifications, setUISettings } from '../../services'; +import { + setInjectedVars, + setData, + setNotifications, + setUISettings, + setDataViews, +} from '../../services'; import { initVegaLayer, initTmsRasterLayer } from './layers'; import { mapboxgl } from '@kbn/mapbox-gl'; @@ -59,6 +67,7 @@ describe('vega_map_view/view', () => { const coreStart = coreMock.createStart(); const dataPluginStart = dataPluginMock.createStartContract(); + const dataViewsStart = dataViewPluginMocks.createStartContract(); const mockGetServiceSettings = async () => { return { getAttributionsFromTMSServce() { @@ -98,6 +107,7 @@ describe('vega_map_view/view', () => { enableExternalUrls: true, }); setData(dataPluginStart); + setDataViews(dataViewsStart); setNotifications(coreStart.notifications); setUISettings(coreStart.uiSettings); @@ -125,7 +135,7 @@ describe('vega_map_view/view', () => { JSON.stringify(vegaMap), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), diff --git a/src/plugins/vis_types/vega/public/vega_visualization.test.js b/src/plugins/vis_types/vega/public/vega_visualization.test.js index 2412e26014e51e..af5b6e0d61ada9 100644 --- a/src/plugins/vis_types/vega/public/vega_visualization.test.js +++ b/src/plugins/vis_types/vega/public/vega_visualization.test.js @@ -21,12 +21,12 @@ import { SearchAPI } from './data_model/search_api'; import { setInjectedVars, setData, setNotifications } from './services'; import { coreMock } from '../../../../core/public/mocks'; import { dataPluginMock } from '../../../data/public/mocks'; +import { dataViewPluginMocks } from '../../../data_views/public/mocks'; jest.mock('./default_spec', () => ({ getDefaultSpec: () => jest.requireActual('./test_utils/default.spec.json'), })); -// FLAKY: https://github.com/elastic/kibana/issues/71713 describe('VegaVisualizations', () => { let domNode; let VegaVisualization; @@ -39,6 +39,7 @@ describe('VegaVisualizations', () => { const coreStart = coreMock.createStart(); const dataPluginStart = dataPluginMock.createStartContract(); + const dataViewsPluginStart = dataViewPluginMocks.createStartContract(); const setupDOM = (width = 512, height = 512) => { mockedWidthValue = width; @@ -94,7 +95,7 @@ describe('VegaVisualizations', () => { JSON.stringify(vegaliteGraph), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), @@ -127,7 +128,7 @@ describe('VegaVisualizations', () => { JSON.stringify(vegaGraph), new SearchAPI({ search: dataPluginStart.search, - indexPatterns: dataPluginStart.indexPatterns, + indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, injectedMetadata: coreStart.injectedMetadata, }), diff --git a/src/plugins/vis_types/vega/tsconfig.json b/src/plugins/vis_types/vega/tsconfig.json index ed7690ac70d1a1..ccb4bbfb344540 100644 --- a/src/plugins/vis_types/vega/tsconfig.json +++ b/src/plugins/vis_types/vega/tsconfig.json @@ -17,6 +17,7 @@ "references": [ { "path": "../../../core/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../data_views/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../maps_ems/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 79b04f132077bc..3a4dc81ed8df75 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -13,7 +13,8 @@ "inspector", "savedObjects", "screenshotMode", - "presentationUtil" + "presentationUtil", + "dataViews" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact", "home"], diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index c72b8618dc199b..b5cd655e712e92 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -24,7 +24,7 @@ import { getUISettings, getHttp, getTimeFilter, getCapabilities } from '../servi import { urlFor } from '../utils/saved_visualize_utils'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; -import { IndexPattern } from '../../../data/public'; +import type { DataView } from '../../../data_views/public'; import { createVisualizeEmbeddableAsync } from './visualize_embeddable_async'; export const createVisEmbeddableFromObject = @@ -51,7 +51,7 @@ export const createVisEmbeddableFromObject = return new DisabledLabEmbeddable(vis.title, input); } - let indexPatterns: IndexPattern[] = []; + let indexPatterns: DataView[] = []; if (vis.type.getUsedIndexPattern) { indexPatterns = await vis.type.getUsedIndexPattern(vis.params); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index 3d3c98ce4aaeae..7854012bf61fe9 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -16,12 +16,8 @@ import { Filter, onlyDisabledFiltersChanged } from '@kbn/es-query'; import type { SavedObjectAttributes, KibanaExecutionContext } from 'kibana/public'; import { KibanaThemeProvider } from '../../../kibana_react/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; -import { - IndexPattern, - TimeRange, - Query, - TimefilterContract, -} from '../../../../plugins/data/public'; +import { TimeRange, Query, TimefilterContract } from '../../../../plugins/data/public'; +import type { DataView } from '../../../../plugins/data_views/public'; import { EmbeddableInput, EmbeddableOutput, @@ -51,7 +47,7 @@ const getKeys = (o: T): Array => Object.keys(o) as Array< export interface VisualizeEmbeddableConfiguration { vis: Vis; - indexPatterns?: IndexPattern[]; + indexPatterns?: DataView[]; editPath: string; editUrl: string; capabilities: { visualizeSave: boolean; dashboardSave: boolean }; @@ -74,7 +70,7 @@ export interface VisualizeOutput extends EmbeddableOutput { editPath: string; editApp: string; editUrl: string; - indexPatterns?: IndexPattern[]; + indexPatterns?: DataView[]; visTypeName: string; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 69a7c61e688936..901ca34c345212 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -15,6 +15,7 @@ import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; import { dataPluginMock } from '../../../plugins/data/public/mocks'; +import { dataViewPluginMocks } from '../../../plugins/data_views/public/mocks'; import { usageCollectionPluginMock } from '../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; @@ -56,6 +57,7 @@ const createInstance = async () => { const doStart = () => plugin.start(coreMock.createStart(), { data: dataPluginMock.createStartContract(), + dataViews: dataViewPluginMocks.createStartContract(), expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 88b9d35d5255f9..997d78b31163d0 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -79,6 +79,7 @@ import type { Start as InspectorStart, } from '../../../plugins/inspector/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '../../../plugins/data/public'; +import type { DataViewsPublicPluginStart } from '../../../plugins/data_views/public'; import type { ExpressionsSetup, ExpressionsStart } from '../../expressions/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; @@ -117,6 +118,7 @@ export interface VisualizationsSetupDeps { export interface VisualizationsStartDeps { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; embeddable: EmbeddableStart; inspector: InspectorStart; @@ -237,10 +239,10 @@ export class VisualizationsPlugin }; // make sure the index pattern list is up to date - pluginsStart.data.indexPatterns.clearCache(); + pluginsStart.dataViews.clearCache(); // make sure a default index pattern exists // if not, the page will be redirected to management and visualize won't be rendered - await pluginsStart.data.indexPatterns.ensureDefaultDataView(); + await pluginsStart.dataViews.ensureDefaultDataView(); appMounted(); @@ -268,6 +270,7 @@ export class VisualizationsPlugin pluginInitializerContext: this.initializerContext, chrome: coreStart.chrome, data: pluginsStart.data, + dataViews: pluginsStart.dataViews, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, share: pluginsStart.share, diff --git a/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts index 31713d8ad7d5ee..5e9af25e58249b 100644 --- a/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts +++ b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; -import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; const isControlsVis = (visType: string) => visType === 'input_control_vis'; @@ -26,7 +26,7 @@ export const extractControlsReferences = ( control.indexPatternRefName = `${prefix}_${i}_index_pattern`; references.push({ name: control.indexPatternRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; diff --git a/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts index a3917699fcab36..36217579b88a67 100644 --- a/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts +++ b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts @@ -8,7 +8,7 @@ import { SavedObjectReference } from '../../../../../core/types'; import { VisParams } from '../../../common'; -import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; /** @internal **/ const REF_NAME_POSTFIX = '_ref_name'; @@ -49,7 +49,7 @@ export const extractTimeSeriesReferences = ( object[key + REF_NAME_POSTFIX] = name; references.push({ name, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: object[key].id, }); delete object[key]; diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index 6aa70c080f8e7c..47a667a4f36b9b 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -22,7 +22,8 @@ import { i18n } from '@kbn/i18n'; import { PersistedState } from './persisted_state'; import { getTypes, getAggs, getSearch, getSavedObjects, getSpaces } from './services'; -import { IAggConfigs, IndexPattern, ISearchSource, AggConfigSerialized } from '../../data/public'; +import { IAggConfigs, ISearchSource, AggConfigSerialized } from '../../data/public'; +import type { DataView } from '../../data_views/public'; import { BaseVisType } from './vis_types'; import { SerializedVis, SerializedVisData, VisParams } from '../common/types'; @@ -33,7 +34,7 @@ export type { SerializedVis, SerializedVisData }; export interface VisData { ast?: string; aggs?: IAggConfigs; - indexPattern?: IndexPattern; + indexPattern?: DataView; searchSource?: ISearchSource; savedSearchId?: string; } diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 834c781b3b8280..14da9eb887e0ce 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -9,13 +9,8 @@ import type { IconType } from '@elastic/eui'; import type { ReactNode } from 'react'; import type { Adapters } from 'src/plugins/inspector'; -import type { - IndexPattern, - AggGroupNames, - AggParam, - AggGroupName, - Query, -} from '../../../data/public'; +import type { AggGroupNames, AggParam, AggGroupName, Query } from '../../../data/public'; +import type { DataView } from '../../../data_views/public'; import { PaletteOutput } from '../../../charts/public'; import type { Vis, VisEditorOptionsProps, VisParams, VisToExpressionAst } from '../types'; import { VisGroups } from './vis_groups_enum'; @@ -181,7 +176,7 @@ export interface VisTypeDefinition { * Some visualizations are created without SearchSource and may change the used indexes during the visualization configuration. * Using this method we can rewrite the standard mechanism for getting used indexes */ - readonly getUsedIndexPattern?: (visParams: VisParams) => IndexPattern[] | Promise; + readonly getUsedIndexPattern?: (visParams: VisParams) => DataView[] | Promise; readonly isAccessible?: boolean; /** diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 245441d26f3f07..276c99ec4ca5cf 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -20,7 +20,7 @@ import { } from '../types'; import { VISUALIZE_APP_NAME } from '../../../common/constants'; import { getTopNavConfig } from '../utils'; -import type { IndexPattern } from '../../../../data/public'; +import type { DataView } from '../../../../data_views/public'; import type { NavigateToLensContext } from '../../../../visualizations/public'; const LOCAL_STORAGE_EDIT_IN_LENS_BADGE = 'EDIT_IN_LENS_BADGE_VISIBLE'; @@ -151,7 +151,7 @@ const TopNav = ({ hideLensBadge, hideTryInLensBadge, ]); - const [indexPatterns, setIndexPatterns] = useState( + const [indexPatterns, setIndexPatterns] = useState( vis.data.indexPattern ? [vis.data.indexPattern] : [] ); const showDatePicker = () => { @@ -210,13 +210,13 @@ const TopNav = ({ useEffect(() => { const asyncSetIndexPattern = async () => { - let indexes: IndexPattern[] | undefined; + let indexes: DataView[] | undefined; if (vis.type.getUsedIndexPattern) { indexes = await vis.type.getUsedIndexPattern(vis.params); } if (!indexes || !indexes.length) { - const defaultIndex = await services.data.indexPatterns.getDefault(); + const defaultIndex = await services.dataViews.getDefault(); if (defaultIndex) { indexes = [defaultIndex]; } @@ -229,7 +229,7 @@ const TopNav = ({ if (!vis.data.indexPattern) { asyncSetIndexPattern(); } - }, [vis.params, vis.type, services.data.indexPatterns, vis.data.indexPattern]); + }, [vis.params, vis.type, vis.data.indexPattern, services.dataViews]); useEffect(() => { const autoRefreshFetchSub = services.data.query.timefilter.timefilter diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index a414dd2e61762b..7c4c8155a94054 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -37,6 +37,7 @@ import type { import type { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import type { Filter } from '@kbn/es-query'; import type { Query, DataPublicPluginStart, TimeRange } from 'src/plugins/data/public'; +import type { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; @@ -89,6 +90,7 @@ export interface VisualizeServices extends CoreStart { pluginInitializerContext: PluginInitializerContext; chrome: ChromeStart; data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; localStorage: Storage; navigation: NavigationStart; toastNotifications: ToastsStart; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 4855b2589bed37..e480d03e137ae6 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -11,11 +11,8 @@ import type { SavedObjectMigrationFn, SavedObjectMigrationMap } from 'kibana/ser import { mergeSavedObjectMigrationMaps } from '../../../../core/server'; import { MigrateFunctionsObject, MigrateFunction } from '../../../kibana_utils/common'; -import { - DEFAULT_QUERY_LANGUAGE, - INDEX_PATTERN_SAVED_OBJECT_TYPE, - SerializedSearchSourceFields, -} from '../../../data/common'; +import { DEFAULT_QUERY_LANGUAGE, SerializedSearchSourceFields } from '../../../data/common'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../data_views/common'; import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, @@ -47,7 +44,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; doc.references.push({ name: searchSource.indexRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: searchSource.index, }); delete searchSource.index; @@ -60,7 +57,7 @@ const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { filterRow.meta.indexRefName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; doc.references.push({ name: filterRow.meta.indexRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: filterRow.meta.index, }); delete filterRow.meta.index; @@ -658,7 +655,7 @@ const migrateControls: SavedObjectMigrationFn = (doc) => { control.indexPatternRefName = `control_${i}_index_pattern`; doc.references.push({ name: control.indexPatternRefName, - type: INDEX_PATTERN_SAVED_OBJECT_TYPE, + type: DATA_VIEW_SAVED_OBJECT_TYPE, id: control.indexPattern, }); delete control.indexPattern; @@ -1103,7 +1100,7 @@ export const replaceIndexPatternReference: SavedObjectMigrationFn = (d references: Array.isArray(doc.references) ? doc.references.map((reference) => { if (reference.type === 'index_pattern') { - reference.type = INDEX_PATTERN_SAVED_OBJECT_TYPE; + reference.type = DATA_VIEW_SAVED_OBJECT_TYPE; } return reference; }) diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 2bc25cfb3c3463..ce38bbf55ebdff 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -15,6 +15,7 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, + { "path": "../data_views/tsconfig.json" }, { "path": "../expressions/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index f0dedb155fc9ba..402783694cbd5b 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -34,7 +34,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FLAKY: https://github.com/elastic/kibana/issues/127905 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index d6d2f2606e29d5..3014ec79941c59 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -11,6 +11,7 @@ import { FtrService } from '../ftr_provider_context'; export class FieldEditorService extends FtrService { private readonly browser = this.ctx.getService('browser'); private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); public async setName(name: string, clearFirst = false, typeCharByChar = false) { await this.testSubjects.setValue('nameField > input', name, { @@ -50,12 +51,16 @@ export class FieldEditorService extends FtrService { } public async confirmSave() { - await this.testSubjects.setValue('saveModalConfirmText', 'change'); - await this.testSubjects.click('confirmModalConfirmButton'); + await this.retry.try(async () => { + await this.testSubjects.setValue('saveModalConfirmText', 'change'); + await this.testSubjects.clickWhenNotDisabled('confirmModalConfirmButton', { timeout: 1000 }); + }); } public async confirmDelete() { - await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); - await this.testSubjects.click('confirmModalConfirmButton'); + await this.retry.try(async () => { + await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); + await this.testSubjects.clickWhenNotDisabled('confirmModalConfirmButton', { timeout: 1000 }); + }); } } diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 903d190a216bd3..6dde7de84aab44 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -643,6 +643,7 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `get` - `getRuleState` - `getAlertSummary` +- `getExecutionLog` - `find` When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index c275053874efac..546fd3e4aed9a0 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -30,6 +30,7 @@ export enum ReadOperations { Get = 'get', GetRuleState = 'getRuleState', GetAlertSummary = 'getAlertSummary', + GetExecutionLog = 'getExecutionLog', Find = 'find', } diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts new file mode 100644 index 00000000000000..92999a80f6b998 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -0,0 +1,887 @@ +/* + * 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 { + getNumExecutions, + getExecutionLogAggregation, + formatExecutionLogResult, + formatSortForBucketSort, + formatSortForTermSort, +} from './get_execution_log_aggregation'; + +describe('formatSortForBucketSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForBucketSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': { order: 'desc' } }, + { 'ruleExecution>executionDuration': { order: 'asc' } }, + ]); + }); +}); + +describe('formatSortForTermSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForTermSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': 'desc' }, + { 'ruleExecution>executionDuration': 'asc' }, + ]); + }); +}); + +describe('getNumExecutions', () => { + test('should calculate the expected number of executions in a given date range with a given schedule interval', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1h' + ) + ).toEqual(24); + }); + + test('should return 0 if dateEnd is less that dateStart', () => { + expect( + getNumExecutions( + new Date('2020-12-02T00:00:00.000Z'), + new Date('2020-12-01T00:00:00.000Z'), + '1h' + ) + ).toEqual(0); + }); + + test('should cap numExecutions at default max buckets limit', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1s' + ) + ).toEqual(1000); + }); +}); + +describe('getExecutionLogAggregation', () => { + test('should throw error when given bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given one bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given bad page field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 0, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot(`"Invalid page field \\"0\\" - must be greater than 0"`); + }); + + test('should throw error when given bad perPage field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 0, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid perPage field \\"0\\" - must be greater than 0"` + ); + }); + + test('should correctly generate aggregation', () => { + expect( + getExecutionLogAggregation({ + page: 2, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }], + }) + ).toEqual({ + executionUuidCardinality: { cardinality: { field: 'kibana.alert.rule.execution.uuid' } }, + executionUuid: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 1000, + order: [ + { 'ruleExecution>executeStartTime': 'asc' }, + { 'ruleExecution>executionDuration': 'desc' }, + ], + }, + aggs: { + executionUuidSorted: { + bucket_sort: { + sort: [ + { 'ruleExecution>executeStartTime': { order: 'asc' } }, + { 'ruleExecution>executionDuration': { order: 'desc' } }, + ], + from: 10, + size: 10, + gap_policy: 'insert_zeros', + }, + }, + alertCounts: { + filters: { + filters: { + newAlerts: { match: { 'event.action': 'new-instance' } }, + activeAlerts: { match: { 'event.action': 'active-instance' } }, + recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, + }, + }, + }, + actionExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'actions' } }, + ], + }, + }, + aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } }, + }, + ruleExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + aggs: { + executeStartTime: { min: { field: 'event.start' } }, + scheduleDelay: { + max: { + field: 'kibana.task.schedule_delay', + }, + }, + totalSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, + }, + esSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' }, + }, + numTriggeredActions: { + max: { field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions' }, + }, + executionDuration: { max: { field: 'event.duration' } }, + outcomeAndMessage: { + top_hits: { size: 1, _source: { includes: ['event.outcome', 'message'] } }, + }, + }, + }, + timeoutMessage: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute-timeout' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + }, + }, + }, + }); + }); +}); + +describe('formatExecutionLogResult', () => { + test('should return empty results if aggregations are undefined', () => { + expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({ + total: 0, + data: [], + }); + }); + test('should format results correctly', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when execution timeouts occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + doc_count: 3, + timeoutMessage: { + meta: {}, + doc_count: 1, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 0, + }, + newAlerts: { + doc_count: 0, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 0.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'dJkWa38B1ylB1EvsAckB', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.0279e10, + }, + executeStartTime: { + value: 1.646769067607e12, + value_as_string: '2022-03-08T19:51:07.607Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 0, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + timestamp: '2022-03-08T19:51:07.607Z', + duration_ms: 10279, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: true, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when action errors occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 417, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 417, + data: [ + { + id: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + timestamp: '2022-03-09T16:56:13.039Z', + duration_ms: 1374, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 0, + num_errored_actions: 5, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '61bb867b-661a-471f-bf92-23471afa10b3', + timestamp: '2022-03-09T16:55:17.518Z', + duration_ms: 418, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3133, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts new file mode 100644 index 00000000000000..445cec6ad8412d --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -0,0 +1,325 @@ +/* + * 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import Boom from '@hapi/boom'; +import { flatMap, get } from 'lodash'; +import { parseDuration } from '.'; +import { AggregateEventsBySavedObjectResult } from '../../../event_log/server'; + +const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions + +const PROVIDER_FIELD = 'event.provider'; +const START_FIELD = 'event.start'; +const ACTION_FIELD = 'event.action'; +const OUTCOME_FIELD = 'event.outcome'; +const DURATION_FIELD = 'event.duration'; +const MESSAGE_FIELD = 'message'; +const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; +const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; +const TOTAL_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.total_search_duration_ms'; +const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = + 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; +const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; + +const Millis2Nanos = 1000 * 1000; + +export interface IExecutionLog { + id: string; + timestamp: string; + duration_ms: number; + status: string; + message: string; + num_active_alerts: number; + num_new_alerts: number; + num_recovered_alerts: number; + num_triggered_actions: number; + num_succeeded_actions: number; + num_errored_actions: number; + total_search_duration_ms: number; + es_search_duration_ms: number; + schedule_delay_ms: number; + timed_out: boolean; +} + +export interface IExecutionLogResult { + total: number; + data: IExecutionLog[]; +} + +interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { + buckets: { + activeAlerts: estypes.AggregationsSingleBucketAggregateBase; + newAlerts: estypes.AggregationsSingleBucketAggregateBase; + recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; + }; +} + +interface IActionExecution + extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { + buckets: Array<{ key: string; doc_count: number }>; +} + +interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketKeys { + timeoutMessage: estypes.AggregationsMultiBucketBase; + ruleExecution: { + executeStartTime: estypes.AggregationsMinAggregate; + executionDuration: estypes.AggregationsMaxAggregate; + scheduleDelay: estypes.AggregationsMaxAggregate; + esSearchDuration: estypes.AggregationsMaxAggregate; + totalSearchDuration: estypes.AggregationsMaxAggregate; + numTriggeredActions: estypes.AggregationsMaxAggregate; + outcomeAndMessage: estypes.AggregationsTopHitsAggregate; + }; + alertCounts: IAlertCounts; + actionExecution: { + actionOutcomes: IActionExecution; + }; +} + +interface ExecutionUuidAggResult + extends estypes.AggregationsAggregateBase { + buckets: TBucket[]; +} +export interface IExecutionLogAggOptions { + page: number; + perPage: number; + sort: estypes.Sort; +} + +const ExecutionLogSortFields: Record = { + timestamp: 'ruleExecution>executeStartTime', + execution_duration: 'ruleExecution>executionDuration', + total_search_duration: 'ruleExecution>totalSearchDuration', + es_search_duration: 'ruleExecution>esSearchDuration', + schedule_delay: 'ruleExecution>scheduleDelay', + num_triggered_actions: 'ruleExecution>numTriggeredActions', +}; + +export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { + // Check if valid sort fields + const sortFields = flatMap(sort as estypes.SortCombinations[], (s) => Object.keys(s)); + for (const field of sortFields) { + if (!Object.keys(ExecutionLogSortFields).includes(field)) { + throw Boom.badRequest( + `Invalid sort field "${field}" - must be one of [${Object.keys(ExecutionLogSortFields).join( + ',' + )}]` + ); + } + } + + // Check if valid page value + if (page <= 0) { + throw Boom.badRequest(`Invalid page field "${page}" - must be greater than 0`); + } + + // Check if valid page value + if (perPage <= 0) { + throw Boom.badRequest(`Invalid perPage field "${perPage}" - must be greater than 0`); + } + + return { + // Get total number of executions + executionUuidCardinality: { + cardinality: { + field: EXECUTION_UUID_FIELD, + }, + }, + executionUuid: { + // Bucket by execution UUID + terms: { + field: EXECUTION_UUID_FIELD, + size: DEFAULT_MAX_BUCKETS_LIMIT, + order: formatSortForTermSort(sort), + }, + aggs: { + // Bucket sort to allow paging through executions + executionUuidSorted: { + bucket_sort: { + sort: formatSortForBucketSort(sort), + from: (page - 1) * perPage, + size: perPage, + gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, + }, + }, + // Get counts for types of alerts and whether there was an execution timeout + alertCounts: { + filters: { + filters: { + newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, + activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, + recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, + }, + }, + }, + // Filter by action execute doc and get information from this event + actionExecution: { + filter: getProviderAndActionFilter('actions', 'execute'), + aggs: { + actionOutcomes: { + terms: { + field: OUTCOME_FIELD, + size: 2, + }, + }, + }, + }, + // Filter by rule execute doc and get information from this event + ruleExecution: { + filter: getProviderAndActionFilter('alerting', 'execute'), + aggs: { + executeStartTime: { + min: { + field: START_FIELD, + }, + }, + scheduleDelay: { + max: { + field: SCHEDULE_DELAY_FIELD, + }, + }, + totalSearchDuration: { + max: { + field: TOTAL_SEARCH_DURATION_FIELD, + }, + }, + esSearchDuration: { + max: { + field: ES_SEARCH_DURATION_FIELD, + }, + }, + numTriggeredActions: { + max: { + field: NUMBER_OF_TRIGGERED_ACTIONS_FIELD, + }, + }, + executionDuration: { + max: { + field: DURATION_FIELD, + }, + }, + outcomeAndMessage: { + top_hits: { + size: 1, + _source: { + includes: [OUTCOME_FIELD, MESSAGE_FIELD], + }, + }, + }, + }, + }, + // If there was a timeout, this filter will return non-zero doc count + timeoutMessage: { + filter: getProviderAndActionFilter('alerting', 'execute-timeout'), + }, + }, + }, + }; +} + +function getProviderAndActionFilter(provider: string, action: string) { + return { + bool: { + must: [ + { + match: { + [ACTION_FIELD]: action, + }, + }, + { + match: { + [PROVIDER_FIELD]: provider, + }, + }, + ], + }, + }; +} + +function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutionLog { + const durationUs = bucket?.ruleExecution?.executionDuration?.value + ? bucket.ruleExecution.executionDuration.value + : 0; + const scheduleDelayUs = bucket?.ruleExecution?.scheduleDelay?.value + ? bucket.ruleExecution.scheduleDelay.value + : 0; + const timedOut = (bucket?.timeoutMessage?.doc_count ?? 0) > 0; + + const actionExecutionOutcomes = bucket?.actionExecution?.actionOutcomes?.buckets ?? []; + const actionExecutionSuccess = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'success')?.doc_count ?? 0; + const actionExecutionError = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'failure')?.doc_count ?? 0; + + return { + id: bucket?.key ?? '', + timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', + duration_ms: durationUs / Millis2Nanos, + status: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.event?.outcome, + message: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.message, + num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, + num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, + num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, + num_succeeded_actions: actionExecutionSuccess, + num_errored_actions: actionExecutionError, + total_search_duration_ms: bucket?.ruleExecution?.totalSearchDuration?.value ?? 0, + es_search_duration_ms: bucket?.ruleExecution?.esSearchDuration?.value ?? 0, + schedule_delay_ms: scheduleDelayUs / Millis2Nanos, + timed_out: timedOut, + }; +} + +export function formatExecutionLogResult( + results: AggregateEventsBySavedObjectResult +): IExecutionLogResult { + const { aggregations } = results; + + if (!aggregations) { + return { + total: 0, + data: [], + }; + } + + const total = (aggregations.executionUuidCardinality as estypes.AggregationsCardinalityAggregate) + .value; + const buckets = (aggregations.executionUuid as ExecutionUuidAggResult).buckets; + + return { + total, + data: buckets.map((bucket: IExecutionUuidAggBucket) => formatExecutionLogAggBucket(bucket)), + }; +} + +export function getNumExecutions(dateStart: Date, dateEnd: Date, ruleSchedule: string) { + const durationInMillis = dateEnd.getTime() - dateStart.getTime(); + const scheduleMillis = parseDuration(ruleSchedule); + + const numExecutions = Math.ceil(durationInMillis / scheduleMillis); + + return Math.min(numExecutions < 0 ? 0 : numExecutions, DEFAULT_MAX_BUCKETS_LIMIT); +} + +export function formatSortForBucketSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, curr) }), + {} + ) + ); +} + +export function formatSortForTermSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, `${curr}.order`) }), + {} + ) + ); +} diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts new file mode 100644 index 00000000000000..e359e9c52dda04 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { getRuleExecutionLogRoute } from './get_rule_execution_log'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { rulesClientMock } from '../rules_client.mock'; +import { IExecutionLogResult } from '../lib/get_execution_log_aggregation'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('getRuleExecutionLogRoute', () => { + const dateString = new Date().toISOString(); + const mockedExecutionLog: IExecutionLogResult = { + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3008, + }, + ], + }; + + it('gets rule execution log', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_execution_log"`); + + rulesClient.getExecutionLogForRule.mockResolvedValue(mockedExecutionLog); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: { + date_start: dateString, + per_page: 10, + page: 1, + sort: [{ timestamp: { order: 'desc' } }], + }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.getExecutionLogForRule).toHaveBeenCalledTimes(1); + expect(rulesClient.getExecutionLogForRule.mock.calls[0]).toEqual([ + { + dateStart: dateString, + id: '1', + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }, + ]); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('returns NOT-FOUND when rule is not found', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.getExecutionLogForRule = jest + .fn() + .mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1')); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['notFound'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: Saved object [alert/1] not found]` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts new file mode 100644 index 00000000000000..845c14ecf0ea4d --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -0,0 +1,77 @@ +/* + * 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 { IRouter } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { ILicenseState } from '../lib'; +import { GetExecutionLogByIdParams } from '../rules_client'; +import { RewriteRequestCase, verifyAccessAndContext } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]); + +const sortFieldSchema = schema.oneOf([ + schema.object({ timestamp: schema.object({ order: sortOrderSchema }) }), + schema.object({ execution_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ total_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ es_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), +]); + +const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { + defaultValue: [{ timestamp: { order: 'desc' } }], +}); + +const querySchema = schema.object({ + date_start: schema.string(), + date_end: schema.maybe(schema.string()), + filter: schema.maybe(schema.string()), + per_page: schema.number({ defaultValue: 10, min: 1 }), + page: schema.number({ defaultValue: 1, min: 1 }), + sort: sortFieldsSchema, +}); + +const rewriteReq: RewriteRequestCase = ({ + date_start: dateStart, + date_end: dateEnd, + per_page: perPage, + ...rest +}) => ({ + ...rest, + dateStart, + dateEnd, + perPage, +}); + +export const getRuleExecutionLogRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_execution_log`, + validate: { + params: paramSchema, + query: querySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const { id } = req.params; + return res.ok({ + body: await rulesClient.getExecutionLogForRule(rewriteReq({ id, ...req.query })), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 1cb58fd6d06575..ed1a9583cc75c1 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -20,6 +20,7 @@ import { disableRuleRoute } from './disable_rule'; import { enableRuleRoute } from './enable_rule'; import { findRulesRoute, findInternalRulesRoute } from './find_rules'; import { getRuleAlertSummaryRoute } from './get_rule_alert_summary'; +import { getRuleExecutionLogRoute } from './get_rule_execution_log'; import { getRuleStateRoute } from './get_rule_state'; import { healthRoute } from './health'; import { resolveRuleRoute } from './resolve_rule'; @@ -54,6 +55,7 @@ export function defineRoutes(opts: RouteOptions) { findRulesRoute(router, licenseState, usageCounter); findInternalRulesRoute(router, licenseState, usageCounter); getRuleAlertSummaryRoute(router, licenseState); + getRuleExecutionLogRoute(router, licenseState); getRuleStateRoute(router, licenseState); healthRoute(router, licenseState, encryptedSavedObjects); ruleTypesRoute(router, licenseState); diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 2a7fb7177ce4cd..de1de6a8e3cbca 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -30,6 +30,7 @@ const createRulesClientMock = () => { unmuteInstance: jest.fn(), listAlertTypes: jest.fn(), getAlertSummary: jest.fn(), + getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), snooze: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index 96d6b9a5d17ef9..65be7fc739ca25 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -23,6 +23,7 @@ export enum RuleAuditAction { MUTE_ALERT = 'rule_alert_mute', UNMUTE_ALERT = 'rule_alert_unmute', AGGREGATE = 'rule_aggregate', + GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', } @@ -43,6 +44,11 @@ const eventVerbs: Record = { rule_alert_mute: ['mute alert of', 'muting alert of', 'muted alert of'], rule_alert_unmute: ['unmute alert of', 'unmuting alert of', 'unmuted alert of'], rule_aggregate: ['access', 'accessing', 'accessed'], + rule_get_execution_log: [ + 'access execution log for', + 'accessing execution log for', + 'accessed execution log for', + ], rule_snooze: ['snooze', 'snoozing', 'snoozed'], }; @@ -61,6 +67,7 @@ const eventTypes: Record = { rule_alert_mute: 'change', rule_alert_unmute: 'change', rule_aggregate: 'access', + rule_get_execution_log: 'access', rule_snooze: 'change', }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 4208c0d76d5ff8..e396b4fd949434 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -84,6 +84,11 @@ import { getModifiedSearch, modifyFilterKueryNode, } from './lib/mapped_params_utils'; +import { + formatExecutionLogResult, + getExecutionLogAggregation, + IExecutionLogResult, +} from '../lib/get_execution_log_aggregation'; import { validateSnoozeDate } from '../lib/validate_snooze_date'; import { RuleMutedError } from '../lib/errors/rule_muted'; @@ -235,6 +240,16 @@ export interface GetAlertSummaryParams { numberOfExecutions?: number; } +export interface GetExecutionLogByIdParams { + id: string; + dateStart: string; + dateEnd?: string; + filter?: string; + page: number; + perPage: number; + sort: estypes.Sort; +} + // NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects const extractedSavedObjectParamReferenceNamePrefix = 'param:'; @@ -639,6 +654,70 @@ export class RulesClient { }); } + public async getExecutionLogForRule({ + id, + dateStart, + dateEnd, + filter, + page, + perPage, + sort, + }: GetExecutionLogByIdParams): Promise { + this.logger.debug(`getExecutionLogForRule(): getting execution log for rule ${id}`); + const rule = (await this.get({ id, includeLegacyId: true })) as SanitizedRuleWithLegacyId; + + try { + // Make sure user has access to this rule + await this.authorization.ensureAuthorized({ + ruleTypeId: rule.alertTypeId, + consumer: rule.consumer, + operation: ReadOperations.GetExecutionLog, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + }) + ); + + // default duration of instance summary is 60 * rule interval + const dateNow = new Date(); + const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow); + const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow); + + const eventLogClient = await this.getEventLogClient(); + + const results = await eventLogClient.aggregateEventsBySavedObjectIds( + 'alert', + [id], + { + start: parsedDateStart.toISOString(), + end: parsedDateEnd.toISOString(), + filter, + aggs: getExecutionLogAggregation({ + page, + perPage, + sort, + }), + }, + rule.legacyId !== null ? [rule.legacyId] : undefined + ); + + return formatExecutionLogResult(results); + } + public async find({ options: { fields, ...options } = {}, excludeFromPublicApi = false, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts new file mode 100644 index 00000000000000..a55a3e57428bba --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -0,0 +1,613 @@ +/* + * 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '../../../../actions/server'; +import { eventLogClientMock } from '../../../../event_log/server/mocks'; +import { SavedObject } from 'kibana/server'; +import { RawRule } from '../../types'; +import { auditLoggerMock } from '../../../../security/server/audit/mocks'; +import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; +import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation'; + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const eventLogClient = eventLogClientMock.create(); + +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const kibanaVersion = 'v7.10.0'; +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + minimumScheduleInterval: '1m', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry, eventLogClient); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +const RuleIntervalSeconds = 1; + +const BaseRuleSavedObject: SavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'rule-consumer', + legacyId: null, + schedule: { interval: `${RuleIntervalSeconds}s` }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: mockedDateString, + updatedAt: mockedDateString, + apiKey: null, + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + warning: null, + }, + }, + references: [], +}; + +const aggregateResults = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.345e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, +}; + +function getRuleSavedObject(attributes: Partial = {}): SavedObject { + return { + ...BaseRuleSavedObject, + attributes: { ...BaseRuleSavedObject.attributes, ...attributes }, + }; +} + +function getExecutionLogByIdParams(overwrites = {}) { + return { + id: '1', + dateStart: new Date(Date.now() - 3600000).toISOString(), + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }] as estypes.Sort, + ...overwrites, + }; +} +describe('getExecutionLogForRule()', () => { + let rulesClient: RulesClient; + + beforeEach(() => { + rulesClient = new RulesClient(rulesClientParams); + }); + + test('runs as expected with some event log aggregation data', async () => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const result = await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(result).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3345, + }, + ], + }); + }); + + // Further tests don't check the result of `getExecutionLogForRule()`, as the result + // is just the result from the `formatExecutionLogResult()`, which itself + // has a complete set of tests. + + test('calls saved objects and event log client with default params', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with legacy ids param', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce( + getRuleSavedObject({ legacyId: '99999' }) + ); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + ['99999'], + ]); + }); + + test('calls event log client with end date if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ dateEnd: new Date(Date.now() - 2700000).toISOString() }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: '2019-02-12T20:16:22.479Z', + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with filter if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ filter: 'event.outcome: success' }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + filter: 'event.outcome: success', + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('invalid start date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateStart = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateStart })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateStart: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid end date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateEnd = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateEnd })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateEnd: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid page value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ page: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid page field "-3" - must be greater than 0]`); + }); + + test('invalid perPage value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ perPage: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid perPage field "-3" - must be greater than 0]`); + }); + + test('invalid sort value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]]` + ); + }); + + test('throws error when saved object get throws an error', async () => { + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('OMG!')); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG!]`); + }); + + test('throws error when eventLog.aggregateEventsBySavedObjectIds throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockRejectedValueOnce(new Error('OMG 2!')); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG 2!]`); + }); + + describe('authorization', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('ensures user is authorised to get this type of alert under the consumer', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValueOnce( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to get a "myType" alert for "myApp"]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + }); + + describe('auditLogger', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('logs audit event when getting a rule execution log', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a rule', async () => { + // first call occurs during rule SO get + authorization.ensureAuthorized.mockResolvedValueOnce(); + authorization.ensureAuthorized.mockRejectedValueOnce(new Error('Unauthorized')); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts index 09e3e22a1d352f..8a838360b3d44f 100644 --- a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts +++ b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts @@ -7,23 +7,17 @@ import { i18n } from '@kbn/i18n'; -export const FAILED_TRANSACTIONS_IMPACT_THRESHOLD = { - HIGH: i18n.translate( - 'xpack.apm.correlations.failedTransactions.highImpactText', - { - defaultMessage: 'High', - } - ), - MEDIUM: i18n.translate( - 'xpack.apm.correlations.failedTransactions.mediumImpactText', - { - defaultMessage: 'Medium', - } - ), - LOW: i18n.translate( - 'xpack.apm.correlations.failedTransactions.lowImpactText', - { - defaultMessage: 'Low', - } - ), +export const CORRELATIONS_IMPACT_THRESHOLD = { + HIGH: i18n.translate('xpack.apm.correlations.highImpactText', { + defaultMessage: 'High', + }), + MEDIUM: i18n.translate('xpack.apm.correlations.mediumImpactText', { + defaultMessage: 'Medium', + }), + LOW: i18n.translate('xpack.apm.correlations.lowImpactText', { + defaultMessage: 'Low', + }), + VERY_LOW: i18n.translate('xpack.apm.correlations.veryLowImpactText', { + defaultMessage: 'Very low', + }), } as const; diff --git a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts index 8b09d45c1e1b6d..e63d3d6faa92ec 100644 --- a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts @@ -7,7 +7,7 @@ import { FieldValuePair, HistogramItem } from '../types'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from './constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from './constants'; import { FieldStats } from '../field_stats_types'; export interface FailedTransactionsCorrelation extends FieldValuePair { @@ -22,7 +22,7 @@ export interface FailedTransactionsCorrelation extends FieldValuePair { } export type FailedTransactionsCorrelationsImpactThreshold = - typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD[keyof typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD]; + typeof CORRELATIONS_IMPACT_THRESHOLD[keyof typeof CORRELATIONS_IMPACT_THRESHOLD]; export interface FailedTransactionsCorrelationsResponse { ccsWarning: boolean; @@ -31,4 +31,5 @@ export interface FailedTransactionsCorrelationsResponse { overallHistogram?: HistogramItem[]; errorHistogram?: HistogramItem[]; fieldStats?: FieldStats[]; + fallbackResult?: FailedTransactionsCorrelation; } diff --git a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts index 23c91554b65473..cf20490774e18b 100644 --- a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts @@ -10,8 +10,9 @@ import { FieldStats } from '../field_stats_types'; export interface LatencyCorrelation extends FieldValuePair { correlation: number; - histogram: HistogramItem[]; + histogram?: HistogramItem[]; ksTest: number; + isFallbackResult?: boolean; } export interface LatencyCorrelationsResponse { diff --git a/x-pack/plugins/apm/common/correlations/types.ts b/x-pack/plugins/apm/common/correlations/types.ts index 402750b72b2ab7..6884d8c627fd08 100644 --- a/x-pack/plugins/apm/common/correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/types.ts @@ -11,6 +11,7 @@ export interface FieldValuePair { // but for example `http.response.status_code` which is part of // of the list of predefined field candidates is of type long/number. fieldValue: string | number; + isFallbackResult?: boolean; } export interface HistogramItem { diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index 6d20faae89a10f..c970838f8b8c34 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -28,7 +28,10 @@ import { i18n } from '@kbn/i18n'; import { useUiTracker } from '../../../../../observability/public'; -import { asPercent } from '../../../../common/utils/formatters'; +import { + asPercent, + asPreciseDecimal, +} from '../../../../common/utils/formatters'; import { FailedTransactionsCorrelation } from '../../../../common/correlations/failed_transactions_correlations/types'; import { FieldStats } from '../../../../common/correlations/field_stats_types'; @@ -36,8 +39,6 @@ import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_ import { useLocalStorage } from '../../../hooks/use_local_storage'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; - -import { ImpactBar } from '../../shared/impact_bar'; import { push } from '../../shared/links/url_helpers'; import { CorrelationsTable } from './correlations_table'; @@ -229,21 +230,33 @@ export function FailedTransactionsCorrelations({ width: '116px', field: 'normalizedScore', name: ( - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', + - ), - render: (_, { normalizedScore }) => { - return ( + > <> - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', + { + defaultMessage: 'Score', + } + )} + - ); + + ), + render: (_, { normalizedScore }) => { + return
{asPreciseDecimal(normalizedScore, 2)}
; }, sortable: true, }, @@ -260,8 +273,11 @@ export function FailedTransactionsCorrelations({ )} ), - render: (_, { pValue }) => { - const label = getFailedTransactionsCorrelationImpactLabel(pValue); + render: (_, { pValue, isFallbackResult }) => { + const label = getFailedTransactionsCorrelationImpactLabel( + pValue, + isFallbackResult + ); return label ? ( {label.impact} ) : null; @@ -377,18 +393,30 @@ export function FailedTransactionsCorrelations({ sort: { field: sortField, direction: sortDirection }, }; - const correlationTerms = useMemo( - () => - orderBy( - response.failedTransactionsCorrelations, - // The smaller the p value the higher the impact - // So we want to sort by the normalized score here - // which goes from 0 -> 1 - sortField === 'pValue' ? 'normalizedScore' : sortField, - sortDirection - ), - [response.failedTransactionsCorrelations, sortField, sortDirection] - ); + const correlationTerms = useMemo(() => { + if ( + progress.loaded === 1 && + response?.failedTransactionsCorrelations?.length === 0 && + response.fallbackResult !== undefined + ) { + return [{ ...response.fallbackResult, isFallbackResult: true }]; + } + + return orderBy( + response.failedTransactionsCorrelations, + // The smaller the p value the higher the impact + // So we want to sort by the normalized score here + // which goes from 0 -> 1 + sortField === 'pValue' ? 'normalizedScore' : sortField, + sortDirection + ); + }, [ + response.failedTransactionsCorrelations, + response.fallbackResult, + progress.loaded, + sortField, + sortDirection, + ]); const [pinnedSignificantTerm, setPinnedSignificantTerm] = useState(null); diff --git a/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts b/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts index 49ddd8aec0fe45..12799e5edc726b 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts @@ -7,11 +7,10 @@ import { i18n } from '@kbn/i18n'; import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common'; -import type { - FieldValuePair, - HistogramItem, -} from '../../../../common/correlations/types'; +import type { HistogramItem } from '../../../../common/correlations/types'; import { TransactionDistributionChartData } from '../../shared/charts/transaction_distribution_chart'; +import { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; +import { FailedTransactionsCorrelation } from '../../../../common/correlations/failed_transactions_correlations/types'; export function getTransactionDistributionChartData({ euiTheme, @@ -22,7 +21,7 @@ export function getTransactionDistributionChartData({ euiTheme: EuiTheme; allTransactionsHistogram?: HistogramItem[]; failedTransactionsHistogram?: HistogramItem[]; - selectedTerm?: FieldValuePair & { histogram: HistogramItem[] }; + selectedTerm?: LatencyCorrelation | FailedTransactionsCorrelation | undefined; }) { const transactionDistributionChartData: TransactionDistributionChartData[] = []; diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 5b37a14b4e4e5c..f3734dae5d2471 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -17,12 +17,14 @@ import { EuiSpacer, EuiTitle, EuiToolTip, + EuiBadge, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { useUiTracker } from '../../../../../observability/public'; import { asPreciseDecimal } from '../../../../common/utils/formatters'; @@ -48,6 +50,18 @@ import { getTransactionDistributionChartData } from './get_transaction_distribut import { useTheme } from '../../../hooks/use_theme'; import { ChartTitleToolTip } from './chart_title_tool_tip'; import { MIN_TAB_TITLE_HEIGHT } from '../transaction_details/distribution'; +import { getLatencyCorrelationImpactLabel } from './utils/get_failed_transactions_correlation_impact_label'; + +export function FallbackCorrelationBadge() { + return ( + + + + ); +} export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { const { @@ -151,6 +165,31 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { }, sortable: true, }, + { + width: '116px', + field: 'pValue', + name: ( + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel', + { + defaultMessage: 'Impact', + } + )} + + ), + render: (_, { correlation, isFallbackResult }) => { + const label = getLatencyCorrelationImpactLabel( + correlation, + isFallbackResult + ); + return label ? ( + {label.impact} + ) : null; + }, + sortable: true, + }, + { field: 'fieldName', name: i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts index 41a2afada6e658..26f63e1ab0c59f 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts @@ -77,6 +77,7 @@ export function useFailedTransactionsCorrelations() { // and histogram data for statistically significant results. const responseUpdate: FailedTransactionsCorrelationsResponse = { ccsWarning: false, + fallbackResult: undefined, }; const [overallHistogramResponse, errorHistogramRespone] = @@ -149,6 +150,7 @@ export function useFailedTransactionsCorrelations() { const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = []; + let fallbackResult: FailedTransactionsCorrelation | undefined; const fieldsToSample = new Set(); const chunkSize = 10; let chunkLoadCounter = 0; @@ -177,6 +179,21 @@ export function useFailedTransactionsCorrelations() { getFailedTransactionsCorrelationsSortedByScore([ ...failedTransactionsCorrelations, ]); + } else { + // If there's no significant correlations found and there's a fallback result + // Update the highest ranked/scored fall back result + if (pValues.fallbackResult) { + if (!fallbackResult) { + fallbackResult = pValues.fallbackResult; + } else { + if ( + pValues.fallbackResult.normalizedScore > + fallbackResult.normalizedScore + ) { + fallbackResult = pValues.fallbackResult; + } + } + } } chunkLoadCounter++; @@ -209,7 +226,12 @@ export function useFailedTransactionsCorrelations() { ); responseUpdate.fieldStats = stats; - setResponse({ ...responseUpdate, loaded: LOADED_DONE, isRunning: false }); + setResponse({ + ...responseUpdate, + fallbackResult, + loaded: LOADED_DONE, + isRunning: false, + }); setResponse.flush(); } catch (e) { if (!abortCtrl.current.signal.aborted) { diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts index 0e166344d0dec4..428fdcda7cfc69 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts @@ -177,6 +177,7 @@ export function useLatencyCorrelations() { chunkSize ); + const fallbackResults: LatencyCorrelation[] = []; for (const fieldValuePairChunk of fieldValuePairChunks) { const significantCorrelations = await callApmApi( 'POST /internal/apm/correlations/significant_correlations', @@ -197,6 +198,12 @@ export function useLatencyCorrelations() { ); responseUpdate.latencyCorrelations = getLatencyCorrelationsSortedByCorrelation([...latencyCorrelations]); + } else { + // If there's no correlation results that matches the criteria + // Consider the fallback results + if (significantCorrelations.fallbackResult) { + fallbackResults.push(significantCorrelations.fallbackResult); + } } chunkLoadCounter++; @@ -213,6 +220,23 @@ export function useLatencyCorrelations() { } } + if (latencyCorrelations.length === 0 && fallbackResults.length > 0) { + // Rank the fallback results and show at least one value + const sortedFallbackResults = fallbackResults + .filter((r) => r.correlation > 0) + .sort((a, b) => b.correlation - a.correlation); + + responseUpdate.latencyCorrelations = sortedFallbackResults + .slice(0, 1) + .map((r) => ({ ...r, isFallbackResult: true })); + setResponse({ + ...responseUpdate, + loaded: + LOADED_FIELD_VALUE_PAIRS + + (chunkLoadCounter / fieldValuePairChunks.length) * + PROGRESS_STEP_CORRELATIONS, + }); + } setResponse.flush(); const { stats } = await callApmApi( diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts index d35833295703fa..b85121ea94a9c6 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts @@ -6,19 +6,19 @@ */ import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; const EXPECTED_RESULT = { HIGH: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH, + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, color: 'danger', }, MEDIUM: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM, + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, color: 'warning', }, LOW: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW, + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, color: 'default', }, }; diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts index d5d0fd4dcae51a..556c13d7467bb4 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts @@ -9,10 +9,11 @@ import { FailedTransactionsCorrelation, FailedTransactionsCorrelationsImpactThreshold, } from '../../../../../common/correlations/failed_transactions_correlations/types'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; export function getFailedTransactionsCorrelationImpactLabel( - pValue: FailedTransactionsCorrelation['pValue'] + pValue: FailedTransactionsCorrelation['pValue'], + isFallbackResult?: boolean ): { impact: FailedTransactionsCorrelationsImpactThreshold; color: string; @@ -21,22 +22,64 @@ export function getFailedTransactionsCorrelationImpactLabel( return null; } + if (isFallbackResult) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.VERY_LOW, + color: 'default', + }; + // The lower the p value, the higher the impact if (pValue >= 0 && pValue < 1e-6) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH, + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, color: 'danger', }; if (pValue >= 1e-6 && pValue < 0.001) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM, + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, color: 'warning', }; if (pValue >= 0.001 && pValue < 0.02) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW, + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, + color: 'default', + }; + + return null; +} + +export function getLatencyCorrelationImpactLabel( + correlation: FailedTransactionsCorrelation['pValue'], + isFallbackResult?: boolean +): { + impact: FailedTransactionsCorrelationsImpactThreshold; + color: string; +} | null { + if (correlation === null || correlation < 0) { + return null; + } + + // The lower the p value, the higher the impact + if (isFallbackResult) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.VERY_LOW, + color: 'default', + }; + if (correlation < 0.4) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, color: 'default', }; + if (correlation < 0.6) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, + color: 'warning', + }; + if (correlation < 1) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, + color: 'danger', + }; return null; } diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx index 863f2c6227f415..bce1d5936a3522 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx @@ -25,6 +25,7 @@ import { import { SettingsForm, SettingsSection } from './settings_form'; import { isSettingsFormValid, mergeNewVars } from './settings_form/utils'; import { PackagePolicyVars } from './typings'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { updateAPMPolicy: (newVars: PackagePolicyVars, isValid: boolean) => void; @@ -37,6 +38,8 @@ export function APMPolicyForm({ isCloudPolicy, updateAPMPolicy, }: Props) { + const tailSamplingPoliciesDocsLink = + useKibana().services.docLinks?.links.apm.tailSamplingPolicies; const { apmSettings, rumSettings, @@ -51,9 +54,11 @@ export function APMPolicyForm({ agentAuthorizationSettings: getAgentAuthorizationSettings({ isCloudPolicy, }), - tailSamplingSettings: getTailSamplingSettings(), + tailSamplingSettings: getTailSamplingSettings( + tailSamplingPoliciesDocsLink + ), }; - }, [isCloudPolicy]); + }, [isCloudPolicy, tailSamplingPoliciesDocsLink]); function handleFormChange(key: string, value: any) { // Merge new key/value with the rest of fields diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts rename to x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx index cb9c72cdb59f60..6b19cd22c4eb9b 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.test.tsx @@ -4,15 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { getTailSamplingSettings, isTailBasedSamplingValid, } from './tail_sampling_settings'; +const DOCS_LINK = + 'https://www.elastic.co/guide/en/apm/guide/master/configure-tail-based-sampling.html'; + describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { it('return true when tail_sampling_interval is greater than 1s', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: true, type: 'bool' }, @@ -28,7 +30,7 @@ describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { }); it('return false when tail_sampling_interval is less than 1s', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: true, type: 'bool' }, @@ -44,7 +46,7 @@ describe('tail_sampling_settings - isTailBasedSamplingFormValid', () => { }); it('returns true when tail_sampling_enabled is disabled', () => { - const settings = getTailSamplingSettings(); + const settings = getTailSamplingSettings(DOCS_LINK); const isValid = isTailBasedSamplingValid( { tail_sampling_enabled: { value: false, type: 'bool' } }, settings diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx similarity index 79% rename from x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts rename to x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx index 075e838b5d8189..74c509fc4fc7d7 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_definition/tail_sampling_settings.tsx @@ -4,6 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { isSettingsFormValid, OPTIONAL_LABEL } from '../settings_form/utils'; import { PackagePolicyVars, SettingsRow } from '../typings'; @@ -11,7 +14,7 @@ import { getDurationRt } from '../../../../../common/agent_configuration/runtime export const TAIL_SAMPLING_ENABLED_KEY = 'tail_sampling_enabled'; -export function getTailSamplingSettings(): SettingsRow[] { +export function getTailSamplingSettings(docsLinks?: string): SettingsRow[] { return [ { key: TAIL_SAMPLING_ENABLED_KEY, @@ -67,6 +70,24 @@ export function getTailSamplingSettings(): SettingsRow[] { 'Policies map trace events to a sample rate. Each policy must specify a sample rate. Trace events are matched to policies in the order specified. All policy conditions must be true for a trace event to match. Each policy list should conclude with a policy that only specifies a sample rate. This final policy is used to catch remaining trace events that don’t match a stricter policy.', } ), + helpText: docsLinks && ( + + {i18n.translate( + 'xpack.apm.fleet_integration.settings.tailSamplingDocsHelpTextLink', + { + defaultMessage: 'docs', + } + )} + + ), + }} + /> + ), required: true, }, ], diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx index 506d5bbb5128c3..84b06e37b2ae26 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx @@ -98,8 +98,7 @@ interface Props { } export function SettingsForm({ settingsSection, vars, onChange }: Props) { - const { title, subtitle, settings, isBeta, isPlatinumLicence } = - settingsSection; + const { title, subtitle, settings, isPlatinumLicence } = settingsSection; return ( @@ -130,25 +129,6 @@ export function SettingsForm({ settingsSection, vars, onChange }: Props) { )} /> )} -   - {isBeta && ( - - )} diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts index d1283e0fede17e..e7108e8910446f 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/typings.ts @@ -5,6 +5,7 @@ * 2.0. */ import * as t from 'io-ts'; +import { ReactNode } from 'react'; import { PackagePolicyConfigRecordEntry } from '../../../../../fleet/common'; export type { @@ -41,9 +42,11 @@ export interface BasicSettingRow { rowTitle?: string; rowDescription?: string; label?: string; - helpText?: string; + helpText?: ReactNode; placeholder?: string; labelAppend?: string; + labelAppendLink?: string; + labelAppendLinkText?: string; settings?: SettingsRow[]; validation?: SettingValidation; required?: boolean; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts index 03b28b28d521a0..5c6b8f3594aa03 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts @@ -32,7 +32,7 @@ export async function fetchTransactionDurationCorrelationWithHistogram( histogramRangeSteps: number[], totalDocCount: number, fieldValuePair: FieldValuePair -): Promise { +) { const { correlation, ksTest } = await fetchTransactionDurationCorrelation( esClient, params, @@ -43,23 +43,28 @@ export async function fetchTransactionDurationCorrelationWithHistogram( [fieldValuePair] ); - if ( - correlation !== null && - correlation > CORRELATION_THRESHOLD && - ksTest !== null && - ksTest < KS_TEST_THRESHOLD - ) { - const logHistogram = await fetchTransactionDurationRanges( - esClient, - params, - histogramRangeSteps, - [fieldValuePair] - ); - return { - ...fieldValuePair, - correlation, - ksTest, - histogram: logHistogram, - }; + if (correlation !== null && ksTest !== null && !isNaN(ksTest)) { + if (correlation > CORRELATION_THRESHOLD && ksTest < KS_TEST_THRESHOLD) { + const logHistogram = await fetchTransactionDurationRanges( + esClient, + params, + histogramRangeSteps, + [fieldValuePair] + ); + return { + ...fieldValuePair, + correlation, + ksTest, + histogram: logHistogram, + } as LatencyCorrelation; + } else { + return { + ...fieldValuePair, + correlation, + ksTest, + } as Omit; + } } + + return undefined; } diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts index 7c471aebd0f7a6..ee59925c47f278 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts @@ -41,18 +41,39 @@ export const fetchPValues = async ( ) ); - const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = - fulfilled - .flat() - .filter( - (record) => - record && - typeof record.pValue === 'number' && - record.pValue < ERROR_CORRELATION_THRESHOLD - ); + const flattenedResults = fulfilled.flat(); + + const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = []; + let fallbackResult: FailedTransactionsCorrelation | undefined; + + flattenedResults.forEach((record) => { + if ( + record && + typeof record.pValue === 'number' && + record.pValue < ERROR_CORRELATION_THRESHOLD + ) { + failedTransactionsCorrelations.push(record); + } else { + // If there's no result matching the criteria + // Find the next highest/closest result to the threshold + // to use as a fallback result + if (!fallbackResult) { + fallbackResult = record; + } else { + if ( + record.pValue !== null && + fallbackResult && + fallbackResult.pValue !== null && + record.pValue < fallbackResult.pValue + ) { + fallbackResult = record; + } + } + } + }); const ccsWarning = rejected.length > 0 && paramsWithIndex?.index.includes(':'); - return { failedTransactionsCorrelations, ccsWarning }; + return { failedTransactionsCorrelations, ccsWarning, fallbackResult }; }; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts index ed5ad1c2781437..2fc1e69eab3567 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts @@ -13,7 +13,7 @@ import type { FieldValuePair, CorrelationsParams, } from '../../../../common/correlations/types'; -import { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; +import type { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; import { computeExpectationsAndRanges, @@ -25,6 +25,7 @@ import { fetchTransactionDurationFractions, fetchTransactionDurationHistogramRangeSteps, fetchTransactionDurationPercentiles, + fetchTransactionDurationRanges, } from './index'; export const fetchSignificantCorrelations = async ( @@ -76,12 +77,54 @@ export const fetchSignificantCorrelations = async ( ) ); - const latencyCorrelations: LatencyCorrelation[] = fulfilled.filter( - (d): d is LatencyCorrelation => d !== undefined - ); + const latencyCorrelations = fulfilled.filter( + (d) => d && 'histogram' in d + ) as LatencyCorrelation[]; + let fallbackResult: LatencyCorrelation | undefined = + latencyCorrelations.length > 0 + ? undefined + : fulfilled + .filter((d) => !(d as LatencyCorrelation)?.histogram) + .reduce((d, result) => { + if (d?.correlation !== undefined) { + if (!result) { + result = d?.correlation > 0 ? d : undefined; + } else { + if ( + d.correlation > 0 && + d.ksTest > result.ksTest && + d.correlation > result.correlation + ) { + result = d; + } + } + } + return result; + }, undefined); + if (latencyCorrelations.length === 0 && fallbackResult) { + const { fieldName, fieldValue } = fallbackResult; + const logHistogram = await fetchTransactionDurationRanges( + esClient, + paramsWithIndex, + histogramRangeSteps, + [{ fieldName, fieldValue }] + ); + + if (fallbackResult) { + fallbackResult = { + ...fallbackResult, + histogram: logHistogram, + }; + } + } const ccsWarning = rejected.length > 0 && paramsWithIndex?.index.includes(':'); - return { latencyCorrelations, ccsWarning, totalDocCount }; + return { + latencyCorrelations, + ccsWarning, + totalDocCount, + fallbackResult, + }; }; diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index fd0bce7a62ff89..b0735c7c57b368 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -257,6 +257,7 @@ const significantCorrelationsRoute = createApmServerRoute({ >; ccsWarning: boolean; totalDocCount: number; + fallbackResult?: import('./../../../common/correlations/latency_correlations/types').LatencyCorrelation; }> => { const { context } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { @@ -314,6 +315,7 @@ const pValuesRoute = createApmServerRoute({ import('./../../../common/correlations/failed_transactions_correlations/types').FailedTransactionsCorrelation >; ccsWarning: boolean; + fallbackResult?: import('./../../../common/correlations/failed_transactions_correlations/types').FailedTransactionsCorrelation; }> => { const { context } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index d7ce7318f87243..ffd1f2bc4c8c90 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -17,6 +17,7 @@ export const KibanaServices = { getKibanaVersion: jest.fn(() => '8.0.0'), getConfig: jest.fn(() => null), }; + export const useKibana = jest.fn().mockReturnValue({ services: createStartServicesMock(), }); @@ -46,6 +47,9 @@ export const useNavigation = jest.fn().mockReturnValue({ navigateTo: jest.fn(), }); -export const useKibanaCapabilities = jest.fn().mockReturnValue({ - visualize: true, +export const useApplicationCapabilities = jest.fn().mockReturnValue({ + actions: { crud: true, read: true }, + generalCases: { crud: true, read: true }, + visualize: { crud: true, read: true }, + dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx new file mode 100644 index 00000000000000..0f6a1e0035e5cc --- /dev/null +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useApplicationCapabilities } from './hooks'; +import { TestProviders } from '../../mock'; + +describe('hooks', () => { + describe('useApplicationCapabilities', () => { + it('should return the correct capabilities', async () => { + const { result } = renderHook<{}, ReturnType>( + () => useApplicationCapabilities(), + { + wrapper: ({ children }) => {children}, + } + ); + + expect(result.current).toEqual({ + actions: { crud: true, read: true }, + generalCases: { crud: true, read: true }, + visualize: { crud: true, read: true }, + dashboard: { crud: true, read: true }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index f8b3fc1df7b44b..127274e5ed55fe 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -7,7 +7,7 @@ import moment from 'moment-timezone'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { @@ -160,22 +160,48 @@ export const useNavigation = (appId: string) => { return { navigateTo, getAppUrl }; }; +interface Capabilities { + crud: boolean; + read: boolean; +} +interface UseApplicationCapabilities { + actions: Capabilities; + generalCases: Capabilities; + visualize: Capabilities; + dashboard: Capabilities; +} + /** - * Returns the capabilities of the main cases application + * Returns the capabilities of various applications * */ -export const useApplicationCapabilities = (): { crud: boolean; read: boolean } => { - const capabilities = useKibana().services.application.capabilities; - const casesCapabilities = capabilities[FEATURE_ID]; - return { - crud: !!casesCapabilities?.crud_cases, - read: !!casesCapabilities?.read_cases, - }; -}; -export const useKibanaCapabilities = (): { visualize?: boolean; dashboard?: boolean } => { + +export const useApplicationCapabilities = (): UseApplicationCapabilities => { const capabilities = useKibana().services?.application?.capabilities; + const casesCapabilities = capabilities[FEATURE_ID]; - return { - visualize: !!capabilities?.visualize?.save, - }; + return useMemo( + () => ({ + actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show }, + generalCases: { + crud: !!casesCapabilities?.crud_cases, + read: !!casesCapabilities?.read_cases, + }, + visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show }, + dashboard: { + crud: !!capabilities.dashboard?.createNew, + read: !!capabilities.dashboard?.show, + }, + }), + [ + capabilities.actions?.save, + capabilities.actions?.show, + capabilities.dashboard?.createNew, + capabilities.dashboard?.show, + capabilities.visualize?.save, + capabilities.visualize?.show, + casesCapabilities?.crud_cases, + casesCapabilities?.read_cases, + ] + ); }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts index 05da3dd75cf609..90291201d74e11 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts @@ -19,6 +19,8 @@ import { securityMock } from '../../../../../security/public/mocks'; import { spacesPluginMock } from '../../../../../spaces/public/mocks'; import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks'; import { BehaviorSubject } from 'rxjs'; +import { registerConnectorsToMockActionRegistry } from '../../mock/register_connectors'; +import { connectorsMock } from '../../mock/connectors'; export const createStartServicesMock = (): StartServices => { const services = { @@ -38,6 +40,24 @@ export const createStartServicesMock = (): StartServices => { new Map([['testAppId', { category: { label: 'Test' } } as unknown as PublicAppInfo]]) ); + services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ + actionTypeTitle: '.servicenow', + iconClass: 'logoSecurity', + }); + + registerConnectorsToMockActionRegistry( + services.triggersActionsUi.actionTypeRegistry, + connectorsMock + ); + + services.application.capabilities = { + ...services.application.capabilities, + actions: { save: true, show: true }, + generalCases: { crud_cases: true, read_cases: true }, + visualize: { save: true, show: true }, + dashboard: { show: true, createNew: true }, + }; + return services; }; diff --git a/x-pack/plugins/cases/public/common/mock/connectors.ts b/x-pack/plugins/cases/public/common/mock/connectors.ts new file mode 100644 index 00000000000000..01afbbee118a87 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/connectors.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 { ActionConnector, ActionTypeConnector } from '../../../common/api'; + +export const connectorsMock: ActionConnector[] = [ + { + id: 'servicenow-1', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, + { + id: 'resilient-2', + actionTypeId: '.resilient', + name: 'My Connector 2', + config: { + apiUrl: 'https://test/', + orgId: '201', + }, + isPreconfigured: false, + }, + { + id: 'jira-1', + actionTypeId: '.jira', + name: 'Jira', + config: { + apiUrl: 'https://instance.atlassian.ne', + }, + isPreconfigured: false, + }, + { + id: 'servicenow-sir', + actionTypeId: '.servicenow-sir', + name: 'My Connector SIR', + config: { + apiUrl: 'https://instance1.service-now.com', + }, + isPreconfigured: false, + }, + { + id: 'servicenow-uses-table-api', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + usesTableApi: true, + }, + isPreconfigured: false, + }, +]; + +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow-sir', + name: 'ServiceNow SIR', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.ts index 5965ccbcf504eb..a7d4405ced030f 100644 --- a/x-pack/plugins/cases/public/common/test_utils.ts +++ b/x-pack/plugins/cases/public/common/test_utils.ts @@ -12,8 +12,8 @@ import { MatcherFunction } from '@testing-library/react'; /** * Convenience utility to remove text appended to links by EUI */ -export const removeExternalLinkText = (str: string) => - str.replace(/\(opens in a new tab or window\)/g, ''); +export const removeExternalLinkText = (str: string | null) => + str?.replace(/\(opens in a new tab or window\)/g, ''); export async function waitForComponentToPaint

(wrapper: ReactWrapper

, amount = 0) { await act(async () => { diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 61554f5191dc83..5c349a65dd8694 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -276,3 +276,11 @@ export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { export const APP_DESC = i18n.translate('xpack.cases.common.appDescription', { defaultMessage: 'Open and track issues, push information to third party systems.', }); + +export const READ_ACTIONS_PERMISSIONS_ERROR_MSG = i18n.translate( + 'xpack.cases.configure.readPermissionsErrorDescription', + { + defaultMessage: + 'You do not have permissions to view connectors. If you would like to view connectors, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 1ceb950ee201d4..eae099404d3181 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -99,7 +99,7 @@ export const AllCasesList = React.memo( // Post Comment to Case const { postComment, isLoading: isCommentUpdating } = usePostComment(); - const { connectors } = useConnectors({ toastPermissionsErrors: false }); + const { connectors } = useConnectors(); const sorting = useMemo( () => ({ diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 090ac0d31ed062..764a51443b0e35 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -11,23 +11,24 @@ import { mount } from 'enzyme'; import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; -import { useKibana } from '../../common/lib/kibana'; import { connectors } from '../configure_cases/__mock__'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; describe('ExternalServiceColumn ', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); + let appMockRender: AppMockRenderer; + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('Not pushed render', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() @@ -36,7 +37,12 @@ describe('ExternalServiceColumn ', () => { it('Up to date', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() @@ -45,7 +51,12 @@ describe('ExternalServiceColumn ', () => { it('Needs update', () => { const wrapper = mount( - + + + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() @@ -56,19 +67,42 @@ describe('ExternalServiceColumn ', () => { // If the component throws the test will fail expect(() => mount( - + + + ) ).not.toThrowError(); }); + + it('shows the connectors icon if the user has read access to actions', async () => { + const result = appMockRender.render( + + ); + + expect(result.getByTestId('cases-table-connector-icon')).toBeInTheDocument(); + }); + + it('hides the connectors icon if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render( + + ); + + expect(result.queryByTestId('cases-table-connector-icon')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 391bef00b6e868..f92f1605c4c511 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -34,7 +34,7 @@ import { getActions } from './actions'; import { UpdateCase } from '../../containers/use_get_cases'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; -import { useKibana } from '../../common/lib/kibana'; +import { useApplicationCapabilities, useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; @@ -409,6 +409,7 @@ const IconWrapper = styled.span` export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) => { const { triggersActionsUi } = useKibana().services; + const { actions } = useApplicationCapabilities(); if (theCase.externalService == null) { return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); @@ -426,13 +427,16 @@ export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) return (

- - - + {actions.read && ( + + + + )} ; const useConnectorsMock = useConnectors as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; describe('AllCases', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - const dispatchUpdateCaseProperty = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); @@ -70,7 +64,6 @@ describe('AllCases', () => { }; beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); (useGetReporters as jest.Mock).mockReturnValue({ reporters: ['casetester'], diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 5fb46676d92316..ba2a61ec6691f2 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -23,7 +23,7 @@ const CasesAppComponent: React.FC = () => { {getCasesLazy({ owner: [APP_OWNER], useFetchAlertData: () => [false, {}], - userCanCrud: userCapabilities.crud, + userCanCrud: userCapabilities.generalCases.crud, basePath: '/', features: { alerts: { enabled: false } }, releasePhase: 'experimental', diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index e5564ee9429aae..a933e823f8b3a4 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -17,13 +17,13 @@ import { basicCaseMetrics, caseUserActions, getAlertUserAction, + connectorsMock, } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { ConnectorTypes } from '../../../common/api'; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 235c8eabc9e595..72d976f45f6180 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -202,13 +202,7 @@ export const CaseViewPage = React.memo( updateCase, ]); - const { - loading: isLoadingConnectors, - connectors, - permissionsError, - } = useConnectors({ - toastPermissionsErrors: false, - }); + const { loading: isLoadingConnectors, connectors } = useConnectors(); const [connectorName, isValidConnector] = useMemo(() => { const connector = connectors.find((c) => c.id === caseData.connector.id); @@ -403,7 +397,6 @@ export const CaseViewPage = React.memo( isLoading={isLoadingConnectors || (isLoading && loadingKey === 'connector')} isValidConnector={isLoadingConnectors ? true : isValidConnector} onSubmit={onSubmitConnector} - permissionsError={permissionsError} updateCase={handleUpdateCase} userActions={caseUserActions} userCanCrud={userCanCrud} diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx index 9d41e3ec0f7fc7..d7d8ca6df8f8d6 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx @@ -18,6 +18,7 @@ import { alertComment, getAlertUserAction, basicCaseMetrics, + connectorsMock, } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; import { SpacesApi } from '../../../../spaces/public'; @@ -27,7 +28,6 @@ import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { ConnectorTypes } from '../../../common/api'; import { Case } from '../../../common/ui'; diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index 49ac3737243363..46dbbdbe9a196b 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -10,7 +10,7 @@ import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; -import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; +import { connectorsMock, actionTypesMock } from '../../../common/mock/connectors'; export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 7a6bca518ac3e5..955e114561ce86 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -10,18 +10,14 @@ import { mount, ReactWrapper } from 'enzyme'; import { render, screen } from '@testing-library/react'; import { Connectors, Props } from './connectors'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors, actionTypes } from './__mock__'; import { ConnectorTypes } from '../../../common/api'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('Connectors', () => { let wrapper: ReactWrapper; + let appMockRender: AppMockRenderer; const onChangeConnector = jest.fn(); const handleShowEditFlyout = jest.fn(); @@ -37,28 +33,30 @@ describe('Connectors', () => { updateConnectorDisabled: false, }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); - test('it shows the connectors from group', () => { + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('shows the connectors from group', () => { expect(wrapper.find('[data-test-subj="case-connectors-form-group"]').first().exists()).toBe( true ); }); - test('it shows the connectors form row', () => { + it('shows the connectors form row', () => { expect(wrapper.find('[data-test-subj="case-connectors-form-row"]').first().exists()).toBe(true); }); - test('it shows the connectors dropdown', () => { + it('shows the connectors dropdown', () => { expect(wrapper.find('[data-test-subj="case-connectors-dropdown"]').first().exists()).toBe(true); }); - test('it pass the correct props to child', () => { + it('pass the correct props to child', () => { const connectorsDropdownProps = wrapper.find(ConnectorsDropdown).props(); expect(connectorsDropdownProps).toMatchObject({ disabled: false, @@ -69,7 +67,7 @@ describe('Connectors', () => { }); }); - test('the connector is changed successfully', () => { + it('the connector is changed successfully', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); @@ -77,7 +75,7 @@ describe('Connectors', () => { expect(onChangeConnector).toHaveBeenCalledWith('resilient-2'); }); - test('the connector is changed successfully to none', () => { + it('the connector is changed successfully to none', () => { onChangeConnector.mockClear(); const newWrapper = mount( { expect(onChangeConnector).toHaveBeenCalledWith('none'); }); - test('it shows the add connector button', () => { + it('shows the add connector button', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); @@ -105,7 +103,7 @@ describe('Connectors', () => { ).toBeTruthy(); }); - test('the text of the update button is shown correctly', () => { + it('the text of the update button is shown correctly', () => { const newWrapper = mount( { ).toBe('Update My Connector'); }); - test('it shows the deprecated callout when the connector is deprecated', async () => { + it('shows the deprecated callout when the connector is deprecated', async () => { render( { expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); }); - test('it does not shows the deprecated callout when the connector is none', async () => { + it('does not shows the deprecated callout when the connector is none', async () => { render(, { // wrapper: TestProviders produces a TS error wrapper: ({ children }) => {children}, @@ -147,4 +145,17 @@ describe('Connectors', () => { expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument(); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + expect( + result.getByTestId('configure-case-connector-permissions-error-msg') + ).toBeInTheDocument(); + expect(result.queryByTestId('case-connectors-dropdown')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index e75f7ed2bdffad..4b608246a4c222 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -12,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, + EuiText, } from '@elastic/eui'; import styled from 'styled-components'; @@ -24,6 +25,7 @@ import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common/api'; import { DeprecatedCallout } from '../connectors/deprecated_callout'; import { isDeprecatedConnector } from '../utils'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -55,6 +57,7 @@ const ConnectorsComponent: React.FC = ({ selectedConnector, updateConnectorDisabled, }) => { + const { actions } = useApplicationCapabilities(); const connector = useMemo( () => connectors.find((c) => c.id === selectedConnector.id), [connectors, selectedConnector.id] @@ -101,15 +104,21 @@ const ConnectorsComponent: React.FC = ({ > - + {actions.read ? ( + + ) : ( + + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} + + )} {selectedConnector.type !== ConnectorTypes.none && isDeprecatedConnector(connector) && ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 127c6c30febfbb..4fd56525541a6a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -13,11 +13,6 @@ import { render, screen } from '@testing-library/react'; import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; import { connectors } from './__mock__'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorsDropdown', () => { let wrapper: ReactWrapper; @@ -29,10 +24,7 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx index 7a07e87a1da4c7..6254150620fd4d 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -9,21 +9,9 @@ import React from 'react'; import { mount } from 'enzyme'; import { ConnectorTypes } from '../../../common/api'; -import { useKibana } from '../../common/lib/kibana'; -import { connectors } from '../configure_cases/__mock__'; import { ConnectorCard } from './card'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorCard ', () => { - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); - }); - it('it does not throw when accessing the icon if the connector type is not registered', () => { expect(() => mount( diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index b48e8ef82ac0eb..7a2a4b366c7a14 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -18,13 +18,10 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetChoices } from '../connectors/servicenow/use_get_choices'; import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; -import { useKibana } from '../../common/lib/kibana'; -import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; -jest.mock('../../common/lib/kibana'); jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); @@ -34,7 +31,6 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; -const useKibanaMock = useKibana as jest.Mocked; const useGetIncidentTypesResponse = { isLoading: false, @@ -58,6 +54,7 @@ const defaultProps = { }; describe('Connector', () => { + let appMockRender: AppMockRenderer; let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { @@ -74,14 +71,9 @@ describe('Connector', () => { return

{children}
; }; - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; - - beforeAll(() => { - registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); - }); - beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); @@ -179,4 +171,19 @@ describe('Connector', () => { }); }); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('create-case-connector-permissions-error-msg')).toBeInTheDocument(); + expect(result.queryByTestId('caseConnectors')).toBe(null); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 3b479927ee0692..ca196f06908b1c 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useCallback, useMemo, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { ActionConnector } from '../../../common/api'; import { @@ -21,6 +21,8 @@ import { ConnectorFieldsForm } from '../connectors/fields_form'; import { FormProps, schema } from './schema'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; +import * as i18n from '../../common/translations'; interface Props { connectors: ActionConnector[]; @@ -54,6 +56,7 @@ ConnectorFields.displayName = 'ConnectorFields'; const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const { getFields, setFieldValue } = useFormContext(); const { connector: configurationConnector } = useCaseConfigure(); + const { actions } = useApplicationCapabilities(); const handleConnectorChange = useCallback(() => { const { fields } = getFields(); @@ -76,6 +79,14 @@ const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingC connectors, }); + if (!actions.read) { + return ( + + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} + + ); + } + return ( diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index a28e6819f02682..1fe13b17e7337a 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -6,17 +6,19 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { act } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; +import userEvent, { specialChars } from '@testing-library/user-event'; import { useForm, Form, FormHook } from '../../common/shared_imports'; import { Description } from './description'; import { schema, FormProps } from './schema'; +import { createAppMockRenderer, AppMockRenderer } from '../../common/mock'; jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); describe('Description', () => { let globalForm: FormHook; + let appMockRender: AppMockRenderer; const MockHookWrapperComponent: React.FC = ({ children }) => { const { form } = useForm({ @@ -33,32 +35,33 @@ describe('Description', () => { beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); it('it renders', async () => { - const wrapper = mount( + const result = appMockRender.render( ); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); + expect(result.getByTestId('caseDescription')).toBeInTheDocument(); }); it('it changes the description', async () => { - const wrapper = mount( + const result = appMockRender.render( ); - await act(async () => { - wrapper - .find(`[data-test-subj="caseDescription"] textarea`) - .first() - .simulate('change', { target: { value: 'My new description' } }); - }); + userEvent.type( + result.getByRole('textbox'), + `${specialChars.selectAll}${specialChars.delete}My new description` + ); - expect(globalForm.getFormData()).toEqual({ description: 'My new description' }); + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ description: 'My new description' }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index f70ea7eba48961..5e62415def1540 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -18,7 +18,6 @@ import { usePostComment } from '../../containers/use_post_comment'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { connectorsMock } from '../../containers/configure/mock'; import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; import { useGetSeverity } from '../connectors/resilient/use_get_severity'; import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; @@ -41,6 +40,7 @@ import { SubmitCaseButton } from './submit_button'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { Choice } from '../connectors/servicenow/types'; import userEvent from '@testing-library/user-event'; +import { connectorsMock } from '../../common/mock/connectors'; const sampleId = 'case-id'; diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 0512501bf5e111..040fe0866a84ee 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -8,17 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; import { render, waitFor, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { EditConnector, EditConnectorProps } from './index'; -import { TestProviders } from '../../common/mock'; -import { connectorsMock } from '../../containers/configure/mock'; -import { basicCase, basicPush, caseUserActions } from '../../containers/mock'; -import { useKibana } from '../../common/lib/kibana'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { basicCase, basicPush, caseUserActions, connectorsMock } from '../../containers/mock'; import { CaseConnector } from '../../containers/configure/types'; -import userEvent from '@testing-library/user-event'; - -jest.mock('../../common/lib/kibana'); -const useKibanaMock = useKibana as jest.Mocked; const onSubmit = jest.fn(); const updateCase = jest.fn(); @@ -48,12 +43,10 @@ const getDefaultProps = (): EditConnectorProps => { }; describe('EditConnector ', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: '.servicenow', - iconClass: 'logoSecurity', - }); + appMockRender = createAppMockRenderer(); }); it('Renders servicenow connector from case initially', async () => { @@ -224,28 +217,6 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy(); }); - it('displays the permissions error message when one is provided', async () => { - const defaultProps = getDefaultProps(); - const props = { ...defaultProps, permissionsError: 'error message' }; - const wrapper = mount( - - - - ); - - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() - ).toBeTruthy(); - - expect( - wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() - ).toBeFalsy(); - - expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy(); - }); - }); - it('displays the callout message when none is selected', async () => { const defaultProps = getDefaultProps(); const props = { ...defaultProps, connectors: [] }; @@ -336,4 +307,58 @@ describe('EditConnector ', () => { expect(true).toBeTruthy(); }); }); + + it('shows the actions permission message if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); + }); + }); + + it('does not show the actions permission message if the user has read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: true, show: true }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.queryByTestId('edit-connector-permissions-error-msg')).toBe(null); + }); + }); + + it('does not show the callout if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + const props = { ...defaultProps, connectors: [] }; + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); + expect(result.queryByTestId('push-callouts')).toBe(null); + }); + }); + + it('does not show the push button if the user does not have read access to actions', async () => { + const defaultProps = getDefaultProps(); + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render(); + await waitFor(() => { + expect(result.queryByTestId('has-data-to-push-button')).toBe(null); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 7db20170c78577..85bf7de10b7ca8 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -31,6 +31,7 @@ import * as i18n from './translations'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { usePushToService } from '../use_push_to_service'; import { CaseServices } from '../../containers/use_get_case_user_actions'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; export interface EditConnectorProps { caseData: Case; @@ -46,7 +47,6 @@ export interface EditConnectorProps { onError: () => void, onSuccess: () => void ) => void; - permissionsError?: string; updateCase: (newCase: Case) => void; userActions: CaseUserActions[]; userCanCrud?: boolean; @@ -119,18 +119,20 @@ export const EditConnector = React.memo( isLoading, isValidConnector, onSubmit, - permissionsError, updateCase, userActions, userCanCrud = true, }: EditConnectorProps) => { const caseFields = caseData.connector.fields; const selectedConnector = caseData.connector.id; + const { form } = useForm({ defaultValue: { connectorId: selectedConnector }, options: { stripEmptyFields: false }, schema, }); + const { actions } = useApplicationCapabilities(); + const actionsReadCapabilities = actions.read; // by default save if disabled const [enableSave, setEnableSave] = useState(false); @@ -303,7 +305,7 @@ export const EditConnector = React.memo( - {!isLoading && !editConnector && pushCallouts && permissionsError == null && ( + {!isLoading && !editConnector && pushCallouts && actionsReadCapabilities && ( {pushCallouts} )} @@ -330,9 +332,9 @@ export const EditConnector = React.memo( - {!editConnector && permissionsError && ( + {!editConnector && !actionsReadCapabilities && ( - {permissionsError} + {i18n.READ_ACTIONS_PERMISSIONS_ERROR_MSG} )} {pushButton} diff --git a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx index 5d299529561ba8..af803cfc14e05b 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/renderer.test.tsx @@ -6,55 +6,54 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { removeExternalLinkText } from '../../common/test_utils'; import { MarkdownRenderer } from './renderer'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; describe('Markdown', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + describe('markdown links', () => { const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; test('it renders the expected link text', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect( - removeExternalLinkText(wrapper.find('[data-test-subj="markdown-link"]').first().text()) - ).toEqual('External Site'); + expect(removeExternalLinkText(result.getByTestId('markdown-link').textContent)).toEqual( + 'External Site' + ); }); test('it renders the expected href', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'href', - 'https://google.com/' - ); + expect(result.getByTestId('markdown-link')).toHaveProperty('href', 'https://google.com/'); }); test('it does NOT render the href if links are disabled', () => { - const wrapper = mount( + const result = appMockRender.render( {markdownWithLink} ); - expect( - wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() - ).not.toHaveProperty('href'); + expect(result.getByTestId('markdown-link')).not.toHaveProperty('href'); }); test('it opens links in a new tab via target="_blank"', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'target', - '_blank' - ); + expect(result.getByTestId('markdown-link')).toHaveProperty('target', '_blank'); }); test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { - const wrapper = mount({markdownWithLink}); + const result = appMockRender.render({markdownWithLink}); - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + expect(result.getByTestId('markdown-link')).toHaveProperty( 'rel', 'nofollow noopener noreferrer' ); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts index b11d3e42b0b9e6..15d92d1c7f1a17 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_plugins.ts @@ -13,14 +13,14 @@ import { import { useMemo } from 'react'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { TemporaryProcessingPluginsType } from './types'; -import { KibanaServices, useKibanaCapabilities } from '../../common/lib/kibana'; +import { KibanaServices, useApplicationCapabilities } from '../../common/lib/kibana'; import * as lensMarkdownPlugin from './plugins/lens'; import { ID as LensPluginId } from './plugins/lens/constants'; export const usePlugins = (disabledPlugins?: string[]) => { const kibanaConfig = KibanaServices.getConfig(); const timelinePlugins = useTimelineContext()?.editor_plugins; - const appCapabilities = useKibanaCapabilities(); + const appCapabilities = useApplicationCapabilities(); return useMemo(() => { const uiPlugins = getDefaultEuiMarkdownUiPlugins(); @@ -40,7 +40,7 @@ export const usePlugins = (disabledPlugins?: string[]) => { if ( kibanaConfig?.markdownPlugins?.lens && !disabledPlugins?.includes(LensPluginId) && - appCapabilities?.visualize + appCapabilities?.visualize.crud ) { uiPlugins.push(lensMarkdownPlugin.plugin); } @@ -55,7 +55,7 @@ export const usePlugins = (disabledPlugins?: string[]) => { processingPlugins, }; }, [ - appCapabilities?.visualize, + appCapabilities?.visualize.crud, disabledPlugins, kibanaConfig?.markdownPlugins?.lens, timelinePlugins, diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx index 4f99f23b9b2082..74b3a463982926 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -6,10 +6,10 @@ */ import React from 'react'; -import { configure, render } from '@testing-library/react'; +import { configure } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import RecentCases, { RecentCasesProps } from '.'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesMockState } from '../../containers/mock'; import { useCurrentUser } from '../../common/lib/kibana/hooks'; @@ -33,6 +33,7 @@ const useGetCasesMock = useGetCases as jest.Mock; const useCurrentUserMock = useCurrentUser as jest.Mock; describe('RecentCases', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); useGetCasesMock.mockImplementation(() => mockData); @@ -41,6 +42,7 @@ describe('RecentCases', () => { fullName: 'Elastic', username: 'elastic', }); + appMockRender = createAppMockRenderer(); }); it('is good at loading', () => { @@ -48,7 +50,8 @@ describe('RecentCases', () => { ...mockData, loading: 'cases', })); - const { getAllByTestId } = render( + + const { getAllByTestId } = appMockRender.render( @@ -57,7 +60,7 @@ describe('RecentCases', () => { }); it('is good at rendering cases', () => { - const { getAllByTestId } = render( + const { getAllByTestId } = appMockRender.render( @@ -66,7 +69,7 @@ describe('RecentCases', () => { }); it('is good at rendering max cases', () => { - render( + appMockRender.render( @@ -77,7 +80,7 @@ describe('RecentCases', () => { }); it('updates filters', () => { - const { getByTestId } = render( + const { getByTestId } = appMockRender.render( @@ -89,7 +92,7 @@ describe('RecentCases', () => { }); it('it resets the reporters when changing from my recently reported cases to recent cases', () => { - const { getByTestId } = render( + const { getByTestId } = appMockRender.render( diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index cf81b5195a9610..1c97c6ff305068 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -14,9 +14,8 @@ import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../common/mock'; import { CaseStatuses, ConnectorTypes } from '../../../common/api'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { basicPush, actionLicenses } from '../../containers/mock'; +import { basicPush, actionLicenses, connectorsMock } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { connectorsMock } from '../../containers/configure/mock'; import { CLOSED_CASE_PUSH_ERROR_ID } from './callout/types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts index 10cfde0c5ef9c6..b24213cc43af73 100644 --- a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -14,7 +14,8 @@ import { import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; -import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; +import { caseConfigurationCamelCaseResponseMock } from '../mock'; +import { actionTypesMock, connectorsMock } from '../../../common/mock/connectors'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => Promise.resolve(connectorsMock); diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts index a315a455ec2a24..7b5db5692011e6 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -13,8 +13,6 @@ import { fetchActionTypes, } from './api'; import { - connectorsMock, - actionTypesMock, caseConfigurationMock, caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, @@ -22,6 +20,7 @@ import { import { ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; +import { actionTypesMock, connectorsMock } from '../../common/mock/connectors'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index bbcf420324c839..c75d2c839534de 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - ActionConnector, - ActionTypeConnector, - CasesConfigureResponse, - CasesConfigureRequest, - ConnectorTypes, -} from '../../../common/api'; +import { CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { CaseConfigure, CaseConnectorMapping } from './types'; @@ -33,107 +27,6 @@ export const mappings: CaseConnectorMapping[] = [ }, ]; -export const connectorsMock: ActionConnector[] = [ - { - id: 'servicenow-1', - actionTypeId: '.servicenow', - name: 'My Connector', - config: { - apiUrl: 'https://instance1.service-now.com', - }, - isPreconfigured: false, - }, - { - id: 'resilient-2', - actionTypeId: '.resilient', - name: 'My Connector 2', - config: { - apiUrl: 'https://test/', - orgId: '201', - }, - isPreconfigured: false, - }, - { - id: 'jira-1', - actionTypeId: '.jira', - name: 'Jira', - config: { - apiUrl: 'https://instance.atlassian.ne', - }, - isPreconfigured: false, - }, - { - id: 'servicenow-sir', - actionTypeId: '.servicenow-sir', - name: 'My Connector SIR', - config: { - apiUrl: 'https://instance1.service-now.com', - }, - isPreconfigured: false, - }, - { - id: 'servicenow-uses-table-api', - actionTypeId: '.servicenow', - name: 'My Connector', - config: { - apiUrl: 'https://instance1.service-now.com', - usesTableApi: true, - }, - isPreconfigured: false, - }, -]; - -export const actionTypesMock: ActionTypeConnector[] = [ - { - id: '.email', - name: 'Email', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.index', - name: 'Index', - minimumLicenseRequired: 'basic', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.resilient', - name: 'IBM Resilient', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.servicenow-sir', - name: 'ServiceNow SIR', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, -]; - export const caseConfigurationResposeMock: CasesConfigureResponse = { id: '123', created_at: '2020-04-06T13:03:18.657Z', diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts index 01900b8850c195..e77b9f57c8f4c7 100644 --- a/x-pack/plugins/cases/public/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -12,11 +12,3 @@ export * from '../translations'; export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { defaultMessage: 'Saved external connection settings', }); - -export const READ_PERMISSIONS_ERROR_MSG = i18n.translate( - 'xpack.cases.configure.readPermissionsErrorDescription', - { - defaultMessage: - 'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.', - } -); diff --git a/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx index fad84617ee140c..3b19e74d092089 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_action_types.test.tsx @@ -7,8 +7,8 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useActionTypes, UseActionTypesResponse } from './use_action_types'; -import { actionTypesMock } from './mock'; import * as api from './api'; +import { actionTypesMock } from '../../common/mock/connectors'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx index e3d2650fee025f..b1a3bac22d56fa 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.test.tsx @@ -5,41 +5,53 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useConnectors, UseConnectorsResponse } from './use_connectors'; -import { connectorsMock } from './mock'; import * as api from './api'; +import { connectorsMock } from '../mock'; +import { TestProviders } from '../../common/mock'; +import { useApplicationCapabilities } from '../../common/lib/kibana'; + +const useApplicationCapabilitiesMock = useApplicationCapabilities as jest.Mocked< + typeof useApplicationCapabilities +>; -jest.mock('./api'); jest.mock('../../common/lib/kibana'); +jest.mock('./api'); describe('useConnectors', () => { beforeEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); }); - test('init', async () => { + it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() - ); - await waitForNextUpdate(); - expect(result.current).toEqual({ - loading: true, - connectors: [], - refetchConnectors: result.current.refetchConnectors, + const { result, waitFor } = renderHook(() => useConnectors(), { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + loading: true, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); }); }); }); - test('fetch connectors', async () => { + it('fetch connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); + await waitForNextUpdate(); - await waitForNextUpdate(); + expect(result.current).toEqual({ loading: false, connectors: connectorsMock, @@ -48,44 +60,97 @@ describe('useConnectors', () => { }); }); - test('refetch connectors', async () => { + it('refetch connectors', async () => { const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); - await waitForNextUpdate(); result.current.refetchConnectors(); expect(spyOnfetchConnectors).toHaveBeenCalledTimes(2); }); }); - test('set isLoading to true when refetching connectors', async () => { + it('set isLoading to true when refetching connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); - await waitForNextUpdate(); result.current.refetchConnectors(); expect(result.current.loading).toBe(true); }); }); - test('unhappy path', async () => { + it('unhappy path', async () => { const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); spyOnfetchConnectors.mockImplementation(() => { throw new Error('Something went wrong'); }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useConnectors() + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + }); + + it('does not fetch connectors when the user does not has access to actions', async () => { + const spyOnFetchConnectors = jest.spyOn(api, 'fetchConnectors'); + useApplicationCapabilitiesMock().actions = { crud: false, read: false }; + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + connectors: [], + refetchConnectors: result.current.refetchConnectors, + }); + }); + + expect(spyOnFetchConnectors).not.toHaveBeenCalled(); + }); + + it('does not refetch connectors when the user does not has access to actions', async () => { + const spyOnFetchConnectors = jest.spyOn(api, 'fetchConnectors'); + useApplicationCapabilitiesMock().actions = { crud: false, read: false }; + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useConnectors(), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + result.current.refetchConnectors(); expect(result.current).toEqual({ loading: false, @@ -93,5 +158,7 @@ describe('useConnectors', () => { refetchConnectors: result.current.refetchConnectors, }); }); + + expect(spyOnFetchConnectors).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index e350146c650ce3..e8176f5f397e84 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -9,13 +9,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; -import { useToasts } from '../../common/lib/kibana'; +import { useApplicationCapabilities, useToasts } from '../../common/lib/kibana'; import * as i18n from './translations'; interface ConnectorsState { loading: boolean; connectors: ActionConnector[]; - permissionsError?: string; } export interface UseConnectorsResponse { @@ -30,12 +29,9 @@ export interface UseConnectorsResponse { * * @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error */ -export const useConnectors = ({ - toastPermissionsErrors = true, -}: { - toastPermissionsErrors?: boolean; -} = {}): UseConnectorsResponse => { +export const useConnectors = (): UseConnectorsResponse => { const toasts = useToasts(); + const { actions } = useApplicationCapabilities(); const [state, setState] = useState({ loading: true, connectors: [], @@ -43,8 +39,16 @@ export const useConnectors = ({ const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); - const refetchConnectors = useCallback(async () => { + if (!actions.read) { + setState({ + loading: false, + connectors: [], + }); + + return; + } + try { isCancelledRef.current = false; abortCtrlRef.current.abort(); @@ -63,26 +67,15 @@ export const useConnectors = ({ } } catch (error) { if (!isCancelledRef.current) { - let permissionsError: string | undefined; if (error.name !== 'AbortError') { - // if the error was related to permissions then let's return a boilerplate error message describing the problem - if (error.body?.statusCode === 403 || error.body?.statusCode === 401) { - permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG; - } - - // if the error was not permissions related then toast it - // if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast - if (permissionsError === undefined || toastPermissionsErrors) { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } setState({ loading: false, connectors: [], - permissionsError, }); } } @@ -102,6 +95,5 @@ export const useConnectors = ({ loading: state.loading, connectors: state.connectors, refetchConnectors, - permissionsError: state.permissionsError, }; }; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 0d2aff225dfa60..5c2fcd70db2bb1 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -31,11 +31,12 @@ import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { SnakeToCamelCase } from '../../common/types'; import { covertToSnakeCase } from './utils'; -export { connectorsMock } from './configure/mock'; +export { connectorsMock } from '../common/mock/connectors'; export const basicCaseId = 'basic-case-id'; export const caseWithAlertsId = 'case-with-alerts-id'; export const caseWithAlertsSyncOffId = 'case-with-alerts-syncoff-id'; + const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index 661afac394e1cc..1132b7a348b5d8 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -14,7 +14,7 @@ export const allNavigationItems: Record = { findings: { name: TEXT.FINDINGS, path: '/findings' }, rules: { name: 'Rules', - path: '/benchmarks/:packageId/:policyId/rules', + path: '/benchmarks/:packagePolicyId/:policyId/rules', disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, benchmarks: { diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts new file mode 100644 index 00000000000000..f8b7685c776e09 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_navigate_to_cis_integration.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pagePathGetters } from '../../../../fleet/public'; +import { useKibana } from '../hooks/use_kibana'; + +const CIS_INTEGRATION_PATH = pagePathGetters.integrations_all({ searchTerm: 'CIS' }).join(''); + +export const useCISIntegrationLink = () => { + const { http } = useKibana().services; + return http.basePath.prepend(CIS_INTEGRATION_PATH); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx index eb89284f273514..60586dd3c5c052 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_loading_state.tsx @@ -8,8 +8,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React from 'react'; +export const cspLoadingStateTestId = 'csp_loading_state'; + export const CspLoadingState: React.FunctionComponent = ({ children }) => ( - + diff --git a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx index f164b7b92fc72c..45cae3f996e36d 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/page_template.tsx @@ -15,6 +15,7 @@ import { import { useKubebeatDataView } from '../common/api/use_kubebeat_data_view'; import { allNavigationItems } from '../common/navigation/constants'; import type { CspNavigationItem } from '../common/navigation/types'; +import { useCISIntegrationLink } from '../common/navigation/use_navigate_to_cis_integration'; import { CLOUD_SECURITY_POSTURE } from '../common/translations'; import { CspLoadingState } from './csp_loading_state'; import { @@ -45,12 +46,16 @@ export const getSideNavItems = ( const DEFAULT_PROPS: KibanaPageTemplateProps = { solutionNav: { name: CLOUD_SECURITY_POSTURE, - items: getSideNavItems(allNavigationItems), + items: getSideNavItems({ + dashboard: allNavigationItems.dashboard, + findings: allNavigationItems.findings, + benchmark: allNavigationItems.benchmarks, + }), }, restrictWidth: false, }; -const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = { +const getNoDataConfig = (cisIntegrationLink: string): KibanaPageTemplateProps['noDataConfig'] => ({ pageTitle: NO_DATA_CONFIG_TITLE, solution: NO_DATA_CONFIG_SOLUTION_NAME, // TODO: Add real docs link once we have it @@ -58,20 +63,21 @@ const NO_DATA_CONFIG: KibanaPageTemplateProps['noDataConfig'] = { logo: 'logoSecurity', actions: { elasticAgent: { - // TODO: Use `href` prop to link to our own integration once we have it + href: cisIntegrationLink, title: NO_DATA_CONFIG_BUTTON, description: NO_DATA_CONFIG_DESCRIPTION, }, }, -}; +}); export const CspPageTemplate: React.FC = ({ children, ...props }) => { // TODO: Consider using more sophisticated logic to find out if our integration is installed const kubeBeatQuery = useKubebeatDataView(); + const cisIntegrationLink = useCISIntegrationLink(); let noDataConfig: KibanaPageTemplateProps['noDataConfig']; if (kubeBeatQuery.status === 'success' && !kubeBeatQuery.data) { - noDataConfig = NO_DATA_CONFIG; + noDataConfig = getNoDataConfig(cisIntegrationLink); } let template: KibanaPageTemplateProps['template'] = 'default'; @@ -81,9 +87,9 @@ export const CspPageTemplate: React.FC = ({ children, . return ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx index 1c06d4162fc8bd..a86877af4112cc 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks.tsx @@ -20,25 +20,23 @@ import { FormattedMessage } from '@kbn/i18n-react'; import useDebounce from 'react-use/lib/useDebounce'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; +import { useCISIntegrationLink } from '../../common/navigation/use_navigate_to_cis_integration'; import { CspPageTemplate } from '../../components/page_template'; import { BenchmarksTable } from './benchmarks_table'; import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS } from './translations'; import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations'; -import { pagePathGetters } from '../../../../fleet/public'; -import { useKibana } from '../../common/hooks/use_kibana'; import { extractErrorMessage } from '../../../common/utils/helpers'; import { SEARCH_PLACEHOLDER } from './translations'; -const integrationPath = pagePathGetters.integrations_all({ searchTerm: 'CIS' }).join(''); const BENCHMARKS_BREADCRUMBS = [allNavigationItems.benchmarks]; const SEARCH_DEBOUNCE_MS = 300; export const BENCHMARKS_TABLE_DATA_TEST_SUBJ = 'cspBenchmarksTable'; const AddCisIntegrationButton = () => { - const { http } = useKibana().services; + const cisIntegrationLink = useCISIntegrationLink(); return ( - + {ADD_A_CIS_INTEGRATION} ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx index ac1b01b88a1b59..475d6c90773597 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx @@ -54,7 +54,7 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [ render: (packageName, benchmark) => ( history.push( generatePath(allNavigationItems.rules.path, { - packageId: benchmark.package_policy.id, + packagePolicyId: benchmark.package_policy.id, policyId: benchmark.package_policy.policy_id, }) ), diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx index 544e3542d1b59d..f53d76b82c177e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout.tsx @@ -6,9 +6,9 @@ */ import React, { useState } from 'react'; import { + EuiCodeBlock, EuiFlexItem, EuiSpacer, - EuiCode, EuiDescriptionList, EuiTextColor, EuiFlyout, @@ -20,14 +20,19 @@ import { EuiTab, EuiFlexGrid, EuiCard, - PropsOf, + EuiFlexGroup, + type PropsOf, } from '@elastic/eui'; import { assertNever } from '@kbn/std'; import type { CspFinding } from './types'; import { CspEvaluationBadge } from '../../components/csp_evaluation_badge'; import * as TEXT from './translations'; -const tabs = ['result', 'rule', 'resource'] as const; +const tabs = ['remediation', 'resource', 'general'] as const; + +const CodeBlock: React.FC> = (props) => ( + +); type FindingsTab = typeof tabs[number]; @@ -44,15 +49,22 @@ interface FindingFlyoutProps { } export const FindingsRuleFlyout = ({ onClose, findings }: FindingFlyoutProps) => { - const [tab, setTab] = useState('result'); + const [tab, setTab] = useState('remediation'); return ( - - -

{TEXT.FINDINGS}

-
-
+ + + + + + + + {findings.rule.name} + + + + {tabs.map((v) => ( @@ -78,11 +90,15 @@ const Cards = ({ data }: { data: Card[] }) => ( {data.map((card) => ( - + ({ title: v[0], description: v[1] }))} + style={{ flexFlow: 'column' }} + descriptionProps={{ + style: { width: '100%' }, + }} /> @@ -92,38 +108,68 @@ const Cards = ({ data }: { data: Card[] }) => ( const FindingsTab = ({ tab, findings }: { findings: CspFinding; tab: FindingsTab }) => { switch (tab) { - case 'result': - return ; - case 'rule': - return ; + case 'remediation': + return ; case 'resource': return ; + case 'general': + return ; default: assertNever(tab); } }; -const getResourceCards = ({ resource }: CspFinding): Card[] => [ +const getResourceCards = ({ resource, host }: CspFinding): Card[] => [ { title: TEXT.RESOURCE, listItems: [ - [TEXT.FILENAME, {resource.filename}], + [TEXT.FILENAME, {resource.filename}], [TEXT.MODE, resource.mode], - [TEXT.PATH, {resource.path}], + [TEXT.PATH, {resource.path}], [TEXT.TYPE, resource.type], [TEXT.UID, resource.uid], ], }, + { + title: TEXT.HOST, + listItems: [ + [TEXT.ARCHITECTURE, host.architecture], + [TEXT.CONTAINERIZED, host.containerized ? 'true' : 'false'], + [TEXT.HOSTNAME, host.hostname], + [TEXT.ID, {host.id}], + [TEXT.IP, {host.ip.join(', ')}], + [TEXT.MAC, {host.mac.join(', ')}], + [TEXT.NAME, host.name], + ], + }, + { + title: TEXT.OS, + listItems: [ + [TEXT.CODENAME, host.os.codename], + [TEXT.FAMILY, host.os.family], + [TEXT.KERNEL, host.os.kernel], + [TEXT.NAME, host.os.name], + [TEXT.PLATFORM, host.os.platform], + [TEXT.TYPE, host.os.type], + [TEXT.VERSION, host.os.version], + ], + }, ]; -const getRuleCards = ({ rule }: CspFinding): Card[] => [ +const getGeneralCards = ({ rule }: CspFinding): Card[] => [ { title: TEXT.RULE, listItems: [ - [TEXT.BENCHMARK, rule.benchmark], + [TEXT.SEVERITY, ''], + [TEXT.INDEX, ''], + [TEXT.RULE_EVALUATED_AT, ''], + [TEXT.FRAMEWORK_SOURCES, ''], + [TEXT.SECTION, ''], + [TEXT.PROFILE_APPLICABILITY, ''], + [TEXT.AUDIT, ''], + [TEXT.BENCHMARK, rule.benchmark.name], [TEXT.NAME, rule.name], [TEXT.DESCRIPTION, rule.description], - [TEXT.REMEDIATION, {rule.remediation}], [ TEXT.TAGS, rule.tags.map((t) => ( @@ -136,47 +182,22 @@ const getRuleCards = ({ rule }: CspFinding): Card[] => [ }, ]; -const getResultCards = ({ result, agent, host, ...rest }: CspFinding): Card[] => [ +const getRemediationCards = ({ result, ...rest }: CspFinding): Card[] => [ { title: TEXT.RESULT, listItems: [ - [TEXT.EVALUATION, ], - [TEXT.EVIDENCE, {JSON.stringify(result.evidence, null, 2)}], - [TEXT.TIMESTAMP, rest['@timestamp']], - result.evaluation === 'failed' && [TEXT.REMEDIATION, rest.rule.remediation], - ].filter(Boolean) as Card['listItems'], - }, - { - title: TEXT.AGENT, - listItems: [ - [TEXT.NAME, agent.name], - [TEXT.ID, agent.id], - [TEXT.TYPE, agent.type], - [TEXT.VERSION, agent.version], + [TEXT.EXPECTED, ''], + [TEXT.EVIDENCE, {JSON.stringify(result.evidence, null, 2)}], + [TEXT.TIMESTAMP, {rest['@timestamp']}], ], }, { - title: TEXT.HOST, + title: TEXT.REMEDIATION, listItems: [ - [TEXT.ARCHITECTURE, host.architecture], - [TEXT.CONTAINERIZED, host.containerized ? 'true' : 'false'], - [TEXT.HOSTNAME, host.hostname], - [TEXT.ID, host.id], - [TEXT.IP, host.ip.join(',')], - [TEXT.MAC, host.mac.join(',')], - [TEXT.NAME, host.name], - ], - }, - { - title: TEXT.OS, - listItems: [ - [TEXT.CODENAME, host.os.codename], - [TEXT.FAMILY, host.os.family], - [TEXT.KERNEL, host.os.kernel], - [TEXT.NAME, host.os.name], - [TEXT.PLATFORM, host.os.platform], - [TEXT.TYPE, host.os.type], - [TEXT.VERSION, host.os.version], + ['', {rest.rule.remediation}], + [TEXT.IMPACT, rest.rule.impact], + [TEXT.DEFAULT_VALUE, ''], + [TEXT.RATIONALE, ''], ], }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts index 3517589a37a589..610f7b8e6e721f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts @@ -6,142 +6,187 @@ */ import { i18n } from '@kbn/i18n'; -export const NAME = i18n.translate('xpack.csp.name', { +export const NAME = i18n.translate('xpack.csp.findings.nameLabel', { defaultMessage: 'Name', }); -export const SEARCH_FAILED = i18n.translate('xpack.csp.search_failed', { +export const IMPACT = i18n.translate('xpack.csp.findings.impactLabel', { + defaultMessage: 'Impact', +}); + +export const DEFAULT_VALUE = i18n.translate('xpack.csp.findings.defaultValueLabel', { + defaultMessage: 'Default Value', +}); + +export const RATIONALE = i18n.translate('xpack.csp.findings.rationaleLabel', { + defaultMessage: 'Rationale', +}); + +export const SEARCH_FAILED = i18n.translate('xpack.csp.findings.searchFailedLabel', { defaultMessage: 'Search failed', }); -export const TAGS = i18n.translate('xpack.csp.tags', { +export const TAGS = i18n.translate('xpack.csp.findings.tagsLabel', { defaultMessage: 'Tags', }); -export const RULE_NAME = i18n.translate('xpack.csp.rule_name', { +export const RULE_NAME = i18n.translate('xpack.csp.findings.ruleNameLabel', { defaultMessage: 'Rule Name', }); -export const OS = i18n.translate('xpack.csp.os', { +export const OS = i18n.translate('xpack.csp.findings.osLabel', { defaultMessage: 'OS', }); -export const FINDINGS = i18n.translate('xpack.csp.findings', { +export const FINDINGS = i18n.translate('xpack.csp.findings.findingsLabel', { defaultMessage: 'Findings', }); -export const RESOURCE = i18n.translate('xpack.csp.resource', { +export const RESOURCE = i18n.translate('xpack.csp.findings.resourceLabel', { defaultMessage: 'Resource', }); -export const FILENAME = i18n.translate('xpack.csp.filename', { +export const FILENAME = i18n.translate('xpack.csp.findings.filenameLabel', { defaultMessage: 'Filename', }); -export const MODE = i18n.translate('xpack.csp.mode', { +export const MODE = i18n.translate('xpack.csp.findings.modeLabel', { defaultMessage: 'Mode', }); -export const TYPE = i18n.translate('xpack.csp.type', { +export const TYPE = i18n.translate('xpack.csp.findings.typeLabel', { defaultMessage: 'Type', }); -export const PATH = i18n.translate('xpack.csp.path', { +export const PATH = i18n.translate('xpack.csp.findings.pathLabel', { defaultMessage: 'Path', }); -export const UID = i18n.translate('xpack.csp.uid', { +export const UID = i18n.translate('xpack.csp.findings.uidLabel', { defaultMessage: 'UID', }); -export const GID = i18n.translate('xpack.csp.gid', { +export const GID = i18n.translate('xpack.csp.findings.gidLabel', { defaultMessage: 'GID', }); -export const RULE = i18n.translate('xpack.csp.rule', { +export const RULE = i18n.translate('xpack.csp.findings.ruleLabel', { defaultMessage: 'Rule', }); -export const DESCRIPTION = i18n.translate('xpack.csp.description', { +export const DESCRIPTION = i18n.translate('xpack.csp.findings.descriptionLabel', { defaultMessage: 'Description', }); -export const REMEDIATION = i18n.translate('xpack.csp.remediation', { +export const REMEDIATION = i18n.translate('xpack.csp.findings.remediationLabel', { defaultMessage: 'Remediation', }); -export const BENCHMARK = i18n.translate('xpack.csp.benchmark', { +export const BENCHMARK = i18n.translate('xpack.csp.findings.benchmarkLabel', { defaultMessage: 'Benchmark', }); -export const RESULT = i18n.translate('xpack.csp.result', { - defaultMessage: 'Result', +export const SEVERITY = i18n.translate('xpack.csp.findings.severityLabel', { + defaultMessage: 'Severity', }); -export const EVALUATION = i18n.translate('xpack.csp.evaluation', { +export const INDEX = i18n.translate('xpack.csp.findings.indexLabel', { + defaultMessage: 'Index', +}); + +export const RULE_EVALUATED_AT = i18n.translate('xpack.csp.findings.ruleEvaluatedAt', { + defaultMessage: 'Rule evaluated at', +}); + +export const FRAMEWORK_SOURCES = i18n.translate('xpack.csp.findings.frameworkSourcesLabel', { + defaultMessage: 'Framework Sources', +}); + +export const SECTION = i18n.translate('xpack.csp.findings.sectionLabel', { + defaultMessage: 'Section', +}); + +export const AUDIT = i18n.translate('xpack.csp.findings.auditLabel', { + defaultMessage: 'Audit', +}); + +export const RESULT = i18n.translate('xpack.csp.findings.resultLabel', { + defaultMessage: 'Result Details', +}); + +export const PROFILE_APPLICABILITY = i18n.translate( + 'xpack.csp.findings.profileApplicabilityLabel', + { defaultMessage: 'Profile Applicability' } +); + +export const EVALUATION = i18n.translate('xpack.csp.findings.evaluationLabel', { defaultMessage: 'Evaluation', }); -export const EVIDENCE = i18n.translate('xpack.csp.evidence', { +export const EXPECTED = i18n.translate('xpack.csp.findings.expectedLabel', { + defaultMessage: 'Expected', +}); + +export const EVIDENCE = i18n.translate('xpack.csp.findings.evidenceLabel', { defaultMessage: 'Evidence', }); -export const TIMESTAMP = i18n.translate('xpack.csp.timestamp', { +export const TIMESTAMP = i18n.translate('xpack.csp.findings.timestampLabel', { defaultMessage: 'Timestamp', }); -export const AGENT = i18n.translate('xpack.csp.agent', { +export const AGENT = i18n.translate('xpack.csp.findings.agentLabel', { defaultMessage: 'Agent', }); -export const VERSION = i18n.translate('xpack.csp.version', { +export const VERSION = i18n.translate('xpack.csp.findings.versionLabel', { defaultMessage: 'Version', }); -export const ID = i18n.translate('xpack.csp.id', { +export const ID = i18n.translate('xpack.csp.findings.idLabel', { defaultMessage: 'ID', }); -export const HOST = i18n.translate('xpack.csp.host', { +export const HOST = i18n.translate('xpack.csp.findings.hostLabel', { defaultMessage: 'HOST', }); -export const ARCHITECTURE = i18n.translate('xpack.csp.architecture', { +export const ARCHITECTURE = i18n.translate('xpack.csp.findings.architectureLabel', { defaultMessage: 'Architecture', }); -export const CONTAINERIZED = i18n.translate('xpack.csp.containerized', { +export const CONTAINERIZED = i18n.translate('xpack.csp.findings.containerizedLabel', { defaultMessage: 'Containerized', }); -export const HOSTNAME = i18n.translate('xpack.csp.hostname', { +export const HOSTNAME = i18n.translate('xpack.csp.findings.hostnameLabel', { defaultMessage: 'Hostname', }); -export const MAC = i18n.translate('xpack.csp.mac', { +export const MAC = i18n.translate('xpack.csp.findings.macLabel', { defaultMessage: 'Mac', }); -export const IP = i18n.translate('xpack.csp.ip', { +export const IP = i18n.translate('xpack.csp.findings.ipLabel', { defaultMessage: 'IP', }); -export const CODENAME = i18n.translate('xpack.csp.codename', { +export const CODENAME = i18n.translate('xpack.csp.findings.codenameLabel', { defaultMessage: 'Codename', }); -export const FAMILY = i18n.translate('xpack.csp.family', { +export const FAMILY = i18n.translate('xpack.csp.findings.familyLabel', { defaultMessage: 'Family', }); -export const KERNEL = i18n.translate('xpack.csp.kernel', { +export const KERNEL = i18n.translate('xpack.csp.findings.kernelLabel', { defaultMessage: 'Kernel', }); -export const PLATFORM = i18n.translate('xpack.csp.platform', { +export const PLATFORM = i18n.translate('xpack.csp.findings.platformLabel', { defaultMessage: 'Platform', }); -export const NO_FINDINGS = i18n.translate('xpack.csp.thereAreNoFindings', { +export const NO_FINDINGS = i18n.translate('xpack.csp.findings.nonFindingsLabel', { defaultMessage: 'There are no Findings', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx index 0b511c9fb90314..bcfc06e8e16a5c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx @@ -4,28 +4,84 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; -import type { EuiPageHeaderProps } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { EuiTextColor, EuiEmptyPrompt } from '@elastic/eui'; +import * as t from 'io-ts'; import { CspPageTemplate } from '../../components/page_template'; -import { RulesContainer } from './rules_container'; +import { RulesContainer, type PageUrlParams } from './rules_container'; import { allNavigationItems } from '../../common/navigation/constants'; import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; +import type { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public'; +import { CspLoadingState } from '../../components/csp_loading_state'; +import { CspNavigationItem } from '../../common/navigation/types'; +import { extractErrorMessage } from '../../../common/utils/helpers'; +import { useCspIntegration } from './use_csp_integration'; -// TODO: -// - get selected integration - -const pageHeader: EuiPageHeaderProps = { - pageTitle: 'Rules', -}; +const getRulesBreadcrumbs = (name?: string): CspNavigationItem[] => + [allNavigationItems.benchmarks, { ...allNavigationItems.rules, name }].filter( + (breadcrumb): breadcrumb is CspNavigationItem => !!breadcrumb.name + ); -const breadcrumbs = [allNavigationItems.rules]; +export const Rules = ({ match: { params } }: RouteComponentProps) => { + const integrationInfo = useCspIntegration(params); + const breadcrumbs = useMemo( + // TODO: make benchmark breadcrumb navigable + () => getRulesBreadcrumbs(integrationInfo.data?.name), + [integrationInfo.data?.name] + ); -export const Rules = () => { useCspBreadcrumbs(breadcrumbs); + const pageProps: KibanaPageTemplateProps = useMemo( + () => ({ + template: integrationInfo.status !== 'success' ? 'centeredContent' : undefined, + pageHeader: { + bottomBorder: false, // TODO: border still shows. + pageTitle: 'Rules', + description: integrationInfo.data && integrationInfo.data.package && ( + + ), + }, + }), + [integrationInfo.data, integrationInfo.status] + ); + return ( - - + + {integrationInfo.status === 'success' && } + {integrationInfo.status === 'error' && ( + + )} + {integrationInfo.status === 'loading' && } ); }; + +// react-query puts the response data on the 'error' object +const bodyError = t.type({ + body: t.type({ + message: t.string, + }), +}); + +const extractErrorBodyMessage = (err: unknown) => { + if (bodyError.is(err)) return err.body.message; + return extractErrorMessage(err); +}; + +const PageDescription = ({ text }: { text: string }) => ( + {text} +); + +const RulesErrorPrompt = ({ error }: { error: string }) => ( + {error}, + }} + /> +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx new file mode 100644 index 00000000000000..aaf7bdc557e215 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Rules } from './index'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from 'react-query'; +import { TestProvider } from '../../test/test_provider'; +import { useCspIntegration } from './use_csp_integration'; +import { type RouteComponentProps } from 'react-router-dom'; +import { cspLoadingStateTestId } from '../../components/csp_loading_state'; +import type { PageUrlParams } from './rules_container'; +import * as TEST_SUBJECTS from './test_subjects'; + +jest.mock('./use_csp_integration', () => ({ + useCspIntegration: jest.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const getTestComponent = + (params: PageUrlParams): React.FC => + () => + ( + + )} + /> + + ); + +describe('', () => { + beforeEach(() => { + queryClient.clear(); + jest.clearAllMocks(); + }); + + it('calls API with URL params', async () => { + const params = { packagePolicyId: '1', policyId: '2' }; + const Component = getTestComponent(params); + const result = { + status: 'loading', + }; + + (useCspIntegration as jest.Mock).mockReturnValue(result); + + render(); + + expect(useCspIntegration).toHaveBeenCalledWith(params); + }); + + it('displays error state when request had an error', async () => { + const Component = getTestComponent({ packagePolicyId: '1', policyId: '2' }); + const request = { + status: 'error', + data: null, + error: new Error('some error message'), + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect(await screen.findByText(request.error.message)).toBeInTheDocument(); + }); + + it('displays loading state when request is pending', () => { + const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' }); + const request = { + status: 'loading', + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect(screen.getByTestId(cspLoadingStateTestId)).toBeInTheDocument(); + }); + + it('displays success state when result request is resolved', async () => { + const Component = getTestComponent({ packagePolicyId: '21', policyId: '22' }); + const request = { + status: 'success', + data: { + name: 'CIS Kubernetes Benchmark', + package: { + title: 'my package', + }, + }, + }; + + (useCspIntegration as jest.Mock).mockReturnValue(request); + + render(); + + expect( + await screen.findByText(`${request.data.package.title}, ${request.data.name}`) + ).toBeInTheDocument(); + expect(await screen.findByTestId(TEST_SUBJECTS.CSP_RULES_CONTAINER)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx index cce93556a83061..9780f9ecd3778a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx @@ -5,8 +5,17 @@ * 2.0. */ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { type EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { extractErrorMessage } from '../../../common/utils/helpers'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + type EuiBasicTable, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { extractErrorMessage, isNonNullable } from '../../../common/utils/helpers'; import { RulesTable } from './rules_table'; import { RulesBottomBar } from './rules_bottom_bar'; import { RulesTableHeader } from './rules_table_header'; @@ -19,6 +28,8 @@ import { } from './use_csp_rules'; import * as TEST_SUBJECTS from './test_subjects'; import { RuleFlyout } from './rules_flyout'; +import { pagePathGetters } from '../../../../fleet/public'; +import { useKibana } from '../../common/hooks/use_kibana'; interface RulesPageData { rules_page: RuleSavedObject[]; @@ -27,6 +38,7 @@ interface RulesPageData { total: number; error?: string; loading: boolean; + lastModified: string | null; } export type RulesState = RulesPageData & RulesQuery; @@ -71,15 +83,25 @@ const getRulesPageData = ( rules_map: new Map(rules.map((rule) => [rule.id, rule])), rules_page: page.map((rule) => changedRules.get(rule.attributes.id) || rule), total: data?.total || 0, + lastModified: getLastModified(rules) || null, }; }; +const getLastModified = (data: RuleSavedObject[]): string | undefined => + data + .map((v) => v.updatedAt) + .filter(isNonNullable) + .sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0]; + const getPage = (data: readonly RuleSavedObject[], { page, perPage }: RulesQuery) => data.slice(page * perPage, (page + 1) * perPage); const MAX_ITEMS_PER_PAGE = 10000; +export type PageUrlParams = Record<'policyId' | 'packagePolicyId', string>; + export const RulesContainer = () => { + const params = useParams(); const tableRef = useRef(null); const [changedRules, setChangedRules] = useState>(new Map()); const [selectedRuleId, setSelectedRuleId] = useState(null); @@ -145,6 +167,8 @@ export const RulesContainer = () => { return (
+ + setRulesQuery((currentQuery) => ({ ...currentQuery, search: value }))} @@ -162,6 +186,7 @@ export const RulesContainer = () => { searchValue={rulesQuery.search} totalRulesCount={rulesPageData.all_rules.length} isSearching={status === 'loading'} + lastModified={rulesPageData.lastModified} /> {
); }; + +const ManageIntegrationButton = ({ policyId, packagePolicyId }: PageUrlParams) => { + const { http } = useKibana().services; + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx index f9fbe1ab4ae8ee..e8fd704e124f43 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx @@ -134,7 +134,7 @@ const getColumns = ({ }, { field: 'updatedAt', - name: TEXT.UPDATED_AT, + name: TEXT.LAST_MODIFIED, width: '15%', render: (timestamp) => moment(timestamp).fromNow(), }, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx index 011cb5d8f7bc28..8f47a00ce5003a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -5,10 +5,18 @@ * 2.0. */ import React, { useState } from 'react'; -import { EuiFieldSearch, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiFieldSearch, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import useDebounce from 'react-use/lib/useDebounce'; +import moment from 'moment'; import * as TEST_SUBJECTS from './test_subjects'; import * as TEXT from './translations'; import { RulesBulkActionsMenu } from './rules_bulk_actions_menu'; @@ -24,6 +32,7 @@ interface RulesTableToolbarProps { selectedRulesCount: number; searchValue: string; isSearching: boolean; + lastModified: string | null; } interface CounterProps { @@ -34,6 +43,16 @@ interface ButtonProps { onClick(): void; } +const LastModificationLabel = ({ lastModified }: { lastModified: string }) => ( + + + +); + export const RulesTableHeader = ({ search, refresh, @@ -45,22 +64,27 @@ export const RulesTableHeader = ({ selectedRulesCount, searchValue, isSearching, + lastModified, }: RulesTableToolbarProps) => ( - - - - - - - +
+ {lastModified && } + + + + + + + + +
); const Counters = ({ total, selected }: { total: number; selected: number }) => ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts index 0f9c279ae4ba56..8523e0afc06c53 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts @@ -31,19 +31,20 @@ export const BULK_ACTIONS = i18n.translate('xpack.csp.rules.bulkActionsButtonLab defaultMessage: 'Bulk Actions', }); -export const RULE_NAME = i18n.translate('xpack.csp.rules.ruleNameColumnHeaderLabel', { +export const RULE_NAME = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.nameLabel', { defaultMessage: 'Rule Name', }); -export const SECTION = i18n.translate('xpack.csp.rules.sectionColumnHeaderLabel', { +export const SECTION = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.sectionLabel', { defaultMessage: 'Section', }); -export const UPDATED_AT = i18n.translate('xpack.csp.rules.updatedAtColumnHeaderLabel', { - defaultMessage: 'Updated at', -}); +export const LAST_MODIFIED = i18n.translate( + 'xpack.csp.rules.rulesTable.rulesTableColumn.lastModifiedLabel', + { defaultMessage: 'Last modified' } +); -export const ENABLED = i18n.translate('xpack.csp.rules.enabledColumnHeaderLabel', { +export const ENABLED = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.enabledLabel', { defaultMessage: 'Enabled', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx new file mode 100644 index 00000000000000..52e07ae9e016c6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_integration.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery } from 'react-query'; +import { type PageUrlParams } from './rules_container'; +import { useKibana } from '../../common/hooks/use_kibana'; +import { type PackagePolicy, packagePolicyRouteService } from '../../../../../plugins/fleet/common'; + +export const useCspIntegration = ({ packagePolicyId }: PageUrlParams) => { + const { http } = useKibana().services; + return useQuery( + ['packagePolicy', { packagePolicyId }], + () => http.get<{ item: PackagePolicy }>(packagePolicyRouteService.getInfoPath(packagePolicyId)), + { select: (response) => response.item, enabled: !!packagePolicyId } + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx index 9aa0286b2bef0c..671d5efd0641d5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx @@ -12,7 +12,7 @@ import { shallow } from 'enzyme'; import { ApiKey } from '../api_key'; import { CredentialItem } from '../credential_item'; -import { SourceConfigFields } from './'; +import { SourceConfigFields } from './source_config_fields'; describe('SourceConfigFields', () => { it('renders empty with no items', () => { @@ -31,11 +31,14 @@ describe('SourceConfigFields', () => { publicKey="abc" consumerKey="def" baseUrl="ghi" + externalConnectorUrl="https://url.com" + externalConnectorApiKey="apiKey" /> ); expect(wrapper.find(ApiKey)).toHaveLength(0); - expect(wrapper.find(CredentialItem)).toHaveLength(3); + expect(wrapper.find(CredentialItem)).toHaveLength(4); + expect(wrapper.find('[data-test-subj="external-connector-url-input"]')).toHaveLength(1); }); it('shows API keys', () => { @@ -51,4 +54,22 @@ describe('SourceConfigFields', () => { expect(wrapper.find(ApiKey)).toHaveLength(2); }); + + it('handles select all button click', () => { + const wrapper = shallow(); + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + currentTarget: { select: jest.fn() }, + preventDefault: jest.fn(), + }; + + const input = wrapper + .find('[data-test-subj="external-connector-url-input"]') + .dive() + .find('input'); + input.simulate('click', simulatedEvent); + + expect(simulatedEvent.currentTarget.select).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx index e33e7817b5209f..6f2975a8ab7a6c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx @@ -7,7 +7,15 @@ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiCopy, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import { PUBLIC_KEY_LABEL, @@ -15,6 +23,10 @@ import { BASE_URL_LABEL, CLIENT_ID_LABEL, CLIENT_SECRET_LABEL, + EXTERNAL_CONNECTOR_API_KEY_LABEL, + EXTERNAL_CONNECTOR_URL_LABEL, + COPIED_TOOLTIP, + COPY_TOOLTIP, } from '../../../constants'; import { ApiKey } from '../api_key'; import { CredentialItem } from '../credential_item'; @@ -26,6 +38,8 @@ interface SourceConfigFieldsProps { publicKey?: string; consumerKey?: string; baseUrl?: string; + externalConnectorUrl?: string; + externalConnectorApiKey?: string; } export const SourceConfigFields: React.FC = ({ @@ -35,9 +49,16 @@ export const SourceConfigFields: React.FC = ({ publicKey, consumerKey, baseUrl, + externalConnectorApiKey, + externalConnectorUrl, }) => { const credentialItem = (label: string, item?: string) => - item && ; + item && ( + <> + + + + ); const keyElement = ( <> @@ -60,10 +81,51 @@ export const SourceConfigFields: React.FC = ({ <> {isOauth1 && keyElement} {!isOauth1 && credentialItem(CLIENT_ID_LABEL, clientId)} - {!isOauth1 && credentialItem(CLIENT_SECRET_LABEL, clientSecret)} - {credentialItem(BASE_URL_LABEL, baseUrl)} + {credentialItem(EXTERNAL_CONNECTOR_API_KEY_LABEL, externalConnectorApiKey)} + {externalConnectorUrl && ( + <> + + + + + {EXTERNAL_CONNECTOR_URL_LABEL} + + + + + + + {(copy) => ( + + )} + + + + ) => e.currentTarget.select()} + /> + + + + + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index e83430504b3897..5a06cd09071873 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -363,8 +363,6 @@ export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_ export const CUSTOM_SERVICE_TYPE = 'custom'; export const EXTERNAL_SERVICE_TYPE = 'external'; -export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search'; - export const DOCUMENTATION_LINK_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.documentation', { @@ -481,6 +479,20 @@ export const BASE_URL_LABEL = i18n.translate( } ); +export const EXTERNAL_CONNECTOR_URL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.externalConnectorUrl.label', + { + defaultMessage: 'Connector URL', + } +); + +export const EXTERNAL_CONNECTOR_API_KEY_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.externalConnectorApiKey.label', + { + defaultMessage: 'Connector API key', + } +); + export const CLIENT_ID_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.clientId.label', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index ca87459e1e6fb1..21246defbb8630 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -362,7 +362,6 @@ describe('AddSourceLogic', () => { expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/sources/create', { query: { ...params, - kibana_host: '', }, }); @@ -401,7 +400,6 @@ describe('AddSourceLogic', () => { expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/sources/create', { query: { ...params, - kibana_host: '', }, }); @@ -484,7 +482,6 @@ describe('AddSourceLogic', () => { const query = { index_permissions: false, - kibana_host: '', }; expect(clearFlashMessages).toHaveBeenCalled(); @@ -508,7 +505,6 @@ describe('AddSourceLogic', () => { const query = { index_permissions: true, - kibana_host: '', subdomain: 'subdomain', }; @@ -536,12 +532,7 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions.getSourceReConnectData('github'); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/reauth_prepare', - { - query: { - kibana_host: '', - }, - } + '/internal/workplace_search/org/sources/github/reauth_prepare' ); await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); @@ -725,17 +716,11 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - const query = { - kibana_host: '', - }; - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); expect(http.get).toHaveBeenCalledWith( '/internal/workplace_search/account/sources/github/prepare', - { - query, - } + { query: { index_permissions: false } } ); }); @@ -743,12 +728,7 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions.getSourceReConnectData('123'); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/123/reauth_prepare', - { - query: { - kibana_host: '', - }, - } + '/internal/workplace_search/account/sources/123/reauth_prepare' ); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 4c4c47b5a48c7c..8693cffc17e21a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -10,7 +10,6 @@ import { kea, MakeLogicType } from 'kea'; import { keys, pickBy } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { HttpFetchQuery } from 'src/core/public'; import { flashAPIErrors, @@ -21,7 +20,6 @@ import { import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; -import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; @@ -156,15 +154,6 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -/** - * Workplace Search needs to know the host for the redirect. As of yet, we do not - * have access to this in Kibana. We parse it from the browser and pass it as a param. - */ -const { - location: { href }, -} = window; -const kibanaHost = href.substr(0, href.indexOf(WORKPLACE_SEARCH_URL_PREFIX)); - export const AddSourceLogic = kea>({ path: ['enterprise_search', 'workplace_search', 'add_source_logic'], actions: { @@ -383,16 +372,17 @@ export const AddSourceLogic = kea(route, { query }); + const response = await HttpLogic.values.http.get(route, { + query, + }); actions.setSourceConnectData(response); successCallback(response.oauthUrl); } catch (e) { @@ -407,12 +397,8 @@ export const AddSourceLogic = kea(route, { query }); + const response = await HttpLogic.values.http.get(route); actions.setSourceConnectData(response); } catch (e) { flashAPIErrors(e); @@ -497,7 +483,7 @@ export const AddSourceLogic = kea { label={i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.urlLabel', { - defaultMessage: 'URL', + defaultMessage: 'Connector URL', } )} isInvalid={!urlValid} @@ -92,7 +92,7 @@ export const ExternalConnectorFormFields: React.FC = () => { label={i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.apiKeyLabel', { - defaultMessage: 'API key', + defaultMessage: 'Connector API key', } )} > diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d57dc496832751..c2c53bc33a64a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -105,7 +105,15 @@ export const SourceSettings: React.FC = () => { const showOauthConfig = !isGithubApp && isOrganization && !isEmpty(configuredFields); const showGithubAppConfig = isGithubApp; - const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; + const { + clientId, + clientSecret, + publicKey, + consumerKey, + baseUrl, + externalConnectorUrl, + externalConnectorApiKey, + } = configuredFields || {}; const handleNameChange = (e: ChangeEvent) => setValue(e.target.value); @@ -190,6 +198,8 @@ export const SourceSettings: React.FC = () => { publicKey={publicKey} consumerKey={consumerKey} baseUrl={baseUrl} + externalConnectorUrl={externalConnectorUrl} + externalConnectorApiKey={externalConnectorApiKey} /> diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 68b16bc83b7350..ba1bd8119a3e52 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -273,9 +273,6 @@ export function registerAccountSourceReauthPrepareRoute({ params: schema.object({ id: schema.string(), }), - query: schema.object({ - kibana_host: schema.string(), - }), }, }, enterpriseSearchRequestHandler.createRequest({ @@ -355,7 +352,6 @@ export function registerAccountPrepareSourcesRoute({ serviceType: schema.string(), }), query: schema.object({ - kibana_host: schema.string(), subdomain: schema.maybe(schema.string()), }), }, @@ -640,9 +636,6 @@ export function registerOrgSourceReauthPrepareRoute({ params: schema.object({ id: schema.string(), }), - query: schema.object({ - kibana_host: schema.string(), - }), }, }, enterpriseSearchRequestHandler.createRequest({ @@ -722,7 +715,6 @@ export function registerOrgPrepareSourcesRoute({ serviceType: schema.string(), }), query: schema.object({ - kibana_host: schema.string(), index_permissions: schema.boolean(), subdomain: schema.maybe(schema.string()), }), @@ -995,7 +987,6 @@ export function registerOauthConnectorParamsRoute({ path: '/internal/workplace_search/sources/create', validate: { query: schema.object({ - kibana_host: schema.string(), code: schema.maybe(schema.string()), session_state: schema.maybe(schema.string()), authuser: schema.maybe(schema.string()), diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx index 88072b327d9f29..874f60a604bfe2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx @@ -9,6 +9,7 @@ import { createFleetTestRendererMock } from '../../../../../../mock'; import type { MockedFleetStartServices } from '../../../../../../mock'; import { useLicense } from '../../../../../../hooks/use_license'; import type { LicenseService } from '../../../../services'; +import type { AgentPolicy } from '../../../../types'; import { useOutputOptions } from './hooks'; @@ -44,12 +45,44 @@ const mockApiCallsWithOutputs = (http: MockedFleetStartServices['http']) => { { id: 'output2', name: 'Output 2', - is_default: true, - is_default_monitoring: true, + is_default: false, + is_default_monitoring: false, }, { id: 'output3', name: 'Output 3', + is_default: false, + is_default_monitoring: false, + }, + ], + }, + }; + } + + return defaultHttpClientGetImplementation(path); + }); +}; + +const mockApiCallsWithLogstashOutputs = (http: MockedFleetStartServices['http']) => { + http.get.mockImplementation(async (path) => { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + if (path === '/api/fleet/outputs') { + return { + data: { + items: [ + { + id: 'elasticsearch1', + name: 'Elasticsearch1', + is_default: false, + type: 'elasticsearch', + is_default_monitoring: false, + }, + { + id: 'logstash1', + name: 'Logstash 1', + type: 'logstash', is_default: true, is_default_monitoring: true, }, @@ -69,13 +102,16 @@ describe('useOutputOptions', () => { hasAtLeast: () => true, } as unknown as LicenseService); mockApiCallsWithOutputs(testRenderer.startServices.http); - const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); expect(result.current.isLoading).toBeTruthy(); await waitForNextUpdate(); expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": false, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -99,6 +135,7 @@ describe('useOutputOptions', () => { expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": undefined, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -127,13 +164,16 @@ describe('useOutputOptions', () => { hasAtLeast: () => false, } as unknown as LicenseService); mockApiCallsWithOutputs(testRenderer.startServices.http); - const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); expect(result.current.isLoading).toBeTruthy(); await waitForNextUpdate(); expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": false, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -157,6 +197,7 @@ describe('useOutputOptions', () => { expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` Array [ Object { + "disabled": undefined, "inputDisplay": "Default (currently Output 1)", "value": "@@##DEFAULT_OUTPUT_VALUE##@@", }, @@ -178,4 +219,152 @@ describe('useOutputOptions', () => { ] `); }); + + it('should enable logstash output if there is no APM integration in the policy', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithLogstashOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({} as AgentPolicy) + ); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": false, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": undefined, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + }); + + it('should not enable logstash output if there is an APM integration in the policy', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithLogstashOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => + useOutputOptions({ + package_policies: [ + { + package: { + name: 'apm', + }, + }, + ], + } as AgentPolicy) + ); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": true, + "inputDisplay": + + Default (currently Logstash 1) + + + + + + , + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": true, + "inputDisplay": + + Logstash 1 + + + + + + , + "value": "logstash1", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "disabled": undefined, + "inputDisplay": "Default (currently Logstash 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Elasticsearch1", + "value": "elasticsearch1", + }, + Object { + "disabled": false, + "inputDisplay": "Logstash 1", + "value": "logstash1", + }, + ] + `); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx index b0922238799940..47c2db3db05a9a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx @@ -5,52 +5,107 @@ * 2.0. */ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import type { EuiSuperSelectOption } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; import { useGetOutputs, useLicense } from '../../../../hooks'; -import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../../../../../common'; +import { + LICENCE_FOR_PER_POLICY_OUTPUT, + FLEET_APM_PACKAGE, + outputType, +} from '../../../../../../../common'; +import type { NewAgentPolicy, AgentPolicy } from '../../../../types'; // The super select component do not support null or '' as a value export const DEFAULT_OUTPUT_VALUE = '@@##DEFAULT_OUTPUT_VALUE##@@'; -function getDefaultOutput(defaultOutputName?: string) { +function getOutputLabel(name: string, disabledMessage?: React.ReactNode) { + if (!disabledMessage) { + return name; + } + + return ( + <> + {name} + + {disabledMessage} + + ); +} + +function getDefaultOutput( + defaultOutputName?: string, + defaultOutputDisabled?: boolean, + defaultOutputDisabledMessage?: React.ReactNode +) { return { - inputDisplay: i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { - defaultMessage: 'Default (currently {defaultOutputName})', - values: { defaultOutputName }, - }), + inputDisplay: getOutputLabel( + i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { + defaultMessage: 'Default (currently {defaultOutputName})', + values: { defaultOutputName }, + }), + defaultOutputDisabledMessage + ), value: DEFAULT_OUTPUT_VALUE, + disabled: defaultOutputDisabled, }; } -export function useOutputOptions() { +export function useOutputOptions(agentPolicy: Partial) { const outputsRequest = useGetOutputs(); const licenseService = useLicense(); const isLicenceAllowingPolicyPerOutput = licenseService.hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + const isAgentPolicyUsingAPM = + 'package_policies' in agentPolicy && + agentPolicy.package_policies?.some((packagePolicy) => { + return typeof packagePolicy !== 'string' && packagePolicy.package?.name === FLEET_APM_PACKAGE; + }); - const outputOptions: Array> = useMemo(() => { + const dataOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { return []; } - return outputsRequest.data.items.map((item) => ({ - value: item.id, - inputDisplay: item.name, - disabled: !isLicenceAllowingPolicyPerOutput, - })); - }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); - - const dataOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { return []; } - const defaultOutputName = outputsRequest.data.items.find((item) => item.is_default)?.name; - return [getDefaultOutput(defaultOutputName), ...outputOptions]; - }, [outputsRequest, outputOptions]); + const defaultOutput = outputsRequest.data.items.find((item) => item.is_default); + const defaultOutputName = defaultOutput?.name; + const defaultOutputDisabled = + isAgentPolicyUsingAPM && defaultOutput?.type === outputType.Logstash; + + const defaultOutputDisabledMessage = defaultOutputDisabled ? ( + + ) : undefined; + + return [ + getDefaultOutput(defaultOutputName, defaultOutputDisabled, defaultOutputDisabledMessage), + ...outputsRequest.data.items.map((item) => { + const isLogstashOutputWithAPM = isAgentPolicyUsingAPM && item.type === outputType.Logstash; + + return { + value: item.id, + inputDisplay: getOutputLabel( + item.name, + isLogstashOutputWithAPM ? ( + + ) : undefined + ), + disabled: !isLicenceAllowingPolicyPerOutput || isLogstashOutputWithAPM, + }; + }), + ]; + }, [outputsRequest, isLicenceAllowingPolicyPerOutput, isAgentPolicyUsingAPM]); const monitoringOutputOptions = useMemo(() => { if (outputsRequest.isLoading || !outputsRequest.data) { @@ -60,8 +115,17 @@ export function useOutputOptions() { const defaultOutputName = outputsRequest.data.items.find( (item) => item.is_default_monitoring )?.name; - return [getDefaultOutput(defaultOutputName), ...outputOptions]; - }, [outputsRequest, outputOptions]); + return [ + getDefaultOutput(defaultOutputName), + ...outputsRequest.data.items.map((item) => { + return { + value: item.id, + inputDisplay: item.name, + disabled: !isLicenceAllowingPolicyPerOutput, + }; + }), + ]; + }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); return useMemo( () => ({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 305008513d0199..1ba7f09d0333de 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -56,7 +56,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = dataOutputOptions, monitoringOutputOptions, isLoading: isLoadingOptions, - } = useOutputOptions(); + } = useOutputOptions(agentPolicy); // agent monitoring checkbox group can appear multiple times in the DOM, ids have to be unique to work correctly const monitoringCheckboxIdSuffix = Date.now(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx index 9ef31b596e6524..a4c66802e20cb0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.test.tsx @@ -19,6 +19,13 @@ jest.mock('../../../hooks', () => { return { ...jest.requireActual('../../../hooks'), useGetAgentPolicies: jest.fn(), + useGetOutputs: jest.fn().mockResolvedValue({ + data: [], + isLoading: false, + }), + sendGetOneAgentPolicy: jest.fn().mockResolvedValue({ + data: { item: { id: 'policy-1' } }, + }), useFleetStatus: jest.fn().mockReturnValue({ isReady: true } as any), sendGetFleetStatus: jest .fn() @@ -34,12 +41,13 @@ describe('step select agent policy', () => { let testRenderer: TestRenderer; let renderResult: ReturnType; const mockSetHasAgentPolicyError = jest.fn(); + const updateAgentPolicyMock = jest.fn(); const render = () => (renderResult = testRenderer.render( @@ -47,6 +55,7 @@ describe('step select agent policy', () => { beforeEach(() => { testRenderer = createFleetTestRendererMock(); + updateAgentPolicyMock.mockReset(); }); test('should not select agent policy by default if multiple exists', async () => { @@ -68,7 +77,6 @@ describe('step select agent policy', () => { const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); expect((select as any)?.value).toEqual(''); - expect(renderResult.getAllByRole('option').length).toBe(2); expect(renderResult.getByText('An agent policy is required.')).toBeVisible(); }); }); @@ -82,10 +90,10 @@ describe('step select agent policy', () => { } as any); render(); - + await act(async () => {}); // Needed as updateAgentPolicy is called after multiple useEffect await act(async () => { - const select = renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]'); - expect((select as any)?.value).toEqual('policy-1'); + expect(updateAgentPolicyMock).toBeCalled(); + expect(updateAgentPolicyMock).toBeCalledWith({ id: 'policy-1' }); }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx index 8558fbecbde0f3..317c327fa675e4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx @@ -9,8 +9,8 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { EuiSelectOption } from '@elastic/eui'; -import { EuiSelect } from '@elastic/eui'; +import type { EuiSuperSelectOption } from '@elastic/eui'; +import { EuiSuperSelect } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, @@ -18,12 +18,24 @@ import { EuiDescribedFormGroup, EuiTitle, EuiText, + EuiSpacer, } from '@elastic/eui'; import { Error } from '../../../components'; -import type { AgentPolicy, PackageInfo, GetAgentPoliciesResponseItem } from '../../../types'; +import type { + AgentPolicy, + Output, + PackageInfo, + GetAgentPoliciesResponseItem, +} from '../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../services'; -import { useGetAgentPolicies, sendGetOneAgentPolicy, useFleetStatus } from '../../../hooks'; +import { + useGetAgentPolicies, + useGetOutputs, + sendGetOneAgentPolicy, + useFleetStatus, +} from '../../../hooks'; +import { FLEET_APM_PACKAGE, outputType } from '../../../../../../common'; const AgentPolicyFormRow = styled(EuiFormRow)` .euiFormRow__label { @@ -31,23 +43,7 @@ const AgentPolicyFormRow = styled(EuiFormRow)` } `; -export const StepSelectAgentPolicy: React.FunctionComponent<{ - packageInfo?: PackageInfo; - agentPolicy: AgentPolicy | undefined; - updateAgentPolicy: (agentPolicy: AgentPolicy | undefined) => void; - setHasAgentPolicyError: (hasError: boolean) => void; - selectedAgentPolicyId?: string; -}> = ({ - packageInfo, - agentPolicy, - updateAgentPolicy, - setHasAgentPolicyError, - selectedAgentPolicyId, -}) => { - const { isReady: isFleetReady } = useFleetStatus(); - - const [selectedAgentPolicyError, setSelectedAgentPolicyError] = useState(); - +function useAgentPoliciesOptions(packageInfo?: PackageInfo) { // Fetch agent policies info const { data: agentPoliciesData, @@ -72,21 +68,104 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ }, {}); }, [agentPolicies]); + const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); + + const { getDataOutputForPolicy } = useMemo(() => { + const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); + const outputsById = (outputsData?.items ?? []).reduce( + (acc: { [key: string]: Output }, output) => { + acc[output.id] = output; + return acc; + }, + {} + ); + + return { + getDataOutputForPolicy: (policy: AgentPolicy) => { + return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; + }, + }; + }, [outputsData]); + + const agentPolicyOptions: Array> = useMemo( + () => + packageInfo + ? agentPolicies.map((agentConf) => { + const isLimitedPackageAlreadyInPolicy = doesAgentPolicyHaveLimitedPackage( + agentConf, + packageInfo + ); + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(agentConf)?.type === outputType.Logstash; + + return { + inputDisplay: ( + <> + {agentConf.name} + {isAPMPackageAndDataOutputIsLogstash && ( + <> + + + + + + )} + + ), + value: agentConf.id, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyItem', + }; + }) + : [], + [agentPolicies, packageInfo, getDataOutputForPolicy] + ); + + return { + agentPoliciesError, + isLoading: isOutputLoading || isAgentPoliciesLoading, + agentPolicies, + agentPoliciesById, + agentPolicyOptions, + }; +} + +function doesAgentPolicyHaveLimitedPackage(policy: AgentPolicy, pkgInfo: PackageInfo) { + return policy + ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) + : false; +} + +export const StepSelectAgentPolicy: React.FunctionComponent<{ + packageInfo?: PackageInfo; + agentPolicy: AgentPolicy | undefined; + updateAgentPolicy: (agentPolicy: AgentPolicy | undefined) => void; + setHasAgentPolicyError: (hasError: boolean) => void; + selectedAgentPolicyId?: string; +}> = ({ + packageInfo, + agentPolicy, + updateAgentPolicy, + setHasAgentPolicyError, + selectedAgentPolicyId, +}) => { + const { isReady: isFleetReady } = useFleetStatus(); + + const [selectedAgentPolicyError, setSelectedAgentPolicyError] = useState(); + + const { agentPolicies, agentPoliciesById, isLoading, agentPoliciesError, agentPolicyOptions } = + useAgentPoliciesOptions(packageInfo); // Selected agent policy state const [selectedPolicyId, setSelectedPolicyId] = useState( agentPolicy?.id ?? (selectedAgentPolicyId || (agentPolicies.length === 1 ? agentPolicies[0].id : undefined)) ); - const doesAgentPolicyHaveLimitedPackage = useCallback( - (policy: AgentPolicy, pkgInfo: PackageInfo) => { - return policy - ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) - : false; - }, - [] - ); - // Update parent selected agent policy state useEffect(() => { const fetchAgentPolicyInfo = async () => { @@ -109,21 +188,6 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ } }, [selectedPolicyId, agentPolicy, updateAgentPolicy]); - const agentPolicyOptions: EuiSelectOption[] = useMemo( - () => - packageInfo - ? agentPolicies.map((agentConf) => { - return { - text: agentConf.name, - value: agentConf.id, - disabled: doesAgentPolicyHaveLimitedPackage(agentConf, packageInfo), - 'data-test-subj': 'agentPolicyItem', - }; - }) - : [], - [agentPolicies, doesAgentPolicyHaveLimitedPackage, packageInfo] - ); - // Try to select default agent policy useEffect(() => { if (!selectedPolicyId && agentPolicies.length && agentPolicyOptions.length) { @@ -141,6 +205,11 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ } else setHasAgentPolicyError(true); }, [selectedAgentPolicyError, selectedPolicyId, setHasAgentPolicyError]); + const onChange = useCallback( + (newValue: string) => setSelectedPolicyId(newValue === '' ? undefined : newValue), + [] + ); + // Display agent policies list error if there is one if (agentPoliciesError) { return ( @@ -227,19 +296,18 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ ) } > - 1} fullWidth - isLoading={isAgentPoliciesLoading || !packageInfo} + isLoading={isLoading || !packageInfo} options={agentPolicyOptions} - value={selectedPolicyId || undefined} - onChange={(e) => setSelectedPolicyId(e.target.value)} + valueOfSelected={selectedPolicyId} + onChange={onChange} data-test-subj="agentPolicySelect" aria-label="Select Agent Policy" /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx index 0c9f450e83daec..5b127c4e839713 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_hosts.test.tsx @@ -20,6 +20,13 @@ jest.mock('../../../hooks', () => { return { ...jest.requireActual('../../../hooks'), useGetAgentPolicies: jest.fn(), + useGetOutputs: jest.fn().mockResolvedValue({ + data: [], + isLoading: false, + }), + sendGetOneAgentPolicy: jest.fn().mockResolvedValue({ + data: { item: { id: 'policy-1', name: 'Agent policy 1' } }, + }), }; }); @@ -110,7 +117,7 @@ describe('StepSelectHosts', () => { ); }); - it('should display dropdown with agent policy selected when Existing hosts selected', () => { + it('should display dropdown with agent policy selected when Existing hosts selected', async () => { (useGetAgentPolicies as jest.MockedFunction).mockReturnValue({ data: { items: [{ id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' }], @@ -126,8 +133,9 @@ describe('StepSelectHosts', () => { fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); }); - expect(renderResult.getAllByRole('option').length).toEqual(1); - expect(renderResult.getByText('Agent policy 1').closest('select')).toBeInTheDocument(); + expect( + renderResult.container.querySelector('[data-test-subj="agentPolicySelect"]')?.textContent + ).toEqual('Agent policy 1'); }); it('should display dropdown without preselected value when Existing hosts selected with mulitple agent policies', () => { @@ -149,7 +157,6 @@ describe('StepSelectHosts', () => { fireEvent.click(renderResult.getByText('Existing hosts').closest('button')!); }); - expect(renderResult.getAllByRole('option').length).toEqual(2); waitFor(() => { expect(renderResult.getByText('An agent policy is required.')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 7f414a8f12deb0..57b8681e834bbb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -14,10 +14,10 @@ import { describe('Output form validation', () => { describe('validateESHosts', () => { - it('should work without any urls', () => { + it('should not work without any urls', () => { const res = validateESHosts([]); - expect(res).toBeUndefined(); + expect(res).toEqual([{ message: 'URL is required' }]); }); it('should work with valid url', () => { @@ -57,6 +57,12 @@ describe('Output form validation', () => { }); describe('validateLogstashHosts', () => { + it('should not work without any urls', () => { + const res = validateLogstashHosts([]); + + expect(res).toEqual([{ message: 'Host is required' }]); + }); + it('should work for valid hosts', () => { const res = validateLogstashHosts(['test.fr:5044']); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index 33ee3f9678cc89..13b90fe661f613 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { safeLoad } from 'js-yaml'; export function validateESHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -46,13 +46,21 @@ export function validateESHosts(value: string[]) { ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.elasticUrlRequiredError', { + defaultMessage: 'URL is required', + }), + }); + } + if (res.length) { return res; } } export function validateLogstashHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -89,13 +97,20 @@ export function validateLogstashHosts(value: string[]) { .forEach((indexes) => { indexes.forEach((index) => res.push({ - message: i18n.translate('xpack.fleet.settings.outputForm.elasticHostDuplicateError', { - defaultMessage: 'Duplicate URL', + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostDuplicateError', { + defaultMessage: 'Duplicate Host', }), index, }) ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostRequiredError', { + defaultMessage: 'Host is required', + }), + }); + } if (res.length) { return res; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 0d8627c13b3dc3..41ebe9ef713f84 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -57,6 +57,8 @@ export class GenerateServiceTokenError extends IngestManagerError {} export class FleetUnauthorizedError extends IngestManagerError {} export class OutputUnauthorizedError extends IngestManagerError {} +export class OutputInvalidError extends IngestManagerError {} +export class OutputLicenceError extends IngestManagerError {} export class ArtifactsClientError extends IngestManagerError {} export class ArtifactsClientAccessDeniedError extends IngestManagerError { diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts index 92cf5b90ca3f60..43d2561001d9fd 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/index.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/index.ts @@ -10,4 +10,4 @@ export { storedPackagePolicyToAgentInputs, storedPackagePoliciesToAgentInputs, } from './package_policies_to_agent_inputs'; -export { validateOutputForPolicy } from './validate_outputs_for_policy'; +export { getDataOutputForAgentPolicy, validateOutputForPolicy } from './outputs_helpers'; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts similarity index 65% rename from x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts rename to x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts index ba5bc4a3aeeb2b..4bbcdaabe9b505 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/output_helpers.test.ts @@ -5,13 +5,18 @@ * 2.0. */ +import { savedObjectsClientMock } from 'src/core/server/mocks'; + import { appContextService } from '..'; +import { outputService } from '../output'; import { validateOutputForPolicy } from '.'; jest.mock('../app_context'); +jest.mock('../output'); const mockedAppContextService = appContextService as jest.Mocked; +const mockedOutputService = outputService as jest.Mocked; function mockHasLicence(res: boolean) { mockedAppContextService.getSecurityLicense.mockReturnValue({ @@ -23,7 +28,7 @@ describe('validateOutputForPolicy', () => { describe('Without oldData (create)', () => { it('should allow default outputs without platinum licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, }); @@ -31,7 +36,7 @@ describe('validateOutputForPolicy', () => { it('should allow default outputs with platinum licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, }); @@ -39,7 +44,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom data outputs without platinum licence', async () => { mockHasLicence(false); - const res = validateOutputForPolicy({ + const res = validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, }); @@ -50,7 +55,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom monitoring outputs without platinum licence', async () => { mockHasLicence(false); - const res = validateOutputForPolicy({ + const res = validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -61,7 +66,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom data output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, }); @@ -69,7 +74,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom monitoring output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -77,7 +82,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs for managed preconfigured policy without licence', async () => { mockHasLicence(false); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { is_managed: true, is_preconfigured: true, data_output_id: 'test1', @@ -90,6 +95,7 @@ describe('validateOutputForPolicy', () => { it('should allow default outputs without platinum licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: null, @@ -104,6 +110,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom data outputs without platinum licence', async () => { mockHasLicence(false); const res = validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, @@ -121,6 +128,7 @@ describe('validateOutputForPolicy', () => { it('should not allow custom monitoring outputs without platinum licence', async () => { mockHasLicence(false); const res = validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', @@ -138,6 +146,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom data output with platinum licence', async () => { mockHasLicence(true); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: null, @@ -151,7 +160,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom monitoring output with platinum licence', async () => { mockHasLicence(true); - await validateOutputForPolicy({ + await validateOutputForPolicy(savedObjectsClientMock.create(), { data_output_id: null, monitoring_output_id: 'test1', }); @@ -160,6 +169,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs for managed preconfigured policy without licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: 'test1', @@ -171,6 +181,7 @@ describe('validateOutputForPolicy', () => { it('should allow custom outputs if they did not change without licence', async () => { mockHasLicence(false); await validateOutputForPolicy( + savedObjectsClientMock.create(), { data_output_id: 'test1', monitoring_output_id: 'test1', @@ -178,5 +189,57 @@ describe('validateOutputForPolicy', () => { { data_output_id: 'test1', monitoring_output_id: 'test1' } ); }); + + it('should not allow APM for a logstash output', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'logstash', + } as any); + await expect( + validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + true // hasAPM + ) + ).rejects.toThrow(/Logstash output is not usable with policy using the APM integration./); + }); + + it('should allow APM for an elasticsearch output', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'elasticsearch', + } as any); + + await validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + true // hasAPM + ); + }); + + it('should allow logstash output for a policy not using APM', async () => { + mockHasLicence(true); + mockedOutputService.get.mockResolvedValue({ + type: 'logstash', + } as any); + + await validateOutputForPolicy( + savedObjectsClientMock.create(), + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'newdataoutput', monitoring_output_id: 'test1' }, + false // do not have APM + ); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts b/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts new file mode 100644 index 00000000000000..42a48f66de919b --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/outputs_helpers.ts @@ -0,0 +1,84 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; + +import type { AgentPolicySOAttributes } from '../../types'; +import { LICENCE_FOR_PER_POLICY_OUTPUT, outputType } from '../../../common'; +import { appContextService } from '..'; +import { outputService } from '../output'; +import { OutputInvalidError, OutputLicenceError } from '../../errors'; + +/** + * Get the data output for a given agent policy + * @param soClient + * @param agentPolicy + * @returns + */ +export async function getDataOutputForAgentPolicy( + soClient: SavedObjectsClientContract, + agentPolicy: Partial +) { + const dataOutputId = + agentPolicy.data_output_id || (await outputService.getDefaultDataOutputId(soClient)); + + if (!dataOutputId) { + throw new Error('No default data output found.'); + } + + return outputService.get(soClient, dataOutputId); +} + +/** + * Validate outputs are valid for a policy using the current kibana licence or throw. + * @param data + * @returns + */ +export async function validateOutputForPolicy( + soClient: SavedObjectsClientContract, + newData: Partial, + existingData: Partial = {}, + isPolicyUsingAPM = false +) { + if ( + newData.data_output_id === existingData.data_output_id && + newData.monitoring_output_id === existingData.monitoring_output_id + ) { + return; + } + + const data = { ...existingData, ...newData }; + + if (isPolicyUsingAPM) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, data); + + if (dataOutput.type === outputType.Logstash) { + throw new OutputInvalidError( + 'Logstash output is not usable with policy using the APM integration.' + ); + } + } + + if (!data.data_output_id && !data.monitoring_output_id) { + return; + } + + // Do not validate licence output for managed and preconfigured policy + if (data.is_managed && data.is_preconfigured) { + return; + } + + const hasLicence = appContextService + .getSecurityLicense() + .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + if (!hasLicence) { + throw new OutputLicenceError( + `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` + ); + } +} diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts deleted file mode 100644 index 272e1cd6c5b527..00000000000000 --- a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { AgentPolicySOAttributes } from '../../types'; -import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../common'; -import { appContextService } from '..'; - -/** - * Validate outputs are valid for a policy using the current kibana licence or throw. - * @param data - * @returns - */ -export async function validateOutputForPolicy( - newData: Partial, - oldData: Partial = {} -) { - if ( - newData.data_output_id === oldData.data_output_id && - newData.monitoring_output_id === oldData.monitoring_output_id - ) { - return; - } - - const data = { ...oldData, ...newData }; - - if (!data.data_output_id && !data.monitoring_output_id) { - return; - } - - // Do not validate licence output for managed and preconfigured policy - if (data.is_managed && data.is_preconfigured) { - return; - } - - const hasLicence = appContextService - .getSecurityLicense() - .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); - - if (!hasLicence) { - throw new Error( - `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` - ); - } -} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index dd944f366a326a..170942d59061f9 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -38,6 +38,7 @@ import { packageToPackagePolicy, AGENT_POLICY_INDEX, UUID_V5_NAMESPACE, + FLEET_APM_PACKAGE, } from '../../common'; import type { DeleteAgentPolicyResponse, @@ -85,26 +86,31 @@ class AgentPolicyService { user?: AuthenticatedUser, options: { bumpRevision: boolean } = { bumpRevision: true } ): Promise { - const oldAgentPolicy = await this.get(soClient, id, false); + const existingAgentPolicy = await this.get(soClient, id, true); - if (!oldAgentPolicy) { + if (!existingAgentPolicy) { throw new Error('Agent policy not found'); } if ( - oldAgentPolicy.status === agentPolicyStatuses.Inactive && + existingAgentPolicy.status === agentPolicyStatuses.Inactive && agentPolicy.status !== agentPolicyStatuses.Active ) { throw new Error( - `Agent policy ${id} cannot be updated because it is ${oldAgentPolicy.status}` + `Agent policy ${id} cannot be updated because it is ${existingAgentPolicy.status}` ); } - await validateOutputForPolicy(agentPolicy); + await validateOutputForPolicy( + soClient, + agentPolicy, + existingAgentPolicy, + this.hasAPMIntegration(existingAgentPolicy) + ); await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentPolicy, - ...(options.bumpRevision ? { revision: oldAgentPolicy.revision + 1 } : {}), + ...(options.bumpRevision ? { revision: existingAgentPolicy.revision + 1 } : {}), updated_at: new Date().toISOString(), updated_by: user ? user.username : 'system', }); @@ -164,6 +170,12 @@ class AgentPolicyService { }; } + public hasAPMIntegration(agentPolicy: AgentPolicy) { + return agentPolicy.package_policies.some( + (p) => typeof p !== 'string' && p.package?.name === FLEET_APM_PACKAGE + ); + } + public async create( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -172,7 +184,7 @@ class AgentPolicyService { ): Promise { await this.requireUniqueName(soClient, agentPolicy); - await validateOutputForPolicy(agentPolicy); + await validateOutputForPolicy(soClient, agentPolicy); const newSo = await soClient.create( SAVED_OBJECT_TYPE, @@ -297,8 +309,9 @@ class AgentPolicyService { } } - const agentPolicies = await Promise.all( - agentPoliciesSO.saved_objects.map(async (agentPolicySO) => { + const agentPolicies = await pMap( + agentPoliciesSO.saved_objects, + async (agentPolicySO) => { const agentPolicy = { id: agentPolicySO.id, ...agentPolicySO.attributes, @@ -314,7 +327,8 @@ class AgentPolicyService { } } return agentPolicy; - }) + }, + { concurrency: 50 } ); return { diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 589fb10f995881..a08fd7a81759d7 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -65,7 +65,10 @@ function getMockedSoClient( return mockOutputSO('existing-default-output'); } case outputIdToUuid('existing-default-monitoring-output'): { - return mockOutputSO('existing-default-monitoring-output', { is_default: true }); + return mockOutputSO('existing-default-monitoring-output', { + is_default: true, + type: 'elasticsearch', + }); } case outputIdToUuid('existing-preconfigured-default-output'): { return mockOutputSO('existing-preconfigured-default-output', { @@ -74,6 +77,13 @@ function getMockedSoClient( }); } + case outputIdToUuid('existing-logstash-output'): { + return mockOutputSO('existing-logstash-output', { + type: 'logstash', + is_default: false, + }); + } + default: throw new Error('not found: ' + id); } @@ -149,6 +159,8 @@ function getMockedSoClient( describe('Output Service', () => { beforeEach(() => { + mockedAgentPolicyService.list.mockClear(); + mockedAgentPolicyService.hasAPMIntegration.mockClear(); mockedAgentPolicyService.removeOutputFromAll.mockReset(); }); describe('create', () => { @@ -432,6 +444,34 @@ describe('Output Service', () => { { is_default: false } ); }); + + // With logstash output + it('Should work if you try to make that output the default output and no policies using default output has APM integration', async () => { + const soClient = getMockedSoClient({}); + mockedAgentPolicyService.list.mockResolvedValue({ + items: [{}], + } as unknown as ReturnType); + mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(false); + + await outputService.update(soClient, 'existing-logstash-output', { + is_default: true, + }); + + expect(soClient.update).toBeCalled(); + }); + it('Should throw if you try to make that output the default output and somne policies using default output has APM integration', async () => { + const soClient = getMockedSoClient({}); + mockedAgentPolicyService.list.mockResolvedValue({ + items: [{}], + } as unknown as ReturnType); + mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(true); + + await expect( + outputService.update(soClient, 'existing-logstash-output', { + is_default: true, + }) + ).rejects.toThrow(`Logstash output cannot be used with APM integration.`); + }); }); describe('delete', () => { diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 088220a1b47102..9302c87af85c1e 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -9,9 +9,14 @@ import type { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import uuid from 'uuid/v5'; import type { NewOutput, Output, OutputSOAttributes } from '../types'; -import { DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; +import { + DEFAULT_OUTPUT, + DEFAULT_OUTPUT_ID, + OUTPUT_SAVED_OBJECT_TYPE, + AGENT_POLICY_SAVED_OBJECT_TYPE, +} from '../constants'; import { decodeCloudId, normalizeHostsForAgents, SO_SEARCH_LIMIT, outputType } from '../../common'; -import { OutputUnauthorizedError } from '../errors'; +import { OutputUnauthorizedError, OutputInvalidError } from '../errors'; import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; @@ -45,6 +50,39 @@ function outputSavedObjectToOutput(so: SavedObject) { }; } +async function validateLogstashOutputNotUsedInAPMPolicy( + soClient: SavedObjectsClientContract, + outputId?: string, + isDefault?: boolean +) { + // Validate no policy with APM use that policy + let kuery: string; + if (outputId) { + if (isDefault) { + kuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:"${outputId}" or not ${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:*`; + } else { + kuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:"${outputId}"`; + } + } else { + if (isDefault) { + kuery = `not ${AGENT_POLICY_SAVED_OBJECT_TYPE}.data_output_id:*`; + } else { + return; + } + } + + const agentPolicySO = await agentPolicyService.list(soClient, { + kuery, + perPage: SO_SEARCH_LIMIT, + withPackagePolicies: true, + }); + for (const agentPolicy of agentPolicySO.items) { + if (agentPolicyService.hasAPMIntegration(agentPolicy)) { + throw new OutputInvalidError('Logstash output cannot be used with APM integration.'); + } + } +} + class OutputService { private async _getDefaultDataOutputsSO(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -126,6 +164,10 @@ class OutputService { ): Promise { const data: OutputSOAttributes = { ...output }; + if (output.type === outputType.Logstash) { + await validateLogstashOutputNotUsedInAPMPolicy(soClient, undefined, data.is_default); + } + // ensure only default output exists if (data.is_default) { const defaultDataOuputId = await this.getDefaultDataOutputId(soClient); @@ -267,7 +309,13 @@ class OutputService { ); } - const updateData = { type: originalOutput.type, ...data }; + const updateData = { ...data }; + const mergedType = data.type ?? originalOutput.type; + const mergedIsDefault = data.is_default ?? originalOutput.is_default; + + if (mergedType === outputType.Logstash) { + await validateLogstashOutputNotUsedInAPMPolicy(soClient, id, mergedIsDefault); + } // ensure only default output exists if (data.is_default) { @@ -294,7 +342,7 @@ class OutputService { } } - if (updateData.type === outputType.Elasticsearch && updateData.hosts) { + if (mergedType === outputType.Elasticsearch && updateData.hosts) { updateData.hosts = updateData.hosts.map(normalizeHostsForAgents); } const outputSO = await soClient.update( diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 81ecfda9425abc..a6c67ff529a859 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -29,6 +29,8 @@ import { validatePackagePolicy, validationHasErrors, SO_SEARCH_LIMIT, + FLEET_APM_PACKAGE, + outputType, } from '../../common'; import type { DeletePackagePoliciesResponse, @@ -63,6 +65,7 @@ import type { ExternalCallback } from '..'; import { storedPackagePolicyToAgentInputs } from './agent_policies'; import { agentPolicyService } from './agent_policy'; +import { getDataOutputForAgentPolicy } from './agent_policies'; import { outputService } from './output'; import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/packages'; import { getAssetsData } from './epm/packages/assets'; @@ -104,6 +107,15 @@ class PackagePolicyService implements PackagePolicyServiceInterface { overwrite?: boolean; } ): Promise { + const agentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id, true); + + if (agentPolicy && packagePolicy.package?.name === FLEET_APM_PACKAGE) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, agentPolicy); + if (dataOutput.type === outputType.Logstash) { + throw new IngestManagerError('You cannot add APM to a policy using a logstash output'); + } + } + // trailing whitespace causes issues creating API keys packagePolicy.name = packagePolicy.name.trim(); if (!options?.skipUniqueNameVerification) { @@ -155,7 +167,6 @@ class PackagePolicyService implements PackagePolicyServiceInterface { // Check if it is a limited package, and if so, check that the corresponding agent policy does not // already contain a package policy for this package if (isPackageLimited(pkgInfo)) { - const agentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id, true); if (agentPolicy && doesAgentPolicyAlreadyIncludePackage(agentPolicy, pkgInfo.name)) { throw new IngestManagerError( `Unable to create package policy. Package '${pkgInfo.name}' already exists on this agent policy.` diff --git a/x-pack/plugins/fleet/server/types/models/output.ts b/x-pack/plugins/fleet/server/types/models/output.ts index ee7854ade30a88..86b2a70a318fc9 100644 --- a/x-pack/plugins/fleet/server/types/models/output.ts +++ b/x-pack/plugins/fleet/server/types/models/output.ts @@ -35,8 +35,12 @@ const OutputBaseSchema = { hosts: schema.conditional( schema.siblingRef('type'), schema.literal(outputType.Elasticsearch), - schema.arrayOf(schema.uri({ scheme: ['http', 'https'] })), - schema.arrayOf(schema.string({ validate: validateLogstashHost })) + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + minSize: 1, + }), + schema.arrayOf(schema.string({ validate: validateLogstashHost }), { + minSize: 1, + }) ), is_default: schema.boolean({ defaultValue: false }), is_default_monitoring: schema.boolean({ defaultValue: false }), diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx index 81852659e6087b..cdfe338fa38b31 100644 --- a/x-pack/plugins/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -43,11 +43,18 @@ export const CommonInfraProviders: React.FC<{ ); }; -export const CoreProviders: React.FC<{ +export interface CoreProvidersProps { core: CoreStart; plugins: InfraClientStartDeps; theme$: AppMountParameters['theme$']; -}> = ({ children, core, plugins, theme$ }) => { +} + +export const CoreProviders: React.FC = ({ + children, + core, + plugins, + theme$, +}) => { const { Provider: KibanaContextProviderForPlugin } = useMemo( () => createKibanaContextForPlugin(core, plugins), [core, plugins] diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md new file mode 100644 index 00000000000000..6ab4c8d551e771 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/README.md @@ -0,0 +1,66 @@ +# Infrastructure Node Metrics Tables + +This folder contains components that are used to display metrics about infrastructure components. +Currently there are tables for containers, pods and hosts. + +For use within the Infra plugin, make use of the `useXMetricsTable` hooks and their matching +`XMetricsTable` components. + +For use outside of the Infra plugin, consume these components via the lazy components exposed on the +start contract of the plugin. + +## The shared data hook + +All tables get their data from the Metrics Explorer API by making use of the +`useInfrastructureNodeMetrics` hook. The key input to the hook is the `MetricsMap` which defines +which metrics should be requested (by field and type of aggregation). By passing a `MetricsMap` to +the helper `metricsToApiOptions` we get back the options we need to pass to +`useInfrastructureNodeMetrics`. `metricsToApiOptions` also returns a mapping object that is used to +translate the field name to the format the API returns (e.g. `metric_0`). +The `MetricsMap` type is mostly to ensure that the object key and the `field` value match to avoid +mistakes when re-ordering the metrics being used. + +`useInfrastructureNodeMetrics` also expects a timerange and a filter (in ES DSL) and a function +to transform the Metrics Explorer response to something more suitable for the table component to +work with. + +Internally, the hook manages loading the source, making the API request, sorting and paginating the +response. It also manages the loading state. + +Currently, it does a large request and then does sorting and pagination on the client. In the future +we should replace this with a terms aggregation in the API instead, to do more work in +Elasticsearch. + +## Hooks and tables per node type + +For each node type there is a stateless table and a hook to load the data in the right format for +the table. + +Within the hook file we find the `MetricsMap` definition for each node type and the transformation +function. The transformation function makes use of the `metricByField` to unpack the API response +in a type safe way. +The body of the hook sets up the page and sort state, then invokes the shared data hook. + +The table itself is a fairly simple component that uses EUI components to render a table with +pagination. It makes use of components found in the shared folder for the things that are common +across each node type such as the pagination and Node Details page link. + +When using the hook it is important to wrap the timerange and filter clause DSL parameters in +something like `useMemo` to avoid a re-rendering loop. + +## The embeddable component factories + +To make it as easy as possible to consume these tables we expose them fully integrated on our start +contract. The component that is exposed lazily loads our component, adds in all of our providers +and calls the node type specific hook and passes the result to the node type specific table +component. Integration should be as simple as dropping in the component in a React hierarchy. + +The `createLazyXMetricsTable` factory function accepts our Kibana dependencies and return a new +component that lazily renders our integrated component, capturing our dependencies in a closure +during plugin start. + +The integrated component passes these dependencies to the providers the table needs in context as +well as any props that were passed to the lazy component (such as the time range and filter). +If needed, the lazy component can also accept a property called `sourceId` to modify which Infra +source configuration is used, the default is `default`. +Finally the component calls the node specific hook and renders the node specific table. diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx new file mode 100644 index 00000000000000..44bfa1be3e3313 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { ContainerMetricsTable } from './container_metrics_table'; +import type { ContainerMetricsTableProps } from './container_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Container', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: ContainerMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + containers: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + uptime: 23000000, + averageCpuUsagePercent: 99, + averageMemoryUsageMegabytes: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + uptime: 43000000, + averageCpuUsagePercent: 72, + averageMemoryUsageMegabytes: 68, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + uptime: 53000000, + averageCpuUsagePercent: 54, + averageMemoryUsageMegabytes: 132, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + uptime: 63000000, + averageCpuUsagePercent: 34, + averageMemoryUsageMegabytes: 264, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + uptime: 83000000, + averageCpuUsagePercent: 13, + averageMemoryUsageMegabytes: 512, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: ContainerMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx new file mode 100644 index 00000000000000..09e38681062dcb --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyContainerMetricsTable } from './create_lazy_container_metrics_table'; +import IntegratedContainerMetricsTable from './integrated_container_metrics_table'; +import { metricByField } from './use_container_metrics_table'; + +describe('ContainerMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'host.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyContainerMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyContainerMetricsTable = createLazyContainerMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('containerMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('containerMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedContainerMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-container/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createContainer('some-container', 23000000, 76, 3671700000), + createContainer('some-other-container', 32000000, 67, 716300000), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createContainer( + name: string, + uptimeMs: number, + cpuUsagePct: number, + memoryUsageBytes: number +) { + return { + id: name, + rows: [ + { + [metricByField['kubernetes.container.start_time']]: uptimeMs, + [metricByField['kubernetes.container.cpu.usage.node.pct']]: cpuUsagePct, + [metricByField['kubernetes.container.memory.usage.bytes']]: memoryUsageBytes, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx new file mode 100644 index 00000000000000..02c7d0501cdeff --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.tsx @@ -0,0 +1,135 @@ +/* + * 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 { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared'; +import type { SortState } from '../shared'; +import type { ContainerNodeMetricsRow } from './use_container_metrics_table'; + +export interface ContainerMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + containers: ContainerNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => { + const { + timerange, + isLoading, + containers, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => containerNodeColumns(timerange), [timerange]); + + const sortSettings: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = useCallback( + ({ sort }: EuiCriteria) => { + if (!sort) { + return; + } + + setSortState(sort); + setCurrentPageIndex(0); + }, + [setSortState, setCurrentPageIndex] + ); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function containerNodeColumns( + timerange: ContainerMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => { + return ; + }, + }, + { + name: 'Uptime', + field: 'uptime', + align: 'right', + render: (uptime: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory usage(avg.)', + field: 'averageMemoryUsageMegabytes', + align: 'right', + render: (averageMemoryUsageMegabytes: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx new file mode 100644 index 00000000000000..1ca52d2906a118 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedContainerMetricsTable = lazy( + () => import('./integrated_container_metrics_table') +); + +export function createLazyContainerMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts new file mode 100644 index 00000000000000..2497c19bde1872 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ContainerMetricsTable } from './container_metrics_table'; +export { useContainerMetricsTable } from './use_container_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx new file mode 100644 index 00000000000000..4fb2101e8e22ed --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/integrated_container_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { ContainerMetricsTable } from './container_metrics_table'; +import { useContainerMetricsTable } from './use_container_metrics_table'; + +function HookedContainerMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const containerMetricsTableProps = useContainerMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function ContainerMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default ContainerMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts new file mode 100644 index 00000000000000..23c95c665aa91d --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/use_container_metrics_table.ts @@ -0,0 +1,126 @@ +/* + * 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 { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type ContainerMetricsField = + | 'kubernetes.container.start_time' + | 'kubernetes.container.cpu.usage.node.pct' + | 'kubernetes.container.memory.usage.bytes'; + +const containerMetricsMap: MetricsMap = { + 'kubernetes.container.start_time': { + aggregation: 'max', + field: 'kubernetes.container.start_time', + }, + 'kubernetes.container.cpu.usage.node.pct': { + aggregation: 'avg', + field: 'kubernetes.container.cpu.usage.node.pct', + }, + 'kubernetes.container.memory.usage.bytes': { + aggregation: 'avg', + field: 'kubernetes.container.memory.usage.bytes', + }, +}; + +const { options: containerMetricsOptions, metricByField } = metricsToApiOptions( + containerMetricsMap, + 'container.id' +); +export { metricByField }; + +export interface ContainerNodeMetricsRow { + name: string; + uptime: number | null; + averageCpuUsagePercent: number | null; + averageMemoryUsageMegabytes: number | null; +} + +export function useContainerMetricsTable({ + timerange, + filterClauseDsl, +}: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: containers, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: containerMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToContainerNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + containers, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToContainerNodeMetricsRow(series: MetricsExplorerSeries): ContainerNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; + } + + let uptime: number = 0; + let averageCpuUsagePercent: number = 0; + let averageMemoryUsageMegabytes: number = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + uptime += metricValues.uptime ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + averageMemoryUsageMegabytes += metricValues.averageMemoryUsageMegabytes ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + uptime: uptime / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + averageMemoryUsageMegabytes: Math.floor( + averageMemoryUsageMegabytes / bucketCount / bytesPerMegabyte + ), + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + uptime: row[metricByField['kubernetes.container.start_time']] as number | null, + averageCpuUsagePercent: row[metricByField['kubernetes.container.cpu.usage.node.pct']] as + | number + | null, + averageMemoryUsageMegabytes: row[metricByField['kubernetes.container.memory.usage.bytes']] as + | number + | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx new file mode 100644 index 00000000000000..39980ebf3604b4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedHostMetricsTable = lazy(() => import('./integrated_host_metrics_table')); + +export function createLazyHostMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx new file mode 100644 index 00000000000000..5c80223e31e1c0 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.stories.tsx @@ -0,0 +1,99 @@ +/* + * 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 { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { HostMetricsTable } from './host_metrics_table'; +import type { HostMetricsTableProps } from './host_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Host', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: HostMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + hosts: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + cpuCount: 2, + averageCpuUsagePercent: 99, + totalMemoryMegabytes: 1024, + averageMemoryUsagePercent: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + cpuCount: 4, + averageCpuUsagePercent: 74, + totalMemoryMegabytes: 2450, + averageMemoryUsagePercent: 13, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + cpuCount: 8, + averageCpuUsagePercent: 56, + totalMemoryMegabytes: 4810, + averageMemoryUsagePercent: 74, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + cpuCount: 16, + averageCpuUsagePercent: 34, + totalMemoryMegabytes: 8123, + averageMemoryUsagePercent: 56, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + cpuCount: 32, + averageCpuUsagePercent: 13, + totalMemoryMegabytes: 16792, + averageMemoryUsagePercent: 99, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: HostMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx new file mode 100644 index 00000000000000..fd2a010e323214 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx @@ -0,0 +1,129 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyHostMetricsTable } from './create_lazy_host_metrics_table'; +import IntegratedHostMetricsTable from './integrated_host_metrics_table'; +import { metricByField } from './use_host_metrics_table'; + +describe('HostMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'host.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyHostMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyHostMetricsTable = createLazyHostMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('hostMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('hostMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedHostMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-host/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createHost('some-host', 6, 76, 3671700000, 24), + createHost('some-other-host', 12, 67, 7176300000, 42), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createHost( + name: string, + coreCount: number, + cpuUsagePct: number, + memoryBytes: number, + memoryUsagePct: number +) { + return { + id: name, + rows: [ + { + [metricByField['system.cpu.cores']]: coreCount, + [metricByField['system.cpu.total.norm.pct']]: cpuUsagePct, + [metricByField['system.memory.total']]: memoryBytes, + [metricByField['system.memory.used.pct']]: memoryUsagePct, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx new file mode 100644 index 00000000000000..d878fc091722b8 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.tsx @@ -0,0 +1,143 @@ +/* + * 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 { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination } from '../shared'; +import type { SortState } from '../shared'; +import type { HostNodeMetricsRow } from './use_host_metrics_table'; + +export interface HostMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + hosts: HostNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const HostMetricsTable = (props: HostMetricsTableProps) => { + const { + timerange, + isLoading, + hosts, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => hostMetricsColumns(timerange), [timerange]); + + const sortSettings: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = useCallback( + ({ sort }: EuiCriteria) => { + if (!sort) { + return; + } + + setSortState(sort); + setCurrentPageIndex(0); + }, + [setSortState, setCurrentPageIndex] + ); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function hostMetricsColumns( + timerange: HostMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => ( + + ), + }, + { + name: '# of CPUs', + field: 'cpuCount', + align: 'right', + render: (cpuCount: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory total (avg.)', + field: 'totalMemoryMegabytes', + align: 'right', + render: (totalMemoryMegabytes: number) => ( + + ), + }, + { + name: 'Memory usage (avg.)', + field: 'averageMemoryUsagePercent', + align: 'right', + render: (averageMemoryUsagePercent: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts new file mode 100644 index 00000000000000..6200127580f96c --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { HostMetricsTable } from './host_metrics_table'; +export { useHostMetricsTable } from './use_host_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx new file mode 100644 index 00000000000000..ca274a1ef805fa --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/integrated_host_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { HostMetricsTable } from './host_metrics_table'; +import { useHostMetricsTable } from './use_host_metrics_table'; + +function HookedHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const hostMetricsTableProps = useHostMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function HostMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default HostMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts new file mode 100644 index 00000000000000..dddd5ad03c7b07 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/use_host_metrics_table.ts @@ -0,0 +1,122 @@ +/* + * 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 { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type HostMetricsField = + | 'system.cpu.cores' + | 'system.cpu.total.norm.pct' + | 'system.memory.total' + | 'system.memory.used.pct'; + +const hostMetricsMap: MetricsMap = { + 'system.cpu.cores': { aggregation: 'max', field: 'system.cpu.cores' }, + 'system.cpu.total.norm.pct': { + aggregation: 'avg', + field: 'system.cpu.total.norm.pct', + }, + 'system.memory.total': { aggregation: 'max', field: 'system.memory.total' }, + 'system.memory.used.pct': { + aggregation: 'avg', + field: 'system.memory.used.pct', + }, +}; + +const { options: hostMetricsOptions, metricByField } = metricsToApiOptions( + hostMetricsMap, + 'host.name' +); +export { metricByField }; + +export interface HostNodeMetricsRow { + name: string; + cpuCount: number | null; + averageCpuUsagePercent: number | null; + totalMemoryMegabytes: number | null; + averageMemoryUsagePercent: number | null; +} + +export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: hosts, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: hostMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToHostNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + hosts, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToHostNodeMetricsRow(series: MetricsExplorerSeries): HostNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + cpuCount: null, + averageCpuUsagePercent: null, + totalMemoryMegabytes: null, + averageMemoryUsagePercent: null, + }; + } + + let cpuCount = 0; + let averageCpuUsagePercent = 0; + let totalMemoryMegabytes = 0; + let averageMemoryUsagePercent = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + cpuCount += metricValues.cpuCount ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + totalMemoryMegabytes += metricValues.totalMemoryMegabytes ?? 0; + averageMemoryUsagePercent += metricValues.averageMemoryUsagePercent ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + cpuCount: cpuCount / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + totalMemoryMegabytes: Math.floor(totalMemoryMegabytes / bucketCount / bytesPerMegabyte), + averageMemoryUsagePercent: averageMemoryUsagePercent / bucketCount, + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + cpuCount: row[metricByField['system.cpu.cores']] as number | null, + averageCpuUsagePercent: row[metricByField['system.cpu.total.norm.pct']] as number | null, + totalMemoryMegabytes: row[metricByField['system.memory.total']] as number | null, + averageMemoryUsagePercent: row[metricByField['system.memory.used.pct']] as number | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts new file mode 100644 index 00000000000000..cf6fe4b4d11beb --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ContainerMetricsTable, useContainerMetricsTable } from './container'; +export { HostMetricsTable, useHostMetricsTable } from './host'; +export { PodMetricsTable, usePodMetricsTable } from './pod'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx new file mode 100644 index 00000000000000..d24ce323fc2beb --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { SourceProviderProps, UseNodeMetricsTableOptions } from '../shared'; + +const LazyIntegratedPodMetricsTable = lazy(() => import('./integrated_pod_metrics_table')); + +export function createLazyPodMetricsTable(coreProvidersProps: CoreProvidersProps) { + return ({ + timerange, + filterClauseDsl, + sourceId, + }: UseNodeMetricsTableOptions & Partial) => ( + + + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts new file mode 100644 index 00000000000000..ddb679d255dc4b --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PodMetricsTable } from './pod_metrics_table'; +export { usePodMetricsTable } from './use_pod_metrics_table'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx new file mode 100644 index 00000000000000..5166e984ccb026 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/integrated_pod_metrics_table.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreProviders } from '../../../apps/common_providers'; +import { SourceProvider } from '../../../containers/metrics_source'; +import type { IntegratedNodeMetricsTableProps, UseNodeMetricsTableOptions } from '../shared'; +import { PodMetricsTable } from './pod_metrics_table'; +import { usePodMetricsTable } from './use_pod_metrics_table'; + +function HookedPodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const podMetricsTableProps = usePodMetricsTable({ timerange, filterClauseDsl }); + return ; +} + +function PodMetricsTableWithProviders({ + timerange, + filterClauseDsl, + sourceId, + ...coreProvidersProps +}: IntegratedNodeMetricsTableProps) { + return ( + + + + + + ); +} + +// Use default export for lazy loading. +// eslint-disable-next-line import/no-default-export +export default PodMetricsTableWithProviders; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx new file mode 100644 index 00000000000000..50a9a95f8b73e9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta } from '@storybook/react/types-6-0'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; +import { PodMetricsTable } from './pod_metrics_table'; +import type { PodMetricsTableProps } from './pod_metrics_table'; + +const mockServices = { + application: { + getUrlForApp: (app: string, { path }: { path: string }) => `your-kibana/app/${app}/${path}`, + }, +}; + +export default { + title: 'infra/Node Metrics Tables/Pod', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => ( + + {wrappedStory()} + + ), + decorateWithGlobalStorybookThemeProviders, + ], + component: PodMetricsTable, + argTypes: { + setSortState: { + action: 'Sort field or direction changed', + }, + setCurrentPageIndex: { + action: 'Page changed', + }, + }, +} as Meta; + +const storyArgs: Omit = { + isLoading: false, + pods: [ + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg1', + uptime: 23000000, + averageCpuUsagePercent: 99, + averageMemoryUsageMegabytes: 34, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg2', + uptime: 43000000, + averageCpuUsagePercent: 72, + averageMemoryUsageMegabytes: 68, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg3', + uptime: 53000000, + averageCpuUsagePercent: 54, + averageMemoryUsageMegabytes: 132, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg4', + uptime: 63000000, + averageCpuUsagePercent: 34, + averageMemoryUsageMegabytes: 264, + }, + { + name: 'gke-edge-oblt-pool-1-9a60016d-lgg5', + uptime: 83000000, + averageCpuUsagePercent: 13, + averageMemoryUsageMegabytes: 512, + }, + ], + currentPageIndex: 0, + pageCount: 10, + sortState: { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + timerange: { + from: 'now-15m', + to: 'now', + }, +}; + +export const Demo = (args: PodMetricsTableProps) => { + return ; +}; +Demo.args = storyArgs; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx new file mode 100644 index 00000000000000..ab4b449f5331bc --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx @@ -0,0 +1,122 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import type { HttpFetchOptions } from '../../../../../../../src/core/public'; +import type { + DataResponseMock, + NodeMetricsTableFetchMock, + SourceResponseMock, +} from '../test_helpers'; +import { createCoreProvidersPropsMock } from '../test_helpers'; +import { createLazyPodMetricsTable } from './create_lazy_pod_metrics_table'; +import IntegratedPodMetricsTable from './integrated_pod_metrics_table'; +import { metricByField } from './use_pod_metrics_table'; + +describe('PodMetricsTable', () => { + const timerange = { + from: 'now-15m', + to: 'now', + }; + + const filterClauseDsl = { + bool: { + should: [ + { + match: { + 'pod.name': 'gke-edge-oblt-pool-1-9a60016d-lgg9', + }, + }, + ], + minimum_should_match: 1, + }, + }; + + const fetchMock = createFetchMock(); + + describe('createLazyPodMetricsTable', () => { + it('should lazily load and render the table', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + const LazyPodMetricsTable = createLazyPodMetricsTable(coreProvidersPropsMock); + + render(); + + expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('podMetricsTable')).not.toBeInTheDocument(); + + // Using longer time out since resolving dynamic import can be slow + // https://github.com/facebook/jest/issues/10933 + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2), { + timeout: 10000, + }); + + expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('podMetricsTable')).toBeInTheDocument(); + }, 10000); + }); + + describe('IntegratedPodMetricsTable', () => { + it('should render a single row of data', async () => { + const { coreProvidersPropsMock, fetch } = createCoreProvidersPropsMock(fetchMock); + + const { findByText } = render( + + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(await findByText(/some-pod/)).toBeInTheDocument(); + }); + }); +}); + +function createFetchMock(): NodeMetricsTableFetchMock { + const sourceMock: SourceResponseMock = { + source: { + configuration: { + metricAlias: 'some-index-pattern', + }, + }, + }; + + const mockData: DataResponseMock = { + series: [ + createPod('some-pod', 23000000, 76, 3671700000), + createPod('some-other-pod', 32000000, 67, 716300000), + ], + }; + + return (path: string, options: HttpFetchOptions) => { + // options can be used to read body for filter clause + if (path === '/api/metrics/source/default') { + return Promise.resolve(sourceMock); + } else if (path === '/api/infra/metrics_explorer') { + return Promise.resolve(mockData); + } + + throw new Error('Unexpected URL called in test'); + }; +} + +function createPod(name: string, uptimeMs: number, cpuUsagePct: number, memoryUsageBytes: number) { + return { + id: name, + rows: [ + { + [metricByField['kubernetes.pod.start_time']]: uptimeMs, + [metricByField['kubernetes.pod.cpu.usage.node.pct']]: cpuUsagePct, + [metricByField['kubernetes.pod.memory.usage.bytes']]: memoryUsageBytes, + }, + ], + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx new file mode 100644 index 00000000000000..3739d6b4682923 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.tsx @@ -0,0 +1,133 @@ +/* + * 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 { + Criteria as EuiCriteria, + EuiBasicTableColumn, + EuiTableSortingType, +} from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared'; +import type { SortState } from '../shared'; +import type { PodNodeMetricsRow } from './use_pod_metrics_table'; + +export interface PodMetricsTableProps { + timerange: { + from: string; + to: string; + }; + isLoading: boolean; + pods: PodNodeMetricsRow[]; + pageCount: number; + currentPageIndex: number; + setCurrentPageIndex: (value: number) => void; + sortState: SortState; + setSortState: (state: SortState) => void; +} + +export const PodMetricsTable = (props: PodMetricsTableProps) => { + const { + timerange, + isLoading, + pods, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + } = props; + + const columns = useMemo(() => podNodeColumns(timerange), [timerange]); + + const sorting: EuiTableSortingType = { + enableAllColumns: true, + sort: sortState, + }; + + const onTableSortChange = ({ + sort = { + direction: 'desc', + field: 'averageCpuUsagePercent', + }, + }: EuiCriteria) => { + setSortState(sort); + setCurrentPageIndex(0); + }; + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + + + ); +}; + +function podNodeColumns( + timerange: PodMetricsTableProps['timerange'] +): Array> { + return [ + { + name: 'Name', + field: 'name', + truncateText: true, + render: (name: string) => { + return ; + }, + }, + { + name: 'Uptime', + field: 'uptime', + align: 'right', + render: (uptime: number) => , + }, + { + name: 'CPU usage (avg.)', + field: 'averageCpuUsagePercent', + align: 'right', + render: (averageCpuUsagePercent: number) => ( + + ), + }, + { + name: 'Memory usage (avg.)', + field: 'averageMemoryUsageMegabytes', + align: 'right', + render: (averageMemoryUsageMegabytes: number) => ( + + ), + }, + ]; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts new file mode 100644 index 00000000000000..004ab2ab3ffffb --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/use_pod_metrics_table.ts @@ -0,0 +1,123 @@ +/* + * 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 { useState } from 'react'; +import type { + MetricsExplorerRow, + MetricsExplorerSeries, +} from '../../../../common/http_api/metrics_explorer'; +import type { MetricsMap, SortState, UseNodeMetricsTableOptions } from '../shared'; +import { metricsToApiOptions, useInfrastructureNodeMetrics } from '../shared'; + +type PodMetricsField = + | 'kubernetes.pod.start_time' + | 'kubernetes.pod.cpu.usage.node.pct' + | 'kubernetes.pod.memory.usage.bytes'; + +const podMetricsMap: MetricsMap = { + 'kubernetes.pod.start_time': { + aggregation: 'max', + field: 'kubernetes.pod.start_time', + }, + 'kubernetes.pod.cpu.usage.node.pct': { + aggregation: 'avg', + field: 'kubernetes.pod.cpu.usage.node.pct', + }, + 'kubernetes.pod.memory.usage.bytes': { + aggregation: 'avg', + field: 'kubernetes.pod.memory.usage.bytes', + }, +}; + +const { options: podMetricsOptions, metricByField } = metricsToApiOptions( + podMetricsMap, + 'kubernetes.pod.name' +); +export { metricByField }; + +export interface PodNodeMetricsRow { + name: string; + uptime: number | null; + averageCpuUsagePercent: number | null; + averageMemoryUsageMegabytes: number | null; +} + +export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetricsTableOptions) { + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [sortState, setSortState] = useState>({ + field: 'averageCpuUsagePercent', + direction: 'desc', + }); + + const { + isLoading, + nodes: pods, + pageCount, + } = useInfrastructureNodeMetrics({ + metricsExplorerOptions: podMetricsOptions, + timerange, + filterClauseDsl, + transform: seriesToPodNodeMetricsRow, + sortState, + currentPageIndex, + }); + + return { + timerange, + isLoading, + pods, + pageCount, + currentPageIndex, + setCurrentPageIndex, + sortState, + setSortState, + }; +} + +function seriesToPodNodeMetricsRow(series: MetricsExplorerSeries): PodNodeMetricsRow { + if (series.rows.length === 0) { + return { + name: series.id, + uptime: null, + averageCpuUsagePercent: null, + averageMemoryUsageMegabytes: null, + }; + } + + let uptime: number = 0; + let averageCpuUsagePercent: number = 0; + let averageMemoryUsagePercent: number = 0; + series.rows.forEach((row) => { + const metricValues = unpackMetrics(row); + uptime += metricValues.uptime ?? 0; + averageCpuUsagePercent += metricValues.averageCpuUsagePercent ?? 0; + averageMemoryUsagePercent += metricValues.averageMemoryUsageMegabytes ?? 0; + }); + + const bucketCount = series.rows.length; + const bytesPerMegabyte = 1000000; + return { + name: series.id, + uptime: uptime / bucketCount, + averageCpuUsagePercent: averageCpuUsagePercent / bucketCount, + averageMemoryUsageMegabytes: Math.floor( + averageMemoryUsagePercent / bucketCount / bytesPerMegabyte + ), + }; +} + +function unpackMetrics(row: MetricsExplorerRow): Omit { + return { + uptime: row[metricByField['kubernetes.pod.start_time']] as number | null, + averageCpuUsagePercent: row[metricByField['kubernetes.pod.cpu.usage.node.pct']] as + | number + | null, + averageMemoryUsageMegabytes: row[metricByField['kubernetes.pod.memory.usage.bytes']] as + | number + | null, + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts new file mode 100644 index 00000000000000..fa4398a279e86c --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsNodeDetailsLink } from './metrics_node_details_link'; +export { NumberCell } from './number_cell'; +export { StepwisePagination } from './stepwise_pagination'; +export { UptimeCell } from './uptime_cell'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx new file mode 100644 index 00000000000000..b51e1bc8b77070 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx @@ -0,0 +1,39 @@ +/* + * 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 { parse } from '@elastic/datemath'; +import { EuiLink } from '@elastic/eui'; +import React from 'react'; +import { useLinkProps } from '../../../../../../observability/public'; +import type { InventoryItemType } from '../../../../../common/inventory_models/types'; +import { getNodeDetailUrl } from '../../../../pages/link_to'; +import type { MetricsExplorerTimeOptions } from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +type ExtractStrict = Extract; + +interface MetricsNodeDetailsLinkProps { + id: string; + nodeType: ExtractStrict; + timerange: Pick; +} + +export const MetricsNodeDetailsLink = ({ + id, + nodeType, + timerange, +}: MetricsNodeDetailsLinkProps) => { + const linkProps = useLinkProps( + getNodeDetailUrl({ + nodeType, + nodeId: id, + from: parse(timerange.from)?.valueOf(), + to: parse(timerange.to)?.valueOf(), + }) + ); + + return {id}; +}; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx new file mode 100644 index 00000000000000..368484b4af43d2 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/number_cell.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiI18nNumber, EuiTextColor } from '@elastic/eui'; +import React from 'react'; + +interface NumberCellProps { + value?: number; + unit?: string; +} + +export function NumberCell({ value, unit }: NumberCellProps) { + if (value === null || value === undefined || isNaN(value)) { + return N/A; + } + + if (!unit) { + return ; + } + + return ( + + + {unit} + + ); +} + +function roundToOneDecimal(value: number) { + return Math.round(value * 10) / 10; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx new file mode 100644 index 00000000000000..71305061ccb558 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/stepwise_pagination.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiPagination } from '@elastic/eui'; +import React from 'react'; +import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; + +interface CursorPaginationProps { + ariaLabel: string; + currentPageIndex: number; + pageCount: number; + setCurrentPageIndex: (nextPageIndex: number) => void; +} + +const EuiStepwisePagination = euiStyled(EuiPagination)` + [data-test-subj="pagination-button-first"], + [data-test-subj="pagination-button-last"] { + display: none; + } +`; + +export function StepwisePagination({ + ariaLabel, + pageCount, + currentPageIndex, + setCurrentPageIndex, +}: CursorPaginationProps) { + return ( + + ); +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx new file mode 100644 index 00000000000000..80b2ba4a755e10 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/uptime_cell.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiTextColor } from '@elastic/eui'; +import React from 'react'; + +interface UptimeCellProps { + uptimeMs?: number; +} + +export function UptimeCell({ uptimeMs }: UptimeCellProps) { + if (uptimeMs === null || uptimeMs === undefined || isNaN(uptimeMs)) { + return N/A; + } + + return {formatUptime(uptimeMs)}; +} + +const MS_PER_MINUTE = 1000 * 60; +const MS_PER_HOUR = MS_PER_MINUTE * 60; +const MS_PER_DAY = MS_PER_HOUR * 24; + +function formatUptime(uptimeMs: number): string { + if (uptimeMs < MS_PER_HOUR) { + const minutes = Math.floor(uptimeMs / MS_PER_MINUTE); + + if (minutes > 0) { + return `${minutes}m`; + } + + return '< a minute'; + } + + if (uptimeMs < MS_PER_DAY) { + const hours = Math.floor(uptimeMs / MS_PER_HOUR); + const remainingUptimeMs = uptimeMs - hours * MS_PER_HOUR; + const minutes = Math.floor(remainingUptimeMs / MS_PER_MINUTE); + + if (minutes > 0) { + return `${hours}h ${minutes}m`; + } + + return `${hours}h`; + } + + const days = Math.floor(uptimeMs / MS_PER_DAY); + const remainingUptimeMs = uptimeMs - days * MS_PER_DAY; + const hours = Math.floor(remainingUptimeMs / MS_PER_HOUR); + + if (hours > 0) { + return `${days}d ${hours}h`; + } + + return `${days}d`; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts new file mode 100644 index 00000000000000..b3e04f11229989 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { metricsToApiOptions } from './metrics_to_api_options'; +export type { MetricsMap } from './metrics_to_api_options'; +export { useInfrastructureNodeMetrics } from './use_infrastructure_node_metrics'; +export type { SortState } from './use_infrastructure_node_metrics'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts new file mode 100644 index 00000000000000..da4ccc45ebf7dc --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MetricsMap } from './metrics_to_api_options'; +import { metricsToApiOptions } from './metrics_to_api_options'; + +describe('metricsToApiOptions', () => { + type TestNodeTypeMetricsField = 'test.node.type.field1' | 'test.node.type.field2'; + + const testMetricsMapField1First: MetricsMap = { + 'test.node.type.field1': { + aggregation: 'max', + field: 'test.node.type.field1', + }, + 'test.node.type.field2': { + aggregation: 'avg', + field: 'test.node.type.field2', + }, + }; + + const testMetricsMapField1Second: MetricsMap = { + 'test.node.type.field2': { + aggregation: 'avg', + field: 'test.node.type.field2', + }, + 'test.node.type.field1': { + aggregation: 'max', + field: 'test.node.type.field1', + }, + }; + + const fields = ['test.node.type.field1', 'test.node.type.field2']; + + it('should join the grouping field with the metrics in the APIs expected format', () => { + const { options } = metricsToApiOptions( + testMetricsMapField1First, + 'test.node.type.groupingField' + ); + expect(options).toEqual({ + aggregation: 'avg', + groupBy: 'test.node.type.groupingField', + metrics: [ + { + field: 'test.node.type.field1', + aggregation: 'max', + }, + { + field: 'test.node.type.field2', + aggregation: 'avg', + }, + ], + }); + }); + + it('should provide a mapping object that allows consumer to ignore metric definition order', () => { + const field1First = metricsToApiOptions( + testMetricsMapField1First, + 'test.node.type.groupingField' + ); + + assertListContentIsEqual(Object.keys(field1First.metricByField), fields); + expect(field1First.metricByField).toEqual({ + 'test.node.type.field1': 'metric_0', + 'test.node.type.field2': 'metric_1', + }); + + const field1Second = metricsToApiOptions( + testMetricsMapField1Second, + 'test.node.type.groupingField' + ); + + assertListContentIsEqual(Object.keys(field1Second.metricByField), fields); + expect(field1Second.metricByField).toEqual({ + 'test.node.type.field1': 'metric_1', + 'test.node.type.field2': 'metric_0', + }); + }); + + function assertListContentIsEqual(firstList: string[], secondList: string[]) { + const firstListAsSet = new Set(firstList); + const secondListAsSet = new Set(secondList); + + expect(firstListAsSet).toEqual(secondListAsSet); + expect(firstList.length).toBe(secondList.length); + } +}); diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts new file mode 100644 index 00000000000000..23d6383a303da5 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/metrics_to_api_options.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + MetricsExplorerOptions, + MetricsExplorerOptionsMetric, +} from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +/* +They key part of this file is the function 'createFieldLookup'. + +The metrics_explorer endpoint expects a list of the metrics to use, like this: +[ + { field: 'some.metric.field', aggregation: 'avg' }, + { field: 'some.other.metric.field', aggregation: 'min' }, +] + +The API then responds with a series, which is a list of rows (buckets from a date_histogram +aggregation), where each bucket has this format: +{ metric_0: 99, metric_1: 88 } + +For each metric in the request, a key like metric_X is defined, and the number used is the order in +which the metric appeared in the request. So if the metric for 'some.metric.field' is first, it'll +be mapped to metric_0, but if the code changes and it is now second, it will be mapped to metric_1. + +This makes the code that consumes the API response fragile to such re-ordering, the types and +functions in this file are used to reduce this fragility and allowing consuming code to reference +the metrics by their field names instead. +The returned metricByField object, handles the translation from field name to "index name". +For example, in the transform function passed to useInfrastructureNodeMetrics it can be used +to find a field metric like this: +row[metricByField['kubernetes.container.start_time']] + +If the endpoint where to change its return format to: +{ 'some.metric.field': 99, 'some.other.metric.field': 88 } +Then this code would no longer be needed. +*/ + +// The input to this generic type is a (union) string type that defines all the fields we want to +// request metrics for. This input type serves as something like a "source of truth" for which +// fields are being used. The resulting MetricsMap and metricByField helper ensures a type safe +// usage of the metrics data returned from the API. +export type MetricsMap = { + [field in T]: NodeMetricsExplorerOptionsMetric; +}; + +// MetricsMap uses an object type to ensure each field gets defined. +// This type only ensures that the MetricsMap is defined in a way that the key matches the field +// it uses +// { 'some-field: { field: 'some-field', aggregation: 'whatever' } } +export interface NodeMetricsExplorerOptionsMetric + extends Omit { + field: Field; +} + +export function metricsToApiOptions(metricsMap: MetricsMap, groupBy: string) { + const metrics = Object.values(metricsMap) as Array>; + + const options: MetricsExplorerOptions = { + aggregation: 'avg', + groupBy, + metrics, + }; + + const metricByField = createFieldLookup(Object.keys(metricsMap) as T[], metrics); + + return { + options, + metricByField, + }; +} + +function createFieldLookup( + fields: T[], + metrics: Array> +) { + const setMetricIndexToField = (acc: Record, field: T) => { + return { + ...acc, + [field]: fieldToMetricIndex(field, metrics), + }; + }; + return fields.reduce(setMetricIndexToField, {} as Record); +} + +function fieldToMetricIndex( + field: T, + metrics: Array> +) { + const index = metrics.findIndex((metric) => metric.field === field); + + if (index === -1) { + throw new Error('Failed to find index for field ' + field); + } + + return `metric_${index}`; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts new file mode 100644 index 00000000000000..47e4fd86f04e2b --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/hooks/use_infrastructure_node_metrics.ts @@ -0,0 +1,174 @@ +/* + * 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 { parse } from '@elastic/datemath'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useEffect, useMemo, useState } from 'react'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import type { + MetricsExplorerResponse, + MetricsExplorerSeries, +} from '../../../../../common/http_api/metrics_explorer'; +import { useSourceContext } from '../../../../containers/metrics_source'; +import type { + MetricsExplorerOptions, + MetricsExplorerTimeOptions, +} from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { useTrackedPromise } from '../../../../utils/use_tracked_promise'; + +export interface SortState { + field: keyof T; + direction: 'asc' | 'desc'; +} + +interface UseInfrastructureNodeMetricsOptions { + metricsExplorerOptions: MetricsExplorerOptions; + timerange: Pick; + filterClauseDsl?: QueryDslQueryContainer; + transform: (series: MetricsExplorerSeries) => T; + sortState: SortState; + currentPageIndex: number; +} + +const NODE_COUNT_LIMIT = 10000; +const TOTAL_NODES_LIMIT = 100; +const TABLE_PAGE_SIZE = 10; +const nullData: MetricsExplorerResponse = { + series: [], + pageInfo: { + afterKey: null, + total: -1, + }, +}; + +export const useInfrastructureNodeMetrics = ( + options: UseInfrastructureNodeMetricsOptions +) => { + const { + metricsExplorerOptions, + timerange, + filterClauseDsl, + transform, + sortState, + currentPageIndex, + } = options; + + const [transformedNodes, setTransformedNodes] = useState([]); + const fetch = useKibanaHttpFetch(); + const { source, isLoadingSource } = useSourceContext(); + const timerangeWithInterval = useTimerangeWithInterval(timerange); + + const [{ state: promiseState }, fetchNodes] = useTrackedPromise( + { + createPromise: (): Promise => { + if (!source) { + return Promise.resolve(nullData); + } + + const request = { + metrics: metricsExplorerOptions.metrics, + groupBy: metricsExplorerOptions.groupBy, + limit: NODE_COUNT_LIMIT, + indexPattern: source.configuration.metricAlias, + filterQuery: JSON.stringify(filterClauseDsl), + timerange: timerangeWithInterval, + }; + + return fetch('/api/infra/metrics_explorer', { + method: 'POST', + body: JSON.stringify(request), + }); + }, + onResolve: (response: MetricsExplorerResponse) => { + setTransformedNodes(response.series.map(transform)); + }, + onReject: (error) => { + // What to do about this? + // eslint-disable-next-line no-console + console.log(error); + }, + cancelPreviousOn: 'creation', + }, + [source, metricsExplorerOptions, timerangeWithInterval, filterClauseDsl] + ); + const isLoadingNodes = promiseState === 'pending' || promiseState === 'uninitialized'; + + useEffect(() => { + fetchNodes(); + }, [fetchNodes]); + + const sortedNodes = useMemo(() => { + return [...transformedNodes].sort(makeSortNodes(sortState)); + }, [transformedNodes, sortState]); + + const top100Nodes = useMemo(() => { + return sortedNodes.slice(0, TOTAL_NODES_LIMIT); + }, [sortedNodes]); + + const nodes = useMemo(() => { + const pageStartIndex = currentPageIndex * TABLE_PAGE_SIZE; + const pageEndIndex = pageStartIndex + TABLE_PAGE_SIZE; + return top100Nodes.slice(pageStartIndex, pageEndIndex); + }, [top100Nodes, currentPageIndex]); + + const pageCount = useMemo(() => Math.ceil(top100Nodes.length / TABLE_PAGE_SIZE), [top100Nodes]); + + return { + isLoading: isLoadingSource || isLoadingNodes, + nodes, + pageCount, + }; +}; + +function useKibanaHttpFetch() { + const kibana = useKibana(); + const fetch = kibana.services.http?.fetch; + + if (!fetch) { + throw new Error('Could not find Kibana HTTP fetch'); + } + + return fetch; +} + +function useTimerangeWithInterval(timerange: Pick) { + return useMemo(() => { + const from = parse(timerange.from); + const to = parse(timerange.to); + + if (!from || !to) { + throw new Error('Could not parse timerange'); + } + + return { from: from.valueOf(), to: to.valueOf(), interval: 'modules' }; + }, [timerange]); +} + +function makeSortNodes(sortState: SortState) { + return (nodeA: T, nodeB: T) => { + const nodeAValue = nodeA[sortState.field]; + const nodeBValue = nodeB[sortState.field]; + + if (typeof nodeAValue === 'string' && typeof nodeBValue === 'string') { + if (sortState.direction === 'asc') { + return nodeAValue.localeCompare(nodeBValue); + } else { + return nodeBValue.localeCompare(nodeAValue); + } + } + + if (typeof nodeAValue === 'number' && typeof nodeBValue === 'number') { + if (sortState.direction === 'asc') { + return nodeAValue - nodeBValue; + } else { + return nodeBValue - nodeAValue; + } + } + + return 0; + }; +} diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts new file mode 100644 index 00000000000000..8c74b28764d357 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from './components'; +export { metricsToApiOptions, useInfrastructureNodeMetrics } from './hooks'; +export type { MetricsMap, SortState } from './hooks'; +export type { + IntegratedNodeMetricsTableProps, + SourceProviderProps, + UseNodeMetricsTableOptions, +} from './types'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts new file mode 100644 index 00000000000000..5ab363dc7fafd4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { CoreProvidersProps } from '../../../apps/common_providers'; +import type { MetricsExplorerTimeOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +export interface UseNodeMetricsTableOptions { + timerange: Pick; + filterClauseDsl?: QueryDslQueryContainer; +} + +export interface SourceProviderProps { + sourceId: string; +} + +export type IntegratedNodeMetricsTableProps = UseNodeMetricsTableOptions & + SourceProviderProps & + CoreProvidersProps; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts new file mode 100644 index 00000000000000..3a53e006479997 --- /dev/null +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/test_helpers.ts @@ -0,0 +1,38 @@ +/* + * 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 { DeepPartial } from 'utility-types'; +import type { HttpFetchOptions } from '../../../../../../src/core/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import type { MetricsExplorerResponse } from '../../../common/http_api/metrics_explorer'; +import type { MetricsSourceConfigurationResponse } from '../../../common/metrics_sources'; +import type { CoreProvidersProps } from '../../apps/common_providers'; +import type { InfraClientStartDeps } from '../../types'; + +export type SourceResponseMock = DeepPartial; +export type DataResponseMock = DeepPartial; +export type NodeMetricsTableFetchMock = ( + path: string, + options: HttpFetchOptions +) => Promise; + +export function createCoreProvidersPropsMock(fetchMock: NodeMetricsTableFetchMock) { + const core = coreMock.createStart(); + // @ts-expect-error core.http.fetch has overloads, Jest/TypeScript only picks the first definition when mocking + core.http.fetch.mockImplementation(fetchMock); + + const coreProvidersPropsMock: CoreProvidersProps = { + core, + plugins: {} as InfraClientStartDeps, + theme$: core.theme.theme$, + }; + + return { + coreProvidersPropsMock, + fetch: core.http.fetch, + }; +} diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 40c6d611832280..806947b1e5c3ff 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -19,7 +19,7 @@ function setup() { return Promise.resolve([ core as CoreStart, deps as InfraClientStartDeps, - void 0 as InfraClientStartExports, + {} as InfraClientStartExports, ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; }); return { core, mockedGetStartServices }; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 1eb016f5829392..6a125c75ab396c 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -13,6 +13,10 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { createInventoryMetricRuleType } from './alerting/inventory'; import { createLogThresholdRuleType } from './alerting/log_threshold'; import { createMetricThresholdRuleType } from './alerting/metric_threshold'; +import type { CoreProvidersProps } from './apps/common_providers'; +import { createLazyContainerMetricsTable } from './components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table'; +import { createLazyHostMetricsTable } from './components/infrastructure_node_metrics_tables/host/create_lazy_host_metrics_table'; +import { createLazyPodMetricsTable } from './components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table'; import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; @@ -204,7 +208,19 @@ export class Plugin implements InfraClientPluginClass { }); } - start(_core: InfraClientCoreStart, _plugins: InfraClientStartDeps) {} + start(core: InfraClientCoreStart, plugins: InfraClientStartDeps) { + const coreProvidersProps: CoreProvidersProps = { + core, + plugins, + theme$: core.theme.theme$, + }; + + return { + ContainerMetricsTable: createLazyContainerMetricsTable(coreProvidersProps), + HostMetricsTable: createLazyHostMetricsTable(coreProvidersProps), + PodMetricsTable: createLazyPodMetricsTable(coreProvidersProps), + }; + } stop() {} } diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index d304309f278024..8c0033c1b79e5f 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -8,8 +8,8 @@ import type { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; import { IHttpFetchError } from 'src/core/public'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import type { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; import type { UsageCollectionSetup, UsageCollectionStart, @@ -19,18 +19,32 @@ import type { TriggersAndActionsUIPublicPluginStart, } from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; +import { MlPluginSetup, MlPluginStart } from '../../ml/public'; import type { ObservabilityPublicSetup, ObservabilityPublicStart, } from '../../observability/public'; // import type { OsqueryPluginStart } from '../../osquery/public'; import type { SpacesPluginStart } from '../../spaces/public'; -import { MlPluginStart, MlPluginSetup } from '../../ml/public'; -import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { + SourceProviderProps, + UseNodeMetricsTableOptions, +} from './components/infrastructure_node_metrics_tables/shared'; // Our own setup and start contract values export type InfraClientSetupExports = void; -export type InfraClientStartExports = void; + +export interface InfraClientStartExports { + ContainerMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; + HostMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; + PodMetricsTable: ( + props: UseNodeMetricsTableOptions & Partial + ) => JSX.Element; +} export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts index 8a1920f534cd65..d57dc5690e9c29 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts @@ -87,7 +87,7 @@ function setup() { return Promise.resolve([ core as CoreStart, deps as InfraClientStartDeps, - void 0 as InfraClientStartExports, + {} as InfraClientStartExports, ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; }); return { core, mockedGetStartServices, dataResponder }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts index 065c825076578b..9f907c55848024 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.test.ts @@ -474,7 +474,28 @@ describe('isSortableByColumn()', () => { ).toBeFalsy(); }); - it('SHOULD be sortable when NOT using top-hit agg', () => { + it('should NOT be sortable when NOT using date or number source field', () => { + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'string', + isBucketed: false, + sourceField: 'some_string_field', + operationType: 'last_value', + params: { + sortField: 'time', + showArrayValues: false, + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeFalsy(); + }); + + it('SHOULD be sortable when NOT using top-hit agg and source field is date or number', () => { expect( isSortableByColumn( getLayer(getStringBasedOperationColumn(), [ @@ -493,6 +514,25 @@ describe('isSortableByColumn()', () => { 'col2' ) ).toBeTruthy(); + + expect( + isSortableByColumn( + getLayer(getStringBasedOperationColumn(), [ + { + label: 'Last Value', + dataType: 'date', + isBucketed: false, + sourceField: 'order_date', + operationType: 'last_value', + params: { + sortField: 'time', + showArrayValues: false, + }, + } as GenericIndexPatternColumn, + ]), + 'col2' + ) + ).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts index ff8055be06d6b5..95fffca1212b3b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/helpers.ts @@ -208,7 +208,8 @@ export function getDisallowedTermsMessage( function checkLastValue(column: GenericIndexPatternColumn) { return ( column.operationType !== 'last_value' || - !(column as LastValueIndexPatternColumn).params.showArrayValues + (['number', 'date'].includes(column.dataType) && + !(column as LastValueIndexPatternColumn).params.showArrayValues) ); } diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 79954aa1b21457..ab3b97d1e614d3 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -9,17 +9,18 @@ import type { ErrorType } from '../util/errors'; export type JobType = 'anomaly-detector' | 'data-frame-analytics'; export type TrainedModelType = 'trained-model'; +export type MlSavedObjectType = JobType | TrainedModelType; export const ML_JOB_SAVED_OBJECT_TYPE = 'ml-job'; export const ML_TRAINED_MODEL_SAVED_OBJECT_TYPE = 'ml-trained-model'; export const ML_MODULE_SAVED_OBJECT_TYPE = 'ml-module'; export interface SavedObjectResult { - [id: string]: { success: boolean; type: JobType | TrainedModelType; error?: ErrorType }; + [id: string]: { success: boolean; type: MlSavedObjectType; error?: ErrorType }; } export type SyncResult = { - [jobType in JobType | TrainedModelType]?: { + [jobType in MlSavedObjectType]?: { [id: string]: { success: boolean; error?: ErrorType }; }; }; diff --git a/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx b/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx index 42ee6e386c12dc..882a3518770712 100644 --- a/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx +++ b/x-pack/plugins/ml/public/application/components/delete_space_aware_item_check_modal/delete_space_aware_item_check_modal.tsx @@ -23,9 +23,8 @@ import { EuiSpacer, } from '@elastic/eui'; import type { - JobType, CanDeleteMLSpaceAwareItemsResponse, - TrainedModelType, + MlSavedObjectType, } from '../../../../common/types/saved_objects'; import { useMlApiContext } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; @@ -75,7 +74,7 @@ function getRespSummary( function getModalContent( ids: string[], - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, respSummary: CanDeleteMLSpaceAwareItemsSummary, hasManagedJob?: boolean ): ModalContentReturnType { @@ -96,7 +95,7 @@ function getModalContent( defaultMessage="{ids} have different space permissions. " values={{ ids: ids.join(', ') }} /> - {jobType === 'trained-model' ? ( + {mlSavedObjectType === 'trained-model' ? ( - {jobType === 'trained-model' ? ( + {mlSavedObjectType === 'trained-model' ? ( void; onCloseCallback: () => void; refreshJobsCallback?: () => void; - jobType: JobType | TrainedModelType; + mlSavedObjectType: MlSavedObjectType; ids: string[]; setDidUntag?: React.Dispatch>; hasManagedJob?: boolean; @@ -235,7 +234,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ canDeleteCallback, onCloseCallback, refreshJobsCallback, - jobType, + mlSavedObjectType, ids, setDidUntag, hasManagedJob, @@ -257,7 +256,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ useEffect(() => { setIsLoading(true); // Do the spaces check and set the content for the modal and buttons depending on results - canDeleteMLSpaceAwareItems(jobType, ids).then((resp) => { + canDeleteMLSpaceAwareItems(mlSavedObjectType, ids).then((resp) => { const respSummary = getRespSummary(resp); const { canDelete, canRemoveFromSpace, canTakeAnyAction } = respSummary; if (canTakeAnyAction && canDelete && !canRemoveFromSpace) { @@ -266,7 +265,12 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ return; } setItemCheckRespSummary(respSummary); - const { buttonText, modalText } = getModalContent(ids, jobType, respSummary, hasManagedJob); + const { buttonText, modalText } = getModalContent( + ids, + mlSavedObjectType, + respSummary, + hasManagedJob + ); setButtonContent(buttonText); setModalContent(modalText); }); @@ -278,7 +282,7 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ const onUntagClick = async () => { setIsUntagging(true); - const resp = await removeItemFromCurrentSpace(jobType, ids); + const resp = await removeItemFromCurrentSpace(mlSavedObjectType, ids); setIsUntagging(false); if (typeof setDidUntag === 'function') { setDidUntag(true); @@ -356,7 +360,9 @@ export const DeleteSpaceAwareItemCheckModal: FC = ({ size="s" onClick={onUntagClick} > - {jobType === 'trained-model' ? shouldUnTagModelLabel : shouldUnTagJobLabel} + {mlSavedObjectType === 'trained-model' + ? shouldUnTagModelLabel + : shouldUnTagJobLabel} )}
diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts similarity index 77% rename from x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts rename to x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts index 8acec6a45a0c8c..5adc5716896cbd 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList } from './job_spaces_list'; +export { MLSavedObjectsSpacesList } from './ml_saved_objects_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx similarity index 82% rename from x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx rename to x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx index e8e06dfce784b1..20f487da0002e4 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_saved_objects_spaces_list/ml_saved_objects_spaces_list.tsx @@ -10,20 +10,19 @@ import React, { FC, useCallback, useState } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { - JobType, - TrainedModelType, ML_JOB_SAVED_OBJECT_TYPE, SavedObjectResult, + MlSavedObjectType, } from '../../../../common/types/saved_objects'; import type { SpacesPluginStart, ShareToSpaceFlyoutProps } from '../../../../../spaces/public'; -import { ml } from '../../services/ml_api_service'; +import { useMlApiContext } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { spacesApi: SpacesPluginStart; spaceIds: string[]; id: string; - jobType: JobType | TrainedModelType; + mlSavedObjectType: MlSavedObjectType; refresh(): void; } @@ -36,7 +35,16 @@ const modelObjectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.model defaultMessage: 'trained model', }); -export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, refresh }) => { +export const MLSavedObjectsSpacesList: FC = ({ + spacesApi, + spaceIds, + id, + mlSavedObjectType, + refresh, +}) => { + const { + savedObjects: { updateJobsSpaces, updateModelsSpaces }, + } = useMlApiContext(); const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); @@ -50,16 +58,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, ref const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove; if (spacesToAdd.length || spacesToRemove.length) { - if (jobType === 'trained-model') { - const resp = await ml.savedObjects.updateModelsSpaces([id], spacesToAdd, spacesToRemove); + if (mlSavedObjectType === 'trained-model') { + const resp = await updateModelsSpaces([id], spacesToAdd, spacesToRemove); handleApplySpaces(resp); } else { - const resp = await ml.savedObjects.updateJobsSpaces( - jobType, - [id], - spacesToAdd, - spacesToRemove - ); + const resp = await updateJobsSpaces(mlSavedObjectType, [id], spacesToAdd, spacesToRemove); handleApplySpaces(resp); } } @@ -94,7 +97,7 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, id, jobType, ref id, namespaces: spaceIds, title: id, - noun: jobType === 'trained-model' ? modelObjectNoun : jobObjectNoun, + noun: mlSavedObjectType === 'trained-model' ? modelObjectNoun : jobObjectNoun, }, behaviorContext: 'outside-space', changeSpacesHandler, diff --git a/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx b/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx index 028b9db6151476..ccbf0c1082d0ff 100644 --- a/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx +++ b/x-pack/plugins/ml/public/application/components/saved_objects_warning/saved_objects_warning.tsx @@ -8,18 +8,22 @@ import React, { FC, useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { JobType, TrainedModelType } from '../../../../common/types/saved_objects'; +import type { MlSavedObjectType } from '../../../../common/types/saved_objects'; import { useMlApiContext } from '../../contexts/kibana'; import { JobSpacesSyncFlyout } from '../../components/job_spaces_sync'; import { checkPermission } from '../../capabilities/check_capabilities'; interface Props { - jobType?: JobType | TrainedModelType; + mlSavedObjectType?: MlSavedObjectType; onCloseFlyout?: () => void; forceRefresh?: boolean; } -export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRefresh }) => { +export const SavedObjectsWarning: FC = ({ + mlSavedObjectType, + onCloseFlyout, + forceRefresh, +}) => { const { savedObjects: { syncCheck }, } = useMlApiContext(); @@ -35,7 +39,7 @@ export const SavedObjectsWarning: FC = ({ jobType, onCloseFlyout, forceRe return; } - const { result } = await syncCheck(jobType); + const { result } = await syncCheck(mlSavedObjectType); if (mounted.current === true) { setShowWarning(showSyncFlyout || result); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index 90661eb596c841..b6088e9db0252c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -66,7 +66,7 @@ export const useActions = ( deleteAction.closeDeleteJobCheckModal(); }} refreshJobsCallback={refresh} - jobType={deleteAction.jobType} + mlSavedObjectType={deleteAction.jobType} ids={[deleteAction.item.config.id]} /> )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 4213b593861228..329120e26be2d7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -34,7 +34,7 @@ import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/locator'; import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; -import { JobSpacesList } from '../../../../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../../../../components/ml_saved_objects_spaces_list'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -290,11 +290,11 @@ export const useColumns = ( }), render: (item: DataFrameAnalyticsListRow) => Array.isArray(item.spaceIds) ? ( - ) : null, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index b0f51874daed5d..57904a206d2815 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -65,7 +65,7 @@ export const Page: FC = () => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx index 92335b65eb9066..8bac15adb2afb6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -310,7 +310,7 @@ export const Controls: FC = React.memo( {isDeleteJobCheckModalVisible && item && ( { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx index ed31fa9fd10f47..efdd386ca47b3d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx @@ -112,7 +112,7 @@ export const Page: FC = () => { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx index 6df08e637249b9..701ee35d9ca927 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx @@ -167,7 +167,7 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, <> { setCanDelete(true); }} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 18826667298e51..19cbb99bbf7dd7 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -15,7 +15,7 @@ import { toLocaleString } from '../../../../util/string_utils'; import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; -import { JobSpacesList } from '../../../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../../../components/ml_saved_objects_spaces_list'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; import { @@ -315,11 +315,11 @@ export class JobsList extends Component { defaultMessage: 'Spaces', }), render: (item) => ( - ), diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index fe43aa9d3b1239..5620902ee768b0 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -52,7 +52,7 @@ import { getMlGlobalServices } from '../../../../app'; import { ListingPageUrlState } from '../../../../../../common/types/common'; import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; import { ExportJobsFlyout, ImportJobsFlyout } from '../../../../components/import_export_jobs'; -import type { JobType, TrainedModelType } from '../../../../../../common/types/saved_objects'; +import type { JobType, MlSavedObjectType } from '../../../../../../common/types/saved_objects'; import type { FieldFormatsStart } from '../../../../../../../../../src/plugins/field_formats/public'; interface Tab extends EuiTabbedContentTab { @@ -170,7 +170,7 @@ export const JobsListPage: FC<{ const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = useTabs(isMlEnabledInSpace, spacesApi); - const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); + const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); const I18nContext = coreStart.i18n.Context; const theme$ = coreStart.theme.theme$; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index 88eb318ccde27c..131244e7122ccc 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -15,7 +15,7 @@ import { HttpService } from '../http_service'; import { basePath } from './index'; import type { JobType, - TrainedModelType, + MlSavedObjectType, CanDeleteMLSpaceAwareItemsResponse, SyncSavedObjectResponse, InitializeSavedObjectResponse, @@ -45,8 +45,8 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ body, }); }, - removeItemFromCurrentSpace(jobType: JobType | TrainedModelType, ids: string[]) { - const body = JSON.stringify({ jobType, ids }); + removeItemFromCurrentSpace(mlSavedObjectType: MlSavedObjectType, ids: string[]) { + const body = JSON.stringify({ mlSavedObjectType, ids }); return httpService.http({ path: `${basePath()}/saved_objects/remove_item_from_current_space`, method: 'POST', @@ -67,18 +67,18 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ query: { simulate }, }); }, - syncCheck(jobType?: JobType | TrainedModelType) { - const body = JSON.stringify({ jobType }); + syncCheck(mlSavedObjectType?: MlSavedObjectType) { + const body = JSON.stringify({ mlSavedObjectType }); return httpService.http({ path: `${basePath()}/saved_objects/sync_check`, method: 'POST', body, }); }, - canDeleteMLSpaceAwareItems(jobType: JobType | TrainedModelType, ids: string[]) { + canDeleteMLSpaceAwareItems(mlSavedObjectType: MlSavedObjectType, ids: string[]) { const body = JSON.stringify({ ids }); return httpService.http({ - path: `${basePath()}/saved_objects/can_delete_ml_space_aware_item/${jobType}`, + path: `${basePath()}/saved_objects/can_delete_ml_space_aware_item/${mlSavedObjectType}`, method: 'POST', body, }); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx index 51ed0e5e05893d..401f18ab3d3a0d 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/delete_models_modal.tsx @@ -103,7 +103,7 @@ export const DeleteModelsModal: FC = ({ modelIds, onClos ) : ( {}} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index 96c2a1cd556e56..bd3e3638e83105 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -51,7 +51,7 @@ import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats import { useRefresh } from '../../routing/use_refresh'; import { DEPLOYMENT_STATE, TRAINED_MODEL_TYPE } from '../../../../common/constants/trained_models'; import { getUserConfirmationProvider } from './force_stop_dialog'; -import { JobSpacesList } from '../../components/job_spaces_list'; +import { MLSavedObjectsSpacesList } from '../../components/ml_saved_objects_spaces_list'; import { SavedObjectsWarning } from '../../components/saved_objects_warning'; type Stats = Omit; @@ -588,11 +588,11 @@ export const ModelsList: FC = ({ render: (id: string) => { const spaces = modelSpaces[id]; return ( - ); @@ -715,7 +715,7 @@ export const ModelsList: FC = ({ {isManagementTable ? null : ( <> diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index 4ff555445f2c80..342a3913a6cba9 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -7,7 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IScopedClusterClient } from 'kibana/server'; -import { JobSavedObjectService } from '../../saved_objects'; +import { MLSavedObjectService } from '../../saved_objects'; import { getJobDetailsFromTrainedModel } from '../../saved_objects/util'; import { JobType } from '../../../common/types/saved_objects'; @@ -27,7 +27,7 @@ import { export function getMlClient( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ): MlClient { const mlClient = client.asInternalUser.ml; @@ -40,7 +40,7 @@ export function getMlClient( } async function checkJobIds(jobType: JobType, jobIds: string[], allowWildcards: boolean = false) { - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace(jobType, jobIds); + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace(jobType, jobIds); let missingIds = jobIds.filter((j) => filteredJobIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -113,9 +113,7 @@ export function getMlClient( async function datafeedIdsCheck(p: MlClientParams, allowWildcards: boolean = false) { const datafeedIds = getDatafeedIdsFromRequest(p); if (datafeedIds.length) { - const filteredDatafeedIds = await jobSavedObjectService.filterDatafeedIdsForSpace( - datafeedIds - ); + const filteredDatafeedIds = await mlSavedObjectService.filterDatafeedIdsForSpace(datafeedIds); let missingIds = datafeedIds.filter((j) => filteredDatafeedIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -135,7 +133,7 @@ export function getMlClient( } async function checkModelIds(modelIds: string[], allowWildcards: boolean = false) { - const filteredModelIds = await jobSavedObjectService.filterTrainedModelIdsForSpace(modelIds); + const filteredModelIds = await mlSavedObjectService.filterTrainedModelIdsForSpace(modelIds); let missingIds = modelIds.filter((j) => filteredModelIds.indexOf(j) === -1); if (allowWildcards === true && missingIds.join().match('\\*') !== null) { // filter out wildcard ids from the error @@ -173,7 +171,7 @@ export function getMlClient( const resp = await mlClient.deleteDatafeed(...p); const [datafeedId] = getDatafeedIdsFromRequest(p); if (datafeedId !== undefined) { - await jobSavedObjectService.deleteDatafeed(datafeedId); + await mlSavedObjectService.deleteDatafeed(datafeedId); } return resp; }, @@ -242,7 +240,7 @@ export function getMlClient( const groups = calJobIds.filter((j) => allJobIds.includes(j) === false); // get list of calendar jobs which are allowed in this space - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace( + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace( 'anomaly-detector', calJobIds ); @@ -271,7 +269,7 @@ export function getMlClient( const meta = options.meta ?? false; const response = await mlClient.getDataFrameAnalytics(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'data-frame-analytics', // @ts-expect-error @elastic-elasticsearch Data frame types incomplete response.body.data_frame_analytics, @@ -303,7 +301,7 @@ export function getMlClient( })) as unknown as { body: { data_frame_analytics: DataFrameAnalyticsConfig[] }; }; - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'data-frame-analytics', response.body.data_frame_analytics, 'id' @@ -328,7 +326,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getDatafeedStats(params, { ...options, meta: true }); - const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( + const datafeeds = await mlSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', response.body.datafeeds, 'datafeed_id' @@ -353,7 +351,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getDatafeeds(params, { ...options, meta: true }); - const datafeeds = await jobSavedObjectService.filterDatafeedsForSpace( + const datafeeds = await mlSavedObjectService.filterDatafeedsForSpace( 'anomaly-detector', response.body.datafeeds, 'datafeed_id' @@ -384,7 +382,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getJobStats(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', response.body.jobs, 'job_id' @@ -415,7 +413,7 @@ export function getMlClient( const [params, options = {}] = p; const meta = options.meta ?? false; const response = await mlClient.getJobs(params, { ...options, meta: true }); - const jobs = await jobSavedObjectService.filterJobsForSpace( + const jobs = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', response.body.jobs, 'job_id' @@ -459,7 +457,7 @@ export function getMlClient( try { const body = await mlClient.getTrainedModels(...p); const models = - await jobSavedObjectService.filterTrainedModelsForSpace( + await mlSavedObjectService.filterTrainedModelsForSpace( body.trained_model_configs, 'model_id' ); @@ -476,7 +474,7 @@ export function getMlClient( try { const body = await mlClient.getTrainedModelsStats(...p); const models = - await jobSavedObjectService.filterTrainedModelsForSpace( + await mlSavedObjectService.filterTrainedModelsForSpace( body.trained_model_stats, 'model_id' ); @@ -524,7 +522,7 @@ export function getMlClient( const resp = await mlClient.putDataFrameAnalytics(...p); const [analyticsId] = getDFAJobIdsFromRequest(p); if (analyticsId !== undefined) { - await jobSavedObjectService.createDataFrameAnalyticsJob(analyticsId); + await mlSavedObjectService.createDataFrameAnalyticsJob(analyticsId); } return resp; }, @@ -533,7 +531,7 @@ export function getMlClient( const [datafeedId] = getDatafeedIdsFromRequest(p); const jobId = getJobIdFromBody(p); if (datafeedId !== undefined && jobId !== undefined) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); } return resp; @@ -545,7 +543,7 @@ export function getMlClient( const resp = await mlClient.putJob(...p); const [jobId] = getADJobIdsFromRequest(p); if (jobId !== undefined) { - await jobSavedObjectService.createAnomalyDetectionJob(jobId); + await mlSavedObjectService.createAnomalyDetectionJob(jobId); } return resp; }, @@ -555,7 +553,7 @@ export function getMlClient( if (modelId !== undefined) { const model = (p[0] as estypes.MlPutTrainedModelRequest).body; const job = getJobDetailsFromTrainedModel(model); - await jobSavedObjectService.createTrainedModel(modelId, job); + await mlSavedObjectService.createTrainedModel(modelId, job); } return resp; }, @@ -640,7 +638,7 @@ export function getMlClient( return mlClient.getMemoryStats(...p); }, - ...searchProvider(client, jobSavedObjectService), + ...searchProvider(client, mlSavedObjectService), } as MlClient; } diff --git a/x-pack/plugins/ml/server/lib/ml_client/search.ts b/x-pack/plugins/ml/server/lib/ml_client/search.ts index 6ba75478d59884..1ead8d0fd4ea1c 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/search.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/search.ts @@ -15,17 +15,17 @@ import type { TransportRequestOptionsWithOutMeta, } from '@elastic/elasticsearch'; -import { JobSavedObjectService } from '../../saved_objects'; +import { MLSavedObjectService } from '../../saved_objects'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import type { JobType } from '../../../common/types/saved_objects'; export function searchProvider( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { async function jobIdsCheck(jobType: JobType, jobIds: string[]) { if (jobIds.length) { - const filteredJobIds = await jobSavedObjectService.filterJobIdsForSpace(jobType, jobIds); + const filteredJobIds = await mlSavedObjectService.filterJobIdsForSpace(jobType, jobIds); const missingIds = jobIds.filter((j) => filteredJobIds.indexOf(j) === -1); if (missingIds.length) { throw Boom.notFound(`${missingIds.join(',')} missing`); diff --git a/x-pack/plugins/ml/server/lib/route_guard.ts b/x-pack/plugins/ml/server/lib/route_guard.ts index 0b445eeeae396f..90de46340d873c 100644 --- a/x-pack/plugins/ml/server/lib/route_guard.ts +++ b/x-pack/plugins/ml/server/lib/route_guard.ts @@ -16,7 +16,7 @@ import type { import type { SpacesPluginSetup } from '../../../spaces/server'; import type { SecurityPluginSetup } from '../../../security/server'; -import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; +import { mlSavedObjectServiceFactory, MLSavedObjectService } from '../saved_objects'; import type { MlLicense } from '../../common/license'; import { MlClient, getMlClient } from '../lib/ml_client'; @@ -34,7 +34,7 @@ type Handler

= (handlerParams: { request: KibanaRequest; response: KibanaResponseFactory; context: MLRequestHandlerContext; - jobSavedObjectService: JobSavedObjectService; + mlSavedObjectService: MLSavedObjectService; mlClient: MlClient; getDataViewsService(): Promise; }) => ReturnType>; @@ -96,7 +96,7 @@ export class RouteGuard { }); } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( mlSavedObjectClient, internalSavedObjectsClient, this._spacesPlugin !== undefined, @@ -110,8 +110,8 @@ export class RouteGuard { request, response, context, - jobSavedObjectService, - mlClient: getMlClient(client, jobSavedObjectService), + mlSavedObjectService, + mlClient: getMlClient(client, mlSavedObjectService), getDataViewsService: getDataViewsServiceFactory( this._getDataViews, context.core.savedObjects.client, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts index d4c3648d053255..7d2f37cefb50b7 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts @@ -14,7 +14,7 @@ import type { DataViewsService } from '../../../../../../src/plugins/data_views/ import type { Module } from '../../../common/types/modules'; import { DataRecognizer } from '../data_recognizer'; import type { MlClient } from '../../lib/ml_client'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; const callAs = () => Promise.resolve({ body: {} }); @@ -34,7 +34,7 @@ describe('ML - data recognizer', () => { bulkCreate: jest.fn(), } as unknown as SavedObjectsClientContract, { find: jest.fn() } as unknown as DataViewsService, - {} as JobSavedObjectService, + {} as MLSavedObjectService, { headers: { authorization: '' } } as unknown as KibanaRequest ); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 7498d2372f7d74..3ef36f4ad3b6bf 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -52,7 +52,7 @@ import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; import type { JobExistResult, JobStat } from '../../../common/types/data_recognizer'; import type { Datafeed } from '../../../common/types/anomaly_detection_jobs'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; import { isDefined } from '../../../common/types/guards'; import { isPopulatedObject } from '../../../common/util/object_utils'; @@ -111,7 +111,7 @@ export class DataRecognizer { private _client: IScopedClusterClient; private _mlClient: MlClient; private _savedObjectsClient: SavedObjectsClientContract; - private _jobSavedObjectService: JobSavedObjectService; + private _mlSavedObjectService: MLSavedObjectService; private _dataViewsService: DataViewsService; private _request: KibanaRequest; @@ -145,14 +145,14 @@ export class DataRecognizer { mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest ) { this._client = mlClusterClient; this._mlClient = mlClient; this._savedObjectsClient = savedObjectsClient; this._dataViewsService = dataViewsService; - this._jobSavedObjectService = jobSavedObjectService; + this._mlSavedObjectService = mlSavedObjectService; this._request = request; this._authorizationHeader = getAuthorizationHeader(request); this._jobsService = jobServiceProvider(mlClusterClient, mlClient); @@ -782,12 +782,12 @@ export class DataRecognizer { }) ); if (applyToAllSpaces === true) { - const canCreateGlobalJobs = await this._jobSavedObjectService.canCreateGlobalMlSavedObjects( + const canCreateGlobalJobs = await this._mlSavedObjectService.canCreateGlobalMlSavedObjects( 'anomaly-detector', this._request ); if (canCreateGlobalJobs === true) { - await this._jobSavedObjectService.updateJobsSpaces( + await this._mlSavedObjectService.updateJobsSpaces( 'anomaly-detector', jobs.map((j) => j.id), ['*'], // spacesToAdd @@ -1399,7 +1399,7 @@ export function dataRecognizerFactory( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest ) { return new DataRecognizer( @@ -1407,7 +1407,7 @@ export function dataRecognizerFactory( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); } diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index 79b54b9a635a83..cab1d427430172 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -11,7 +11,7 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/type import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ML_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { MESSAGE_LEVEL } from '../../../common/constants/message_levels'; -import type { JobSavedObjectService } from '../../saved_objects'; +import type { MLSavedObjectService } from '../../saved_objects'; import type { MlClient } from '../../lib/ml_client'; import type { JobMessage } from '../../../common/types/audit_message'; import { AuditMessage } from '../../../common/types/anomaly_detection_jobs'; @@ -66,7 +66,7 @@ export function jobAuditMessagesProvider( // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d async function getJobAuditMessages( - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, { jobId, from, @@ -174,7 +174,7 @@ export function jobAuditMessagesProvider( messages.push(hit._source!); }); } - messages = await jobSavedObjectService.filterJobsForSpace( + messages = await mlSavedObjectService.filterJobsForSpace( 'anomaly-detector', messages, 'job_id' diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index d28effae5ca2bd..360e2bef4c99e2 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -40,12 +40,12 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { getJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { jobId } = request.params; const { from, start, end } = request.query; - const resp = await getJobAuditMessages(jobSavedObjectService, { + const resp = await getJobAuditMessages(mlSavedObjectService, { jobId, from, start, @@ -82,11 +82,11 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { getJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { from } = request.query; - const resp = await getJobAuditMessages(jobSavedObjectService, { from }); + const resp = await getJobAuditMessages(mlSavedObjectService, { from }); return response.ok({ body: resp, @@ -118,7 +118,7 @@ export function jobAuditMessagesRoutes({ router, routeGuard }: RouteInitializati }, }, routeGuard.fullLicenseAPIGuard( - async ({ client, mlClient, request, response, jobSavedObjectService }) => { + async ({ client, mlClient, request, response, mlSavedObjectService }) => { try { const { clearJobAuditMessages } = jobAuditMessagesProvider(client, mlClient); const { jobId, notificationIndices } = request.body; diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index d814e91f70ca01..2b366cef9a9f45 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -24,14 +24,14 @@ import { } from './schemas/modules'; import type { RouteInitialization } from '../types'; import type { MlClient } from '../lib/ml_client'; -import type { JobSavedObjectService } from '../saved_objects'; +import type { MLSavedObjectService } from '../saved_objects'; function recognize( client: IScopedClusterClient, mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, indexPatternTitle: string ) { @@ -40,7 +40,7 @@ function recognize( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.findMatches(indexPatternTitle); @@ -51,7 +51,7 @@ function getModule( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId?: string ) { @@ -60,7 +60,7 @@ function getModule( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); if (moduleId === undefined) { @@ -75,7 +75,7 @@ function setup( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId: string, prefix?: string, @@ -96,7 +96,7 @@ function setup( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.setup( @@ -121,7 +121,7 @@ function dataRecognizerJobsExist( mlClient: MlClient, savedObjectsClient: SavedObjectsClientContract, dataViewsService: DataViewsService, - jobSavedObjectService: JobSavedObjectService, + mlSavedObjectService: MLSavedObjectService, request: KibanaRequest, moduleId: string ) { @@ -130,7 +130,7 @@ function dataRecognizerJobsExist( mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.dataRecognizerJobsExist(moduleId); @@ -185,7 +185,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -196,7 +196,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, indexPatternTitle ); @@ -334,7 +334,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -350,7 +350,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId ); @@ -521,7 +521,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -549,7 +549,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId, prefix, @@ -643,7 +643,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { request, response, context, - jobSavedObjectService, + mlSavedObjectService, getDataViewsService, }) => { try { @@ -654,7 +654,7 @@ export function dataRecognizer({ router, routeGuard }: RouteInitialization) { mlClient, context.core.savedObjects.client, dataViewService, - jobSavedObjectService, + mlSavedObjectService, request, moduleId ); diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index faddb55d2a46b6..eecd6f7f9ba5f7 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -18,7 +18,7 @@ import { itemTypeSchema, } from './schemas/saved_objects'; import { spacesUtilsProvider } from '../lib/spaces_utils'; -import type { JobType, TrainedModelType } from '../../common/types/saved_objects'; +import type { MlSavedObjectType } from '../../common/types/saved_objects'; /** * Routes for job saved object management @@ -43,9 +43,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetJobs', 'access:ml:canGetTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, response, mlSavedObjectService }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const status = await checkStatus(); return response.ok({ @@ -82,10 +82,10 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { const { simulate } = request.query; - const { syncSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { syncSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const savedObjects = await syncSavedObjects(simulate); return response.ok({ @@ -119,10 +119,10 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { const { simulate } = request.query; - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const savedObjects = await initSavedObjects(simulate); return response.ok({ @@ -156,11 +156,11 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ client, request, response, mlSavedObjectService }) => { try { - const { jobType } = request.body; - const { isSyncNeeded } = syncSavedObjectsFactory(client, jobSavedObjectService); - const result = await isSyncNeeded(jobType as JobType | TrainedModelType); + const { mlSavedObjectType } = request.body; + const { isSyncNeeded } = syncSavedObjectsFactory(client, mlSavedObjectService); + const result = await isSyncNeeded(mlSavedObjectType as MlSavedObjectType); return response.ok({ body: { result }, @@ -190,11 +190,11 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { const { jobType, jobIds, spacesToAdd, spacesToRemove } = request.body; - const body = await jobSavedObjectService.updateJobsSpaces( + const body = await mlSavedObjectService.updateJobsSpaces( jobType, jobIds, spacesToAdd, @@ -229,11 +229,11 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { const { modelIds, spacesToAdd, spacesToRemove } = request.body; - const body = await jobSavedObjectService.updateTrainedModelsSpaces( + const body = await mlSavedObjectService.updateTrainedModelsSpaces( modelIds, spacesToAdd, spacesToRemove @@ -267,9 +267,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => { try { - const { jobType, ids } = request.body; + const { mlSavedObjectType, ids } = request.body; const { getCurrentSpaceId } = spacesUtilsProvider(getSpaces, request); const currentSpaceId = await getCurrentSpaceId(); @@ -284,8 +284,8 @@ export function savedObjectsRoutes( }); } - if (jobType === 'trained-model') { - const body = await jobSavedObjectService.updateTrainedModelsSpaces( + if (mlSavedObjectType === 'trained-model') { + const body = await mlSavedObjectService.updateTrainedModelsSpaces( ids, [], // spacesToAdd [currentSpaceId] // spacesToRemove @@ -296,8 +296,8 @@ export function savedObjectsRoutes( }); } - const body = await jobSavedObjectService.updateJobsSpaces( - jobType, + const body = await mlSavedObjectService.updateJobsSpaces( + mlSavedObjectType, ids, [], // spacesToAdd [currentSpaceId] // spacesToRemove @@ -328,9 +328,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ response, mlSavedObjectService, client }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const savedObjects = (await checkStatus()).savedObjects; const jobStatus = ( Object.entries(savedObjects) @@ -373,9 +373,9 @@ export function savedObjectsRoutes( tags: ['access:ml:canGetTrainedModels'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ response, mlSavedObjectService, client }) => { try { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); const savedObjects = (await checkStatus()).savedObjects; const modelStatus = savedObjects['trained-model'] .filter((s) => s.checks.trainedModelExists) @@ -433,12 +433,12 @@ export function savedObjectsRoutes( ], }, }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => { + routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService, client }) => { try { const { jobType } = request.params; const { ids } = request.body; - const { canDeleteMLSpaceAwareItems } = checksFactory(client, jobSavedObjectService); + const { canDeleteMLSpaceAwareItems } = checksFactory(client, mlSavedObjectService); const body = await canDeleteMLSpaceAwareItems( request, jobType, diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index 4a90088ab8ae09..ef5c81a08a5163 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -34,13 +34,13 @@ export const updateTrainedModelsSpaces = schema.object({ }); export const itemsAndCurrentSpace = schema.object({ - jobType: itemTypeLiterals, + mlSavedObjectType: itemTypeLiterals, ids: schema.arrayOf(schema.string()), }); export const syncJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); -export const syncCheckSchema = schema.object({ jobType: schema.maybe(schema.string()) }); +export const syncCheckSchema = schema.object({ mlSavedObjectType: schema.maybe(schema.string()) }); export const canDeleteMLSpaceAwareItemsSchema = schema.object({ /** List of job or trained model IDs. */ diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index e23dd7bee74262..c6abcb79030de9 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { IScopedClusterClient, KibanaRequest, SavedObjectsFindResult } from 'kibana/server'; import type { - JobSavedObjectService, + MLSavedObjectService, TrainedModelJob, JobObject, TrainedModelObject, @@ -17,7 +17,7 @@ import type { import type { JobType, DeleteMLSpaceAwareItemsCheckResponse, - TrainedModelType, + MlSavedObjectType, } from '../../common/types/saved_objects'; import type { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; @@ -76,7 +76,7 @@ export interface StatusResponse { export function checksFactory( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { async function checkStatus(): Promise { const [ @@ -89,10 +89,10 @@ export function checksFactory( dfaJobs, models, ] = await Promise.all([ - jobSavedObjectService.getAllJobObjects(undefined, false), - jobSavedObjectService.getAllJobObjectsForAllSpaces(), - jobSavedObjectService.getAllTrainedModelObjects(false), - jobSavedObjectService.getAllTrainedModelObjectsForAllSpaces(), + mlSavedObjectService.getAllJobObjects(undefined, false), + mlSavedObjectService.getAllJobObjectsForAllSpaces(), + mlSavedObjectService.getAllTrainedModelObjects(false), + mlSavedObjectService.getAllTrainedModelObjectsForAllSpaces(), client.asInternalUser.ml.getJobs(), client.asInternalUser.ml.getDatafeeds(), client.asInternalUser.ml.getDataFrameAnalytics() as unknown as { @@ -274,12 +274,15 @@ export function checksFactory( async function canDeleteMLSpaceAwareItems( request: KibanaRequest, - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, ids: string[], spacesEnabled: boolean, resolveMlCapabilities: ResolveMlCapabilities ): Promise { - if (['anomaly-detector', 'data-frame-analytics', 'trained-model'].includes(jobType) === false) { + if ( + ['anomaly-detector', 'data-frame-analytics', 'trained-model'].includes(mlSavedObjectType) === + false + ) { throw Boom.badRequest( 'Saved object type must be "anomaly-detector", "data-frame-analytics" or "trained-model' ); @@ -291,8 +294,9 @@ export function checksFactory( } if ( - (jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || - (jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false) + (mlSavedObjectType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || + (mlSavedObjectType === 'data-frame-analytics' && + mlCapabilities.canDeleteDataFrameAnalytics === false) ) { // user does not have access to delete jobs. return ids.reduce((results, id) => { @@ -302,7 +306,10 @@ export function checksFactory( }; return results; }, {} as DeleteMLSpaceAwareItemsCheckResponse); - } else if (jobType === 'trained-model' && mlCapabilities.canDeleteTrainedModels === false) { + } else if ( + mlSavedObjectType === 'trained-model' && + mlCapabilities.canDeleteTrainedModels === false + ) { // user does not have access to delete trained models. return ids.reduce((results, id) => { results[id] = { @@ -323,19 +330,21 @@ export function checksFactory( return results; }, {} as DeleteMLSpaceAwareItemsCheckResponse); } - const canCreateGlobalMlSavedObjects = await jobSavedObjectService.canCreateGlobalMlSavedObjects( - jobType, + const canCreateGlobalMlSavedObjects = await mlSavedObjectService.canCreateGlobalMlSavedObjects( + mlSavedObjectType, request ); const savedObjects = - jobType === 'trained-model' - ? await Promise.all(ids.map((id) => jobSavedObjectService.getTrainedModelObject(id))) - : await Promise.all(ids.map((id) => jobSavedObjectService.getJobObject(jobType, id))); + mlSavedObjectType === 'trained-model' + ? await Promise.all(ids.map((id) => mlSavedObjectService.getTrainedModelObject(id))) + : await Promise.all( + ids.map((id) => mlSavedObjectService.getJobObject(mlSavedObjectType, id)) + ); return ids.reduce((results, id) => { const savedObject = - jobType === 'trained-model' + mlSavedObjectType === 'trained-model' ? (savedObjects as Array | undefined>).find( (j) => j?.attributes.model_id === id ) diff --git a/x-pack/plugins/ml/server/saved_objects/index.ts b/x-pack/plugins/ml/server/saved_objects/index.ts index 7537c7ed01dccf..1a21a23c63b0b1 100644 --- a/x-pack/plugins/ml/server/saved_objects/index.ts +++ b/x-pack/plugins/ml/server/saved_objects/index.ts @@ -6,8 +6,8 @@ */ export { setupSavedObjects } from './saved_objects'; -export type { JobObject, JobSavedObjectService } from './service'; -export { jobSavedObjectServiceFactory } from './service'; +export type { JobObject, MLSavedObjectService } from './service'; +export { mlSavedObjectServiceFactory } from './service'; export { checksFactory } from './checks'; export type { JobSavedObjectStatus } from './checks'; export { syncSavedObjectsFactory } from './sync'; diff --git a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts index 1764c59f7e4468..5e453a613b22d1 100644 --- a/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts +++ b/x-pack/plugins/ml/server/saved_objects/initialization/initialization.ts @@ -8,7 +8,7 @@ import { IScopedClusterClient, CoreStart, SavedObjectsClientContract } from 'kibana/server'; import { savedObjectClientsFactory } from '../util'; import { syncSavedObjectsFactory } from '../sync'; -import { jobSavedObjectServiceFactory, JobObject } from '../service'; +import { mlSavedObjectServiceFactory, JobObject } from '../service'; import { mlLog } from '../../lib/log'; import { ML_JOB_SAVED_OBJECT_TYPE } from '../../../common/types/saved_objects'; import { createJobSpaceOverrides } from './space_overrides'; @@ -47,7 +47,7 @@ export function jobSavedObjectsInitializationFactory( return; } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( savedObjectsClient, savedObjectsClient, spacesEnabled, @@ -60,7 +60,7 @@ export function jobSavedObjectsInitializationFactory( // create space overrides for specific jobs const jobSpaceOverrides = await createJobSpaceOverrides(client); // initialize jobs - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const { jobs, trainedModels } = await initSavedObjects(false, jobSpaceOverrides); mlLog.info(`${jobs.length + trainedModels.length} ML saved objects initialized`); } catch (error) { diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index af2aef1b0aa147..eb3fbee91308d9 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -18,6 +18,7 @@ import type { JobType, TrainedModelType, SavedObjectResult, + MlSavedObjectType, } from '../../common/types/saved_objects'; import { ML_JOB_SAVED_OBJECT_TYPE, @@ -46,9 +47,9 @@ export interface TrainedModelJob { type TrainedModelObjectFilter = { [k in keyof TrainedModelObject]?: string }; -export type JobSavedObjectService = ReturnType; +export type MLSavedObjectService = ReturnType; -export function jobSavedObjectServiceFactory( +export function mlSavedObjectServiceFactory( savedObjectsClient: SavedObjectsClientContract, internalSavedObjectsClient: SavedObjectsClientContract, spacesEnabled: boolean, @@ -397,7 +398,7 @@ export function jobSavedObjectServiceFactory( } async function canCreateGlobalMlSavedObjects( - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, request: KibanaRequest ) { if (authorization === undefined) { @@ -407,7 +408,9 @@ export function jobSavedObjectServiceFactory( const { canCreateJobsGlobally, canCreateTrainedModelsGlobally } = await authorizationCheck( request ); - return jobType === 'trained-model' ? canCreateTrainedModelsGlobally : canCreateJobsGlobally; + return mlSavedObjectType === 'trained-model' + ? canCreateTrainedModelsGlobally + : canCreateJobsGlobally; } async function getTrainedModelObject( diff --git a/x-pack/plugins/ml/server/saved_objects/sync.ts b/x-pack/plugins/ml/server/saved_objects/sync.ts index 63f62d264a63d2..b4573e8b7c8aa2 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync.ts @@ -7,12 +7,12 @@ import Boom from '@hapi/boom'; import type { IScopedClusterClient } from 'kibana/server'; -import type { JobObject, JobSavedObjectService, TrainedModelObject } from './service'; +import type { JobObject, MLSavedObjectService, TrainedModelObject } from './service'; import type { JobType, - TrainedModelType, SyncSavedObjectResponse, InitializeSavedObjectResponse, + MlSavedObjectType, } from '../../common/types/saved_objects'; import { checksFactory } from './checks'; import type { JobStatus } from './checks'; @@ -26,9 +26,9 @@ export interface JobSpaceOverrides { export function syncSavedObjectsFactory( client: IScopedClusterClient, - jobSavedObjectService: JobSavedObjectService + mlSavedObjectService: MLSavedObjectService ) { - const { checkStatus } = checksFactory(client, jobSavedObjectService); + const { checkStatus } = checksFactory(client, mlSavedObjectService); async function syncSavedObjects(simulate: boolean = false) { const results: SyncSavedObjectResponse = { @@ -65,7 +65,7 @@ export function syncSavedObjectsFactory( const datafeedId = job.datafeedId; tasks.push(async () => { try { - await jobSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); + await mlSavedObjectService.createAnomalyDetectionJob(jobId, datafeedId ?? undefined); results.savedObjectsCreated[type]![job.jobId] = { success: true }; } catch (error) { results.savedObjectsCreated[type]![job.jobId] = { @@ -91,7 +91,7 @@ export function syncSavedObjectsFactory( const jobId = job.jobId; tasks.push(async () => { try { - await jobSavedObjectService.createDataFrameAnalyticsJob(jobId); + await mlSavedObjectService.createDataFrameAnalyticsJob(jobId); results.savedObjectsCreated[type]![job.jobId] = { success: true, }; @@ -129,7 +129,7 @@ export function syncSavedObjectsFactory( return; } const job = getJobDetailsFromTrainedModel(mod); - await jobSavedObjectService.createTrainedModel(modelId, job); + await mlSavedObjectService.createTrainedModel(modelId, job); results.savedObjectsCreated[type]![modelId] = { success: true, }; @@ -159,9 +159,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteAnomalyDetectionJob(jobId, namespaces[0]); + await mlSavedObjectService.forceDeleteAnomalyDetectionJob(jobId, namespaces[0]); } else { - await jobSavedObjectService.deleteAnomalyDetectionJob(jobId); + await mlSavedObjectService.deleteAnomalyDetectionJob(jobId); } results.savedObjectsDeleted[type]![job.jobId] = { success: true }; } catch (error) { @@ -189,9 +189,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteDataFrameAnalyticsJob(jobId, namespaces[0]); + await mlSavedObjectService.forceDeleteDataFrameAnalyticsJob(jobId, namespaces[0]); } else { - await jobSavedObjectService.deleteDataFrameAnalyticsJob(jobId); + await mlSavedObjectService.deleteDataFrameAnalyticsJob(jobId); } results.savedObjectsDeleted[type]![job.jobId] = { success: true, @@ -223,9 +223,9 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (namespaces !== undefined && namespaces.length) { - await jobSavedObjectService.forceDeleteTrainedModel(modelId, namespaces[0]); + await mlSavedObjectService.forceDeleteTrainedModel(modelId, namespaces[0]); } else { - await jobSavedObjectService.deleteTrainedModel(modelId); + await mlSavedObjectService.deleteTrainedModel(modelId); } results.savedObjectsDeleted[type]![modelId] = { success: true, @@ -266,7 +266,7 @@ export function syncSavedObjectsFactory( tasks.push(async () => { try { if (datafeedId !== undefined) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); } results.datafeedsAdded[type]![job.jobId] = { success: true }; } catch (error) { @@ -294,7 +294,7 @@ export function syncSavedObjectsFactory( const datafeedId = job.datafeedId; tasks.push(async () => { try { - await jobSavedObjectService.deleteDatafeed(datafeedId); + await mlSavedObjectService.deleteDatafeed(datafeedId); results.datafeedsRemoved[type]![job.jobId] = { success: true }; } catch (error) { results.datafeedsRemoved[type]![job.jobId] = { @@ -408,7 +408,7 @@ export function syncSavedObjectsFactory( try { // create missing job saved objects - const createJobsResults = await jobSavedObjectService.bulkCreateJobs(jobObjects); + const createJobsResults = await mlSavedObjectService.bulkCreateJobs(jobObjects); createJobsResults.saved_objects.forEach(({ attributes }) => { results.jobs.push({ id: attributes.job_id, @@ -418,7 +418,7 @@ export function syncSavedObjectsFactory( // create missing datafeed ids for (const { jobId, datafeedId } of datafeeds) { - await jobSavedObjectService.addDatafeed(datafeedId, jobId); + await mlSavedObjectService.addDatafeed(datafeedId, jobId); results.datafeeds.push({ id: datafeedId, type: 'anomaly-detector', @@ -426,7 +426,7 @@ export function syncSavedObjectsFactory( } // use * space if no spaces for related jobs can be found. - const createModelsResults = await jobSavedObjectService.bulkCreateTrainedModel( + const createModelsResults = await mlSavedObjectService.bulkCreateTrainedModel( modelObjects, '*' ); @@ -443,15 +443,17 @@ export function syncSavedObjectsFactory( return results; } - async function isSyncNeeded(jobType?: JobType | TrainedModelType) { + async function isSyncNeeded(mlSavedObjectType?: MlSavedObjectType) { const { jobs, datafeeds, trainedModels } = await initSavedObjects(true); const missingJobs = - jobs.length > 0 && (jobType === undefined || jobs.some(({ type }) => type === jobType)); + jobs.length > 0 && + (mlSavedObjectType === undefined || jobs.some(({ type }) => type === mlSavedObjectType)); const missingModels = - trainedModels.length > 0 && (jobType === undefined || jobType === 'trained-model'); + trainedModels.length > 0 && + (mlSavedObjectType === undefined || mlSavedObjectType === 'trained-model'); - const missingDatafeeds = datafeeds.length > 0 && jobType !== 'data-frame-analytics'; + const missingDatafeeds = datafeeds.length > 0 && mlSavedObjectType !== 'data-frame-analytics'; return missingJobs || missingModels || missingDatafeeds; } diff --git a/x-pack/plugins/ml/server/saved_objects/sync_task.ts b/x-pack/plugins/ml/server/saved_objects/sync_task.ts index eeb86cd11d0d56..ce54cc85abc680 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync_task.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync_task.ts @@ -14,7 +14,7 @@ import { } from '../../../task_manager/server'; import type { SecurityPluginSetup } from '../../../security/server'; import { savedObjectClientsFactory } from './util'; -import { jobSavedObjectServiceFactory } from './service'; +import { mlSavedObjectServiceFactory } from './service'; import { syncSavedObjectsFactory } from './sync'; const SAVED_OBJECTS_SYNC_TASK_TYPE = 'ML:saved-objects-sync'; @@ -67,7 +67,7 @@ export class SavedObjectsSyncService { throw new Error(error); } - const jobSavedObjectService = jobSavedObjectServiceFactory( + const mlSavedObjectService = mlSavedObjectServiceFactory( savedObjectsClient, savedObjectsClient, spacesEnabled, @@ -75,7 +75,7 @@ export class SavedObjectsSyncService { client, isMlReady ); - const { initSavedObjects } = syncSavedObjectsFactory(client, jobSavedObjectService); + const { initSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService); const { jobs, trainedModels } = await initSavedObjects(false); const count = jobs.length + trainedModels.length; diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index f6a6c58fadb4ef..10b07a90880deb 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -38,14 +38,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.findMatches(...args); @@ -55,14 +55,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.getModule(moduleId); @@ -72,14 +72,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.listModules(); @@ -89,14 +89,14 @@ export function getModulesProvider( return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canCreateJob']) - .ok(async ({ scopedClient, mlClient, jobSavedObjectService, getDataViewsService }) => { + .ok(async ({ scopedClient, mlClient, mlSavedObjectService, getDataViewsService }) => { const dataViewsService = await getDataViewsService(); const dr = dataRecognizerFactory( scopedClient, mlClient, savedObjectsClient, dataViewsService, - jobSavedObjectService, + mlSavedObjectService, request ); return dr.setup( diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 16dd3cf7bec97f..39ab8a43e32338 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -35,7 +35,7 @@ import { MLUISettingsClientUninitialized, } from './errors'; import { MlClient, getMlClient } from '../lib/ml_client'; -import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; +import { mlSavedObjectServiceFactory, MLSavedObjectService } from '../saved_objects'; import { getAlertingServiceProvider, MlAlertingServiceProvider, @@ -76,7 +76,7 @@ export interface SharedServicesChecks { interface OkParams { scopedClient: IScopedClusterClient; mlClient: MlClient; - jobSavedObjectService: JobSavedObjectService; + mlSavedObjectService: MLSavedObjectService; getFieldsFormatRegistry: FieldFormatsRegistryProvider; getDataViewsService: GetDataViewsService; } @@ -126,7 +126,7 @@ export function createSharedServices( hasMlCapabilities, scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, } = getRequestItems(request); @@ -150,7 +150,7 @@ export function createSharedServices( return callback({ scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, }); @@ -202,7 +202,7 @@ function getRequestItemsProvider( // instead a dummy request object will be supplied const clusterClient = getClusterClient(); const getSobSavedObjectService = (client: IScopedClusterClient) => { - return jobSavedObjectServiceFactory( + return mlSavedObjectServiceFactory( savedObjectsClient, internalSavedObjectsClient, spaceEnabled, @@ -238,12 +238,12 @@ function getRequestItemsProvider( return fieldFormatRegistry; }; - let jobSavedObjectService; + let mlSavedObjectService; if (request instanceof KibanaRequest) { hasMlCapabilities = getHasMlCapabilities(request); scopedClient = clusterClient.asScoped(request); - jobSavedObjectService = getSobSavedObjectService(scopedClient); - mlClient = getMlClient(scopedClient, jobSavedObjectService); + mlSavedObjectService = getSobSavedObjectService(scopedClient); + mlClient = getMlClient(scopedClient, mlSavedObjectService); } else { hasMlCapabilities = () => Promise.resolve(); const { asInternalUser } = clusterClient; @@ -251,8 +251,8 @@ function getRequestItemsProvider( asInternalUser, asCurrentUser: asInternalUser, }; - jobSavedObjectService = getSobSavedObjectService(scopedClient); - mlClient = getMlClient(scopedClient, jobSavedObjectService); + mlSavedObjectService = getSobSavedObjectService(scopedClient); + mlClient = getMlClient(scopedClient, mlSavedObjectService); } const getDataViewsService = getDataViewsServiceFactory( @@ -266,7 +266,7 @@ function getRequestItemsProvider( hasMlCapabilities, scopedClient, mlClient, - jobSavedObjectService, + mlSavedObjectService, getFieldsFormatRegistry, getDataViewsService, }; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/content.ts b/x-pack/plugins/observability/public/components/app/observability_status/content.ts new file mode 100644 index 00000000000000..084d28a5544725 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/content.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { HttpSetup, DocLinksStart } from 'kibana/public'; +import { ObservabilityFetchDataPlugins } from '../../../typings/fetch_overview_data'; + +export interface ObservabilityStatusContent { + id: ObservabilityFetchDataPlugins | 'alert'; + title: string; + description: string; + addTitle: string; + addLink: string; + learnMoreLink: string; + goToAppTitle: string; + goToAppLink: string; +} + +export const getContent = ( + http: HttpSetup, + docLinks: DocLinksStart +): ObservabilityStatusContent[] => { + return [ + { + id: 'infra_logs', + title: i18n.translate('xpack.observability.statusVisualization.logs.title', { + defaultMessage: 'Logs', + }), + description: i18n.translate('xpack.observability.statusVisualization.logs.description', { + defaultMessage: + 'Fast, easy, and scalable centralized log monitoring with out-of-the-box support for common data sources.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.logs.link', { + defaultMessage: 'Add integrations', + }), + addLink: http.basePath.prepend('/app/integrations/browse?q=logs'), + learnMoreLink: docLinks.links.observability.monitorLogs, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.logs.goToAppTitle', { + defaultMessage: 'Show log stream', + }), + goToAppLink: http.basePath.prepend('/app/logs/stream'), + }, + { + id: 'apm', + title: i18n.translate('xpack.observability.statusVisualization.apm.title', { + defaultMessage: 'APM', + }), + description: i18n.translate('xpack.observability.statusVisualization.apm.description', { + defaultMessage: + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.apm.link', { + defaultMessage: 'Add data', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/apm'), + learnMoreLink: docLinks.links.apm.overview, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.apm.goToAppTitle', { + defaultMessage: 'Show services inventory', + }), + goToAppLink: http.basePath.prepend('/app/apm/services'), + }, + { + id: 'infra_metrics', + title: i18n.translate('xpack.observability.statusVisualization.metrics.title', { + defaultMessage: 'Infrastructure', + }), + description: i18n.translate('xpack.observability.statusVisualization.metrics.description', { + defaultMessage: 'Stream, visualize, and analyze your infrastructure metrics.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.metrics.link', { + defaultMessage: 'Add integrations', + }), + addLink: http.basePath.prepend('/app/integrations/browse?q=metrics'), + learnMoreLink: docLinks.links.observability.analyzeMetrics, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.metrics.goToAppTitle', { + defaultMessage: 'Show inventory', + }), + goToAppLink: http.basePath.prepend('/app/metrics/inventory'), + }, + { + id: 'synthetics', + title: i18n.translate('xpack.observability.statusVisualization.uptime.title', { + defaultMessage: 'Uptime', + }), + description: i18n.translate('xpack.observability.statusVisualization.uptime.description', { + defaultMessage: 'Proactively monitor the availability and functionality of user journeys.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.uptime.link', { + defaultMessage: 'Add monitors', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), + learnMoreLink: docLinks.links.observability.monitorUptimeSynthetics, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.uptime.goToAppTitle', { + defaultMessage: 'Show monitors ', + }), + goToAppLink: http.basePath.prepend('/app/uptime'), + }, + { + id: 'ux', + title: i18n.translate('xpack.observability.statusVisualization.ux.title', { + defaultMessage: 'User Experience', + }), + description: i18n.translate('xpack.observability.statusVisualization.ux.description', { + defaultMessage: + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.ux.link', { + defaultMessage: 'Add data', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/apm'), + learnMoreLink: docLinks.links.observability.userExperience, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.ux.goToAppTitle', { + defaultMessage: 'Show dashboard', + }), + goToAppLink: http.basePath.prepend('/app/ux'), + }, + { + id: 'alert', + title: i18n.translate('xpack.observability.statusVisualization.alert.title', { + defaultMessage: 'Alerting', + }), + description: i18n.translate('xpack.observability.statusVisualization.alert.description', { + defaultMessage: + 'Detect complex conditions in Observability and trigger actions when those conditions are met.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.alert.link', { + defaultMessage: 'Create rules', + }), + addLink: http.basePath.prepend('/app/management/insightsAndAlerting/triggersActions/rules'), + learnMoreLink: docLinks.links.observability.createAlerts, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.alert.goToAppTitle', { + defaultMessage: 'Show alerts', + }), + goToAppLink: http.basePath.prepend('/app/observability/alerts'), + }, + ]; +}; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/index.tsx b/x-pack/plugins/observability/public/components/app/observability_status/index.tsx index 18760ea366b3cb..08e8b58d192530 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/index.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/index.tsx @@ -8,25 +8,21 @@ import React from 'react'; import { useHasData } from '../../../hooks/use_has_data'; import { ObservabilityStatusBoxes } from './observability_status_boxes'; -import { getEmptySections } from '../../../pages/overview/empty_section'; +import { getContent } from './content'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityAppServices } from '../../../application/types'; export function ObservabilityStatus() { - const { http } = useKibana().services; + const { http, docLinks } = useKibana().services; const { hasDataMap } = useHasData(); - const appEmptySections = getEmptySections({ http }); + const content = getContent(http, docLinks); - const boxes = appEmptySections.map((app) => { + const boxes = content.map((app) => { return { - id: app.id, - dataSourceName: app.title, + ...app, hasData: hasDataMap[app.id]?.hasData ?? false, - description: app.description, modules: [], - integrationLink: app.href ?? '', - learnMoreLink: app.href ?? '', }; }); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx index d5e2eb9a78d46c..c10ffa0500db6c 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx @@ -16,82 +16,80 @@ export default { const testBoxes = [ { - id: 'logs', - dataSourceName: 'Logs', - hasData: true, - description: 'This is the description for logs', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: false }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'infra_logs', + title: 'Logs', + description: + 'Fast, easy, and scalable, centralized log monitoring with out-of-the-box support for common data sources.', + addTitle: 'Add integrations', + addLink: '/app/integrations/browse?q=logs', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show log stream', + goToAppLink: '/app/logs/stream', + hasData: false, + modules: [], }, { - id: 'metrics', - dataSourceName: 'Metrics', - hasData: true, - description: 'This is the description for metrics', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: false }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'apm', + title: 'APM', + description: + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', + addTitle: 'Add data', + addLink: '/app/home#/tutorial/apm', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show services inventory', + goToAppLink: '/app/apm/services', + hasData: false, + modules: [], }, { - id: 'apm', - dataSourceName: 'APM', - hasData: true, - description: 'This is the description for apm', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'infra_metrics', + title: 'Infrastructure', + description: 'Stream, visualize, and analyze your infrastructure metrics.', + addTitle: 'Add integrations', + addLink: '/app/integrations/browse?q=metrics', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show inventory', + goToAppLink: '/app/metrics/inventory', + hasData: false, + modules: [], }, { - id: 'uptime', - dataSourceName: 'Uptime', + id: 'synthetics', + title: 'Uptime', + description: 'Proactively monitor the availability and functionality of user journeys.', + addTitle: 'Add monitors', + addLink: '/app/home#/tutorial/uptimeMonitors', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show monitors ', + goToAppLink: '/app/uptime', hasData: false, - description: 'This is the description for uptime', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + modules: [], }, { id: 'ux', - dataSourceName: 'User experience', - hasData: false, - description: 'This is the description for user experience', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + title: 'User Experience', + description: + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', + addTitle: 'Add data', + addLink: '/app/home#/tutorial/apm', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show dashboard', + goToAppLink: '/app/ux', + hasData: true, + modules: [], }, { - id: 'alerts', - dataSourceName: 'Alerts and rules', + id: 'alert', + title: 'Alerting', + description: + 'Detect complex conditions in Observability and trigger actions when those conditions are met.', + addTitle: 'Create rules', + addLink: '/app/management/insightsAndAlerting/triggersActions/rules', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show alerts', + goToAppLink: '/app/observability/alerts', hasData: true, - description: 'This is the description for alerts and rules', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + modules: [], }, ]; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx index 097b657f7936dc..7bc9cb60ad3497 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx @@ -15,12 +15,15 @@ describe('ObservabilityStatusBox', () => { beforeEach(() => { const props = { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: false, description: 'test description', modules: [], - integrationLink: 'testIntegrationUrl.com', + addTitle: 'logs add title', + addLink: 'http://example.com', learnMoreLink: 'learnMoreUrl.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }; render( @@ -35,8 +38,8 @@ describe('ObservabilityStatusBox', () => { }); it('should have a learn more button', () => { - const learnMoreLink = screen.getByRole('link') as HTMLAnchorElement; - expect(learnMoreLink.href).toContain('learnMoreUrl.com'); + const learnMoreLink = screen.getByText('Learn more') as HTMLElement; + expect(learnMoreLink.closest('a')?.href).toContain('learnMoreUrl.com'); }); }); @@ -44,7 +47,7 @@ describe('ObservabilityStatusBox', () => { beforeEach(() => { const props = { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: true, description: 'test description', modules: [ @@ -52,8 +55,11 @@ describe('ObservabilityStatusBox', () => { { name: 'module2', hasData: false }, { name: 'module3', hasData: true }, ], - integrationLink: 'addIntegrationUrl.com', - learnMoreLink: 'learnMoreUrl.com', + addTitle: 'logs add title', + addLink: 'addIntegrationUrl.com', + learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }; render( @@ -66,8 +72,8 @@ describe('ObservabilityStatusBox', () => { // it('should have a check icon', () => {}); it('should have the integration link', () => { - const addIntegrationLink = screen.getByRole('link') as HTMLAnchorElement; - expect(addIntegrationLink.href).toContain('addIntegrationUrl.com'); + const addIntegrationLink = screen.getByText('logs add title') as HTMLElement; + expect(addIntegrationLink.closest('a')?.href).toContain('addIntegrationUrl.com'); }); it('should have the list of modules', () => { diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx index 0363a98295d622..a819afab0bed5c 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx @@ -15,18 +15,22 @@ import { EuiPanel, EuiText, EuiTitle, + EuiLink, } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; export interface ObservabilityStatusBoxProps { id: string; - dataSourceName: string; + title: string; hasData: boolean; description: string; modules: Array<{ name: string; hasData: boolean }>; - integrationLink: string; + addTitle: string; + addLink: string; learnMoreLink: string; + goToAppTitle: string; + goToAppLink: string; } export function ObservabilityStatusBox(props: ObservabilityStatusBoxProps) { @@ -38,12 +42,15 @@ export function ObservabilityStatusBox(props: ObservabilityStatusBoxProps) { } export function CompletedStatusBox({ - dataSourceName, + title, modules, - integrationLink, + addLink, + addTitle, + goToAppTitle, + goToAppLink, }: ObservabilityStatusBoxProps) { return ( - +

@@ -54,20 +61,26 @@ export function CompletedStatusBox({ style={{ marginRight: 8 }} /> -

{dataSourceName}

+

{title}

- - + + {addTitle}
+ + + + + + {modules.map((module) => ( @@ -81,50 +94,62 @@ export function CompletedStatusBox({ ))} + + + + + {goToAppTitle} + + +
); } export function EmptyStatusBox({ - dataSourceName, + title, description, learnMoreLink, + addTitle, + addLink, }: ObservabilityStatusBoxProps) { return ( - +
-

{dataSourceName}

+

{title}

- - -
- {description} + {description} - - - + + + + {addTitle} + + + + - +
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx index 0e57dc28aa5dd7..9ad69b2ce64f8c 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx @@ -8,31 +8,42 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ObservabilityStatusBoxes } from './observability_status_boxes'; +import { I18nProvider } from '@kbn/i18n-react'; describe('ObservabilityStatusBoxes', () => { it('should render all boxes passed as prop', () => { const boxes = [ { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: true, description: 'This is the description for logs', modules: [], - integrationLink: 'http://example.com', + addTitle: 'logs add title', + addLink: 'http://example.com', learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }, { id: 'metrics', - dataSourceName: 'Metrics', + title: 'Metrics', hasData: true, description: 'This is the description for metrics', modules: [], - integrationLink: 'http://example.com', + addTitle: 'metrics add title', + addLink: 'http://example.com', learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }, ]; - render(); + render( + + + + ); expect(screen.getByText('Logs')).toBeInTheDocument(); expect(screen.getByText('Metrics')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index f7bec339fbe2a6..0827f7f8c768cc 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -6,21 +6,56 @@ */ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; -import { ObservabilityStatusBox, ObservabilityStatusBoxProps } from './observability_status_box'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + CompletedStatusBox, + EmptyStatusBox, + ObservabilityStatusBoxProps, +} from './observability_status_box'; export interface ObservabilityStatusProps { boxes: ObservabilityStatusBoxProps[]; } export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { + const hasDataBoxes = boxes.filter((box) => box.hasData); + const noHasDataBoxes = boxes.filter((box) => !box.hasData); + return ( -
- {boxes.map((box) => ( - <> - - - + + + +

+ +

+
+
+ {noHasDataBoxes.map((box) => ( + + + + ))} + + {noHasDataBoxes.length > 0 && } + + + +

+ +

+
+
+ {hasDataBoxes.map((box) => ( + + + ))} -
+ ); } diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 98fb24c671cc39..d2050f159fc25e 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -19,7 +19,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoLogging', description: i18n.translate('xpack.observability.emptySection.apps.logs.description', { defaultMessage: - 'Centralize logs from any source. Search, tail, automate anomaly detection, and visualize trends so you can take action quicker.', + 'Fast, easy, and scalable centralized log monitoring with out-of-the-box support for common data sources.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.logs.link', { defaultMessage: 'Install Filebeat', @@ -34,7 +34,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoObservability', description: i18n.translate('xpack.observability.emptySection.apps.apm.description', { defaultMessage: - 'Trace transactions through a distributed architecture and map your services’ interactions to easily spot performance bottlenecks.', + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.apm.link', { defaultMessage: 'Install Agent', @@ -48,8 +48,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { }), icon: 'logoMetrics', description: i18n.translate('xpack.observability.emptySection.apps.metrics.description', { - defaultMessage: - 'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.', + defaultMessage: 'Stream, visualize, and analyze your infrastructure metrics.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.metrics.link', { defaultMessage: 'Install Metricbeat', @@ -63,8 +62,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { }), icon: 'logoUptime', description: i18n.translate('xpack.observability.emptySection.apps.uptime.description', { - defaultMessage: - 'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users’ experience.', + defaultMessage: 'Proactively monitor the availability and functionality of user journeys.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.uptime.link', { defaultMessage: 'Install Heartbeat', @@ -79,7 +77,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoObservability', description: i18n.translate('xpack.observability.emptySection.apps.ux.description', { defaultMessage: - 'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.', + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', { defaultMessage: 'Install RUM Agent', @@ -94,7 +92,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'watchesApp', description: i18n.translate('xpack.observability.emptySection.apps.alert.description', { defaultMessage: - 'Are 503 errors stacking up? Are services responding? Is CPU and RAM utilization jumping? See warnings as they happen—not as part of the post-mortem.', + 'Detect complex conditions within Observability and trigger actions when those conditions are met.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', { defaultMessage: 'Create rule', diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 861f6900fda589..a3b4b76980cd3b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -87,6 +87,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", ] `); @@ -169,6 +170,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", @@ -211,6 +213,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -304,6 +307,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -357,6 +361,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -371,6 +376,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", ] `); @@ -456,6 +462,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -470,6 +477,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index f536959a910cd1..4d2cc97f75d895 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -16,7 +16,7 @@ enum AlertingEntity { } const readOperations: Record = { - rule: ['get', 'getRuleState', 'getAlertSummary', 'find'], + rule: ['get', 'getRuleState', 'getAlertSummary', 'getExecutionLog', 'find'], alert: ['get', 'find'], }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 5291f5a3f15a31..f10beb1c9c6ca9 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -41,6 +41,7 @@ import { Reason } from './reason'; import { InvestigationGuideView } from './investigation_guide_view'; import { Overview } from './overview'; import { HostRisk } from '../../../risk_score/containers'; +import { RelatedCases } from './related_cases'; type EventViewTab = EuiTabbedContentTab; @@ -170,6 +171,7 @@ const EventDetailsComponent: React.FC = ({ /> + { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useGetUserCasesPermissions: jest.fn(), + useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: { + api: { + getRelatedCases: mockGetRelatedCases, + }, + }, + }, + }), + }; +}); + +const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a'; + +describe('Related Cases', () => { + describe('When user does not have cases read permissions', () => { + test('should not show related cases when user does not have permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + read: false, + }); + act(() => { + render( + + + + ); + }); + + expect(screen.queryByText('cases')).toBeNull(); + }); + }); + describe('When user does have case read permissions', () => { + beforeEach(() => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + read: true, + }); + }); + + describe('When related cases are unable to be retrieved', () => { + test('should show 0 related cases when there are none', async () => { + mockGetRelatedCases.mockReturnValue([]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('0 cases.')).toBeInTheDocument(); + }); + }); + + describe('When 1 related case is retrieved', () => { + test('should show 1 related case', async () => { + mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('1 case:')).toBeInTheDocument(); + expect(await screen.findByTestId('case-details-link')).toHaveTextContent('Test Case'); + }); + }); + + describe('When 2 related cases are retrieved', () => { + test('should show 2 related cases', async () => { + mockGetRelatedCases.mockReturnValue([ + { id: '789', title: 'Test Case 1' }, + { id: '456', title: 'Test Case 2' }, + ]); + act(() => { + render( + + + + ); + }); + + expect(await screen.findByText('2 cases:')).toBeInTheDocument(); + const cases = await screen.findAllByTestId('case-details-link'); + expect(cases).toHaveLength(2); + expect(cases[0]).toHaveTextContent('Test Case 1'); + expect(cases[1]).toHaveTextContent('Test Case 2'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx new file mode 100644 index 00000000000000..8cbf62bbf86235 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState, useEffect } from 'react'; +import { EuiFlexItem, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useGetUserCasesPermissions, useKibana, useToasts } from '../../lib/kibana'; +import { CaseDetailsLink } from '../links'; +import { APP_ID } from '../../../../common/constants'; + +interface Props { + eventId: string; +} + +type RelatedCaseList = Array<{ id: string; title: string }>; + +export const RelatedCases: React.FC = React.memo(({ eventId }) => { + const { + services: { cases }, + } = useKibana(); + const toasts = useToasts(); + const casePermissions = useGetUserCasesPermissions(); + const [relatedCases, setRelatedCases] = useState([]); + const [areCasesLoading, setAreCasesLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const hasCasesReadPermissions = casePermissions?.read ?? false; + + const getRelatedCases = useCallback(async () => { + let relatedCaseList: RelatedCaseList = []; + try { + if (eventId) { + relatedCaseList = + (await cases.api.getRelatedCases(eventId, { + owner: APP_ID, + })) ?? []; + } + } catch (error) { + setHasError(true); + toasts.addWarning( + i18n.translate('xpack.securitySolution.alertDetails.overview.relatedCasesFailure', { + defaultMessage: 'Unable to load related cases: "{error}"', + values: { error }, + }) + ); + } + setRelatedCases(relatedCaseList); + setAreCasesLoading(false); + }, [eventId, cases.api, toasts]); + + useEffect(() => { + getRelatedCases(); + }, [eventId, getRelatedCases]); + + if (hasError || !hasCasesReadPermissions) return null; + + return areCasesLoading ? ( + + ) : ( + <> + + + + {' '} + + + + {relatedCases?.map(({ id, title }, index) => + id && title ? ( + + {' '} + + {title} + + {relatedCases[index + 1] ? ',' : ''} + + ) : ( + <> + ) + )} + + + + ); +}); + +RelatedCases.displayName = 'RelatedCases'; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx index f4707272d2c244..05b24600ff9af7 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { cloneDeep } from 'lodash'; + import { initialSourcererState, SourcererScopeName } from '../../store/sourcerer/model'; import { Sourcerer } from './index'; import { sourcererActions, sourcererModel } from '../../store/sourcerer'; @@ -22,6 +24,7 @@ import { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_sel import { waitFor } from '@testing-library/dom'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'; +import { TimelineId, TimelineType } from '../../../../common/types'; const mockDispatch = jest.fn(); @@ -989,6 +992,16 @@ describe('Update available', () => { expect(wrapper.find(`UpdateDefaultDataViewModal`).prop('isShowing')).toEqual(true); }); + test('Show UpdateDefaultDataViewModal Callout', () => { + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline uses a legacy data view selector' + ); + }); + test('Show Add index pattern in UpdateDefaultDataViewModal', () => { wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); @@ -1017,3 +1030,176 @@ describe('Update available', () => { ); }); }); + +describe('Update available for timeline template', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: null, + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + let wrapper: ReactWrapper; + + beforeEach(() => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut', () => { + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline template uses a legacy data view selector' + ); + }); +}); + +describe('Missing index patterns', () => { + const { storage } = createSecuritySolutionStorageMock(); + const state2 = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + timelineType: TimelineType.template, + }, + }, + }, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'auditbeat-*', + patternList: ['auditbeat-*'], + }, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '12347', + title: 'packetbeat-*', + patternList: ['packetbeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + loading: false, + patternList, + selectedDataViewId: 'fake-data-view-id', + selectedPatterns: ['myFakebeat-*'], + missingPatterns: ['myFakebeat-*'], + }, + }, + }, + }; + + let wrapper: ReactWrapper; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + const state3 = cloneDeep(state2); + state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default; + store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline is out of date with the Security Data View' + ); + }); + + test('Show UpdateDefaultDataViewModal CallOut for timeline template', () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + ...sourcererDataView, + activePatterns: ['myFakebeat-*'], + }); + store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + + wrapper.find(`button[data-test-subj="sourcerer-deprecated-update"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="sourcerer-deprecated-callout"]`).first().text()).toEqual( + 'This timeline template is out of date with the Security Data View' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx index ec55b654b9fcc8..156d08df79d069 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx @@ -22,6 +22,10 @@ import React, { useMemo } from 'react'; import * as i18n from './translations'; import { Blockquote, ResetButton } from './helpers'; import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; +import { TimelineId, TimelineType } from '../../../../common/types'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; interface Props { activePatterns?: string[]; @@ -36,11 +40,17 @@ interface Props { const translations = { deprecated: { - title: i18n.CALL_OUT_DEPRECATED_TITLE, + title: { + [TimelineType.default]: i18n.CALL_OUT_DEPRECATED_TITLE, + [TimelineType.template]: i18n.CALL_OUT_DEPRECATED_TEMPLATE_TITLE, + }, update: i18n.UPDATE_INDEX_PATTERNS, }, missingPatterns: { - title: i18n.CALL_OUT_MISSING_PATTERNS_TITLE, + title: { + [TimelineType.default]: i18n.CALL_OUT_MISSING_PATTERNS_TITLE, + [TimelineType.template]: i18n.CALL_OUT_MISSING_PATTERNS_TEMPLATE_TITLE, + }, update: i18n.ADD_INDEX_PATTERN, }, }; @@ -87,7 +97,11 @@ export const TemporarySourcererComp = React.memo( activePatterns && activePatterns.length > 0 ? selectedPatterns.filter((p) => !activePatterns.includes(p)) : []; + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineType = useDeepEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).timelineType + ); return ( <> ( data-test-subj="sourcerer-deprecated-callout" iconType="alert" size="s" - title={translations[isModified].title} + title={translations[isModified].title[timelineType]} /> diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts index 2d8e506f39437d..1e1d300f4acf90 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts @@ -18,6 +18,13 @@ export const CALL_OUT_DEPRECATED_TITLE = i18n.translate( } ); +export const CALL_OUT_DEPRECATED_TEMPLATE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.callOutDeprecxatedTemplateTitle', + { + defaultMessage: 'This timeline template uses a legacy data view selector', + } +); + export const CALL_OUT_MISSING_PATTERNS_TITLE = i18n.translate( 'xpack.securitySolution.indexPatterns.callOutMissingPatternsTitle', { @@ -25,6 +32,13 @@ export const CALL_OUT_MISSING_PATTERNS_TITLE = i18n.translate( } ); +export const CALL_OUT_MISSING_PATTERNS_TEMPLATE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.callOutMissingPatternsTemplateTitle', + { + defaultMessage: 'This timeline template is out of date with the Security Data View', + } +); + export const CALL_OUT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.indexPatterns.callOutTimelineTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 2ca4525c7e1ab5..34807e368cbe4c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -21,6 +21,7 @@ export interface UseAddToCaseActions { ariaLabel?: string; ecsData?: Ecs; nonEcsData?: TimelineNonEcsData[]; + onSuccess?: () => Promise; timelineId: string; } @@ -29,6 +30,7 @@ export const useAddToCaseActions = ({ ariaLabel, ecsData, nonEcsData, + onSuccess, timelineId, }: UseAddToCaseActions) => { const { cases: casesUi } = useKibana().services; @@ -52,11 +54,13 @@ export const useAddToCaseActions = ({ const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ attachments: caseAttachments, onClose: onMenuItemClick, + onSuccess, }); const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ attachments: caseAttachments, onClose: onMenuItemClick, + onRowClick: onSuccess, }); const handleAddToNewCaseClick = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index 9546ef231992c1..938022b5aac5e2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -76,6 +76,7 @@ describe('take action dropdown', () => { onAddExceptionTypeClick: jest.fn(), onAddIsolationStatusClick: jest.fn(), refetch: jest.fn(), + refetchFlyoutData: jest.fn(), timelineId: TimelineId.active, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index d4373501eedd03..4a35fdd6a13811 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -23,6 +23,7 @@ import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_c import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useUserPrivileges } from '../../../common/components/user_privileges'; import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions'; + interface ActionsData { alertStatus: Status; eventId: string; @@ -42,6 +43,7 @@ export interface TakeActionDropdownProps { onAddExceptionTypeClick: (type: ExceptionListType) => void; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; refetch: (() => void) | undefined; + refetchFlyoutData: () => Promise; timelineId: string; } @@ -57,6 +59,7 @@ export const TakeActionDropdown = React.memo( onAddExceptionTypeClick, onAddIsolationStatusClick, refetch, + refetchFlyoutData, timelineId, }: TakeActionDropdownProps) => { const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); @@ -184,6 +187,7 @@ export const TakeActionDropdown = React.memo( ecsData, nonEcsData: detailsData?.map((d) => ({ field: d.field, value: d.values })) ?? [], onMenuItemClick, + onSuccess: refetchFlyoutData, timelineId, }); diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx index e2e9fac383d122..18c8f928660d90 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.test.tsx @@ -52,7 +52,7 @@ describe('Summary artifact hook', () => { result = await renderQuery( () => - useSummaryArtifact(instance, searchableFields, options, { + useSummaryArtifact(instance, options, searchableFields, { onSuccess: onSuccessMock, retry: false, }), @@ -84,7 +84,7 @@ describe('Summary artifact hook', () => { result = await renderQuery( () => - useSummaryArtifact(instance, searchableFields, options, { + useSummaryArtifact(instance, options, searchableFields, { onError: onErrorMock, retry: false, }), diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx index 9e4ca1682f022e..b92929f10503fd 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_summary_artifact.tsx @@ -9,22 +9,23 @@ import { HttpFetchError } from 'kibana/public'; import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../common/utils'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; +import { MaybeImmutable } from '../../../../common/endpoint/types'; const DEFAULT_OPTIONS = Object.freeze({}); export function useSummaryArtifact( exceptionListApiClient: ExceptionsListApiClient, - searchableFields: string[], - options: { + options: Partial<{ filter: string; policies: string[]; - } = { - filter: '', - policies: [], - }, - customQueryOptions: UseQueryOptions = DEFAULT_OPTIONS + }> = DEFAULT_OPTIONS, + searchableFields: MaybeImmutable = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, + customQueryOptions: Partial< + UseQueryOptions + > = DEFAULT_OPTIONS ): QueryObserverResult { - const { filter, policies } = options; + const { filter = '', policies = [] } = options; return useQuery( ['summary', exceptionListApiClient, options], diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx new file mode 100644 index 00000000000000..87860db1fe69d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../../common/mock/endpoint'; +import { getEventFiltersListPath } from '../../../../../../common/routing'; +import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../../../common/components/user_privileges/endpoint/mocks'; +import { useToasts } from '../../../../../../../common/lib/kibana'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { FleetArtifactsCard } from './fleet_artifacts_card'; +import { EVENT_FILTERS_LABELS } from '..'; + +jest.mock('../../../../../../../common/lib/kibana'); + +describe('Fleet artifacts card', () => { + let render: (externalPrivileges?: boolean) => Promise>; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockedApi: ReturnType; + let addDanger: jest.Mock = jest.fn(); + const useToastsMock = useToasts as jest.Mock; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); + getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: true, + }); + render = async () => { + await act(async () => { + renderResult = mockedContext.render( + // @ts-expect-error TS2739 + + ); + await waitFor(mockedApi.responseProvider.eventFiltersList); + }); + return renderResult; + }; + }); + + beforeAll(() => { + useToastsMock.mockImplementation(() => { + return { + addDanger, + }; + }); + }); + beforeEach(() => { + addDanger = jest.fn(); + }); + + it('should render correctly', async () => { + const component = await render(); + expect(component.getByText('Event filters')).not.toBeNull(); + expect(component.getByText('Manage')).not.toBeNull(); + }); + it('should render an error toast when api call fails', async () => { + expect(addDanger).toBeCalledTimes(0); + mockedApi.responseProvider.eventFiltersGetSummary.mockImplementation(() => { + throw new Error('error getting summary'); + }); + const component = await render(); + expect(component.getByText('Event filters')).not.toBeNull(); + expect(component.getByText('Manage')).not.toBeNull(); + await waitFor(() => expect(addDanger).toBeCalledTimes(1)); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx new file mode 100644 index 00000000000000..dc8256d93ae009 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_artifacts_card.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + PackageCustomExtensionComponentProps, + pagePathGetters, +} from '../../../../../../../../../fleet/public'; +import { ListPageRouteState } from '../../../../../../../../common/endpoint/types'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; +import { useToasts } from '../../../../../../../common/lib/kibana'; +import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; +import { LinkWithIcon } from './link_with_icon'; +import { ExceptionItemsSummary } from './exception_items_summary'; +import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; +import { useSummaryArtifact } from '../../../../../../hooks/artifacts'; +import { ExceptionsListApiClient } from '../../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useTestIdGenerator } from '../../../../../../components/hooks/use_test_id_generator'; + +const ARTIFACTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetCustomExtension.artifactsSummaryError', { + defaultMessage: 'There was an error trying to fetch artifacts stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), +}; + +export type ARTIFACTS_LABELS_TYPE = typeof ARTIFACTS_LABELS; + +type FleetArtifactsCardProps = PackageCustomExtensionComponentProps & { + artifactApiClientInstance: ExceptionsListApiClient; + getArtifactsPath: () => string; + labels?: ARTIFACTS_LABELS_TYPE; + 'data-test-subj': string; +}; + +export const FleetArtifactsCard = memo( + ({ + pkgkey, + artifactApiClientInstance, + getArtifactsPath, + labels = ARTIFACTS_LABELS, + 'data-test-subj': dataTestSubj, + }) => { + const { getAppUrl } = useAppUrl(); + const toasts = useToasts(); + const artifactsListUrlPath = getArtifactsPath(); + const getTestId = useTestIdGenerator(dataTestSubj); + + const { data } = useSummaryArtifact(artifactApiClientInstance, {}, [], { + onError: (error) => toasts.addDanger(labels.artifactsSummaryApiError(error.message)), + }); + + const artifactsRouteState = useMemo(() => { + const fleetPackageCustomUrlPath = `#${ + pagePathGetters.integration_details_custom({ pkgkey })[1] + }`; + return { + backButtonLabel: i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', + { defaultMessage: 'Return to Endpoint Security integrations' } + ), + onBackButtonNavigateTo: [ + INTEGRATIONS_PLUGIN_ID, + { + path: fleetPackageCustomUrlPath, + }, + ], + backButtonUrl: getAppUrl({ + appId: INTEGRATIONS_PLUGIN_ID, + path: fleetPackageCustomUrlPath, + }), + }; + }, [getAppUrl, pkgkey]); + + return ( + + + + +

{labels.cardTitle}

+
+
+ + + + + <> + + + + + +
+
+ ); + } +); + +FleetArtifactsCard.displayName = 'FleetArtifactsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx deleted file mode 100644 index 148f85cb301e96..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { I18nProvider } from '@kbn/i18n-react'; -import { FleetEventFiltersCard } from './fleet_event_filters_card'; -import * as reactTestingLibrary from '@testing-library/react'; -import { EventFiltersHttpService } from '../../../../../event_filters/service'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../event_filters/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -// Casting to unknown to avoid ts error because there is an static method in the class -const EventFiltersHttpServiceMock = EventFiltersHttpService as unknown as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -describe('Fleet event filters card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: () => Promise = async () => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - // @ts-expect-error TS2739 - const component = reactTestingLibrary.render(, { wrapper: Wrapper }); - try { - // @ts-expect-error TS2769 - await reactTestingLibrary.act(() => promise); - } catch (err) { - return component; - } - return component; - }; - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - EventFiltersHttpServiceMock.mockReset(); - }); - it('should render correctly', async () => { - EventFiltersHttpServiceMock.mockImplementationOnce(() => { - return { - getSummary: () => jest.fn(() => promise), - }; - }); - const component = await renderComponent(); - expect(component.getByText('Event filters')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - EventFiltersHttpServiceMock.mockImplementationOnce(() => { - return { - getSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Event filters')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx deleted file mode 100644 index a470d4b63e7bda..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_event_filters_card.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useState, useEffect, useRef } from 'react'; -import { EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { getEventFiltersListPath } from '../../../../../../common/routing'; -import { - GetExceptionSummaryResponse, - ListPageRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { LinkWithIcon } from './link_with_icon'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { EventFiltersHttpService } from '../../../../../event_filters/service'; -import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; - -export const FleetEventFiltersCard = memo(({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const eventFiltersListUrlPath = getEventFiltersListPath(); - const eventFiltersApi = useMemo(() => new EventFiltersHttpService(http), [http]); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await eventFiltersApi.getSummary(); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError', - { - defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [eventFiltersApi, toasts]); - - const eventFiltersRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - return ( - - - - -

- -

-
-
- - - - - <> - - - - - -
-
- ); -}); - -FleetEventFiltersCard.displayName = 'FleetEventFiltersCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx deleted file mode 100644 index a60c6aac602e08..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.test.tsx +++ /dev/null @@ -1,110 +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 { I18nProvider } from '@kbn/i18n-react'; -import * as reactTestingLibrary from '@testing-library/react'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { FleetHostIsolationExceptionsCard } from './fleet_host_isolation_exceptions_card'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../host_isolation_exceptions/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -const getHostIsolationExceptionSummaryMock = getHostIsolationExceptionSummary as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -describe('Fleet host isolation exceptions card filters card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: () => Promise = async () => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - // @ts-expect-error TS2739 - const component = reactTestingLibrary.render(, { - wrapper: Wrapper, - }); - try { - // @ts-expect-error TS2769 - await reactTestingLibrary.act(() => promise); - } catch (err) { - return component; - } - return component; - }; - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - getHostIsolationExceptionSummaryMock.mockReset(); - }); - it('should render correctly', async () => { - getHostIsolationExceptionSummaryMock.mockReturnValueOnce(promise); - const component = await renderComponent(); - expect(component.getByText('Host isolation exceptions')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - getHostIsolationExceptionSummaryMock.mockReturnValueOnce(promise); - const component = await renderComponent(); - expect(component.getByText('Host isolation exceptions')).not.toBeNull(); - expect(component.getByText('Manage')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx deleted file mode 100644 index 286047d804ebfc..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_host_isolation_exceptions_card.tsx +++ /dev/null @@ -1,130 +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 { EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - ListPageRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { getHostIsolationExceptionsListPath } from '../../../../../../common/routing'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexGridGroup, StyledEuiFlexGridItem } from './styled_components'; - -export const FleetHostIsolationExceptionsCard = memo( - ({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const hostIsolationExceptionsListUrlPath = getHostIsolationExceptionsListPath(); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getHostIsolationExceptionSummary(http); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error', - { - defaultMessage: - 'There was an error trying to fetch host isolation exceptions stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, toasts]); - - const hostIsolationExceptionsRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - return ( - - - - -

- -

-
-
- - - - - <> - - - - - -
-
- ); - } -); - -FleetHostIsolationExceptionsCard.displayName = 'FleetHostIsolationExceptionsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx index 73721345b8ff6f..7989bb26464836 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.test.tsx @@ -14,16 +14,19 @@ import { } from '../../../../../../../common/mock/endpoint'; import { eventFiltersListQueryHttpMock } from '../../../../../event_filters/test_utils'; -import { FleetIntegrationEventFiltersCard } from './fleet_integration_event_filters_card'; +import { FleetIntegrationArtifactsCard } from './fleet_integration_artifacts_card'; import { EndpointDocGenerator } from '../../../../../../../../common/endpoint/generate_data'; import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; import { PolicyData } from '../../../../../../../../common/endpoint/types'; import { getSummaryExceptionListSchemaMock } from '../../../../../../../../../lists/common/schemas/response/exception_list_summary_schema.mock'; +import { EventFiltersApiClient } from '../../../../../event_filters/service/event_filters_api_client'; +import { SEARCHABLE_FIELDS } from '../../../../../event_filters/constants'; +import { EVENT_FILTERS_LABELS } from '../../endpoint_policy_edit_extension'; const endpointGenerator = new EndpointDocGenerator('seed'); describe('Fleet integration policy endpoint security event filters card', () => { - let render: () => Promise>; + let render: (externalPrivileges?: boolean) => Promise>; let renderResult: ReturnType; let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; @@ -35,10 +38,20 @@ describe('Fleet integration policy endpoint security event filters card', () => mockedContext = createAppRootMockRenderer(); mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http); ({ history } = mockedContext); - render = async () => { + render = async (externalPrivileges = true) => { await act(async () => { renderResult = mockedContext.render( - + ); await waitFor(() => expect(mockedApi.responseProvider.eventFiltersGetSummary).toHaveBeenCalled() @@ -58,7 +71,7 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toHaveTextContent( + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toHaveTextContent( 'Event filters3' ); }); @@ -69,7 +82,25 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-fleet-integration-card')).toBeTruthy(); + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toBeTruthy(); + }); + + it('should not show the card when no permissions and no results', async () => { + mockedApi.responseProvider.eventFiltersGetSummary.mockReturnValue( + getSummaryExceptionListSchemaMock({ total: 0 }) + ); + + await render(false); + expect(renderResult.queryByTestId('artifacts-fleet-integration-card')).toBeNull(); + }); + + it('should show the card when no permissions but results', async () => { + mockedApi.responseProvider.eventFiltersGetSummary.mockReturnValue( + getSummaryExceptionListSchemaMock({ total: 1 }) + ); + + await render(false); + expect(renderResult.getByTestId('artifacts-fleet-integration-card')).toBeTruthy(); }); it('should have the correct manage event filters link', async () => { @@ -78,14 +109,15 @@ describe('Fleet integration policy endpoint security event filters card', () => ); await render(); - expect(renderResult.getByTestId('eventFilters-link-to-exceptions')).toHaveAttribute( + expect(renderResult.getByTestId('artifacts-link-to-exceptions')).toHaveAttribute( 'href', `/app/security/administration/policy/${policy.id}/eventFilters` ); }); it('should show an error toast when API request fails', async () => { - const error = new Error('Uh oh! API error!'); + const errorMessage = 'Uh oh! API error!'; + const error = new Error(errorMessage); mockedApi.responseProvider.eventFiltersGetSummary.mockImplementation(() => { throw error; }); @@ -94,7 +126,7 @@ describe('Fleet integration policy endpoint security event filters card', () => await waitFor(() => { expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - `There was an error trying to fetch event filters stats: "${error}"` + `There was an error trying to fetch event filters stats: "${errorMessage}"` ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx new file mode 100644 index 00000000000000..6ed604df579180 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_artifacts_card.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { memo, useMemo } from 'react'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; +import { pagePathGetters } from '../../../../../../../../../fleet/public'; +import { PolicyDetailsRouteState } from '../../../../../../../../common/endpoint/types'; +import { useAppUrl, useToasts } from '../../../../../../../common/lib/kibana'; +import { ExceptionItemsSummary } from './exception_items_summary'; +import { LinkWithIcon } from './link_with_icon'; +import { StyledEuiFlexItem } from './styled_components'; +import { useSummaryArtifact } from '../../../../../../hooks/artifacts'; +import { ExceptionsListApiClient } from '../../../../../../services/exceptions_list/exceptions_list_api_client'; +import { useTestIdGenerator } from '../../../../../../components/hooks/use_test_id_generator'; + +const ARTIFACTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetIntegrationCard.artifactsSummary.error', { + defaultMessage: 'There was an error trying to fetch artifacts stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; + +export type ARTIFACTS_LABELS_TYPE = typeof ARTIFACTS_LABELS; + +export const FleetIntegrationArtifactsCard = memo<{ + policyId: string; + artifactApiClientInstance: ExceptionsListApiClient; + getArtifactsPath: (policyId: string) => string; + searchableFields: readonly string[]; + labels?: ARTIFACTS_LABELS_TYPE; + privileges?: boolean; + 'data-test-subj': string; +}>( + ({ + policyId, + artifactApiClientInstance, + getArtifactsPath, + searchableFields, + labels = ARTIFACTS_LABELS, + privileges = true, + 'data-test-subj': dataTestSubj, + }) => { + const toasts = useToasts(); + const { getAppUrl } = useAppUrl(); + const policyArtifactsPath = getArtifactsPath(policyId); + const getTestId = useTestIdGenerator(dataTestSubj); + + const { data } = useSummaryArtifact( + artifactApiClientInstance, + { policies: [policyId, 'all'] }, + searchableFields, + { + onError: (error) => toasts.addDanger(labels.artifactsSummaryApiError(error.message)), + } + ); + + const policyArtifactsRouteState = useMemo(() => { + const fleetPackageIntegrationCustomUrlPath = `#${ + pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] + }`; + + return { + backLink: { + label: i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.artifacts.backButtonLabel', + { + defaultMessage: `Back to Fleet integration policy`, + } + ), + navigateTo: [ + INTEGRATIONS_PLUGIN_ID, + { + path: fleetPackageIntegrationCustomUrlPath, + }, + ], + href: getAppUrl({ + appId: INTEGRATIONS_PLUGIN_ID, + path: fleetPackageIntegrationCustomUrlPath, + }), + }, + }; + }, [getAppUrl, policyId]); + + const linkToArtifacts = useMemo( + () => ( + + {labels.linkLabel} + + ), + [getAppUrl, getTestId, labels.linkLabel, policyArtifactsPath, policyArtifactsRouteState] + ); + + // do not render if doesn't have privileges. + // render if doesn't have privileges but has data to show + if ((data === undefined && !privileges) || (data?.total === 0 && !privileges)) { + return null; + } + + return ( + + + + +
{labels.cardTitle}
+
+
+ + + + {linkToArtifacts} +
+
+ ); + } +); + +FleetIntegrationArtifactsCard.displayName = 'FleetIntegrationArtifactsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx deleted file mode 100644 index c6857531a9dd07..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_event_filters_card.tsx +++ /dev/null @@ -1,145 +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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { pagePathGetters } from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - PolicyDetailsRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useAppUrl, useHttp, useToasts } from '../../../../../../../common/lib/kibana'; -import { getPolicyEventFiltersPath } from '../../../../../../common/routing'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexItem } from './styled_components'; -import { getSummary } from '../../../../../event_filters/service/service_actions'; - -export const FleetIntegrationEventFiltersCard = memo<{ - policyId: string; -}>(({ policyId }) => { - const toasts = useToasts(); - const http = useHttp(); - const [stats, setStats] = useState(); - const isMounted = useRef(); - const { getAppUrl } = useAppUrl(); - - const policyEventFiltersPath = getPolicyEventFiltersPath(policyId); - - const policyEventFiltersRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const linkToEventFilters = useMemo( - () => ( - - - - ), - [getAppUrl, policyEventFiltersPath, policyEventFiltersRouteState] - ); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getSummary({ http, filter: parsePoliciesToKQL([policyId, 'all']) }); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error', - { - defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, policyId, toasts]); - - return ( - - - - -
- -
-
-
- - - - {linkToEventFilters} -
-
- ); -}); - -FleetIntegrationEventFiltersCard.displayName = 'FleetIntegrationEventFiltersCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx deleted file mode 100644 index 08b5475b4589cb..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.test.tsx +++ /dev/null @@ -1,110 +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 { waitFor } from '@testing-library/react'; -import React from 'react'; -import uuid from 'uuid'; -import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; -import { useUserPrivileges } from '../../../../../../../common/components/user_privileges'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { FleetIntegrationHostIsolationExceptionsCard } from './fleet_integration_host_isolation_exceptions_card'; - -jest.mock('../../../../../host_isolation_exceptions/service'); -jest.mock('../../../../../../../common/components/user_privileges'); - -const getHostIsolationExceptionSummaryMock = getHostIsolationExceptionSummary as jest.Mock; - -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; - -describe('Fleet host isolation exceptions card filters card', () => { - const policyId = uuid.v4(); - const mockedContext = createAppRootMockRenderer(); - const renderComponent = () => { - return mockedContext.render( - - ); - }; - afterEach(() => { - getHostIsolationExceptionSummaryMock.mockReset(); - }); - describe('With canIsolateHost privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: true, - }, - }); - }); - - it('should call the API and render the card correctly', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 5, - macos: 5, - total: 5, - windows: 5, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalledWith( - mockedContext.coreStart.http, - `(exception-list-agnostic.attributes.tags:"policy:${policyId}" OR exception-list-agnostic.attributes.tags:"policy:all")` - ); - }); - - expect( - renderResult.getByTestId('hostIsolationExceptions-fleet-integration-card') - ).toHaveTextContent('Host isolation exceptions5'); - }); - }); - - describe('Without canIsolateHost privileges', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - endpointPrivileges: { - canIsolateHost: false, - }, - }); - }); - - it('should not render the card if there are no exceptions associated', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 0, - macos: 0, - total: 0, - windows: 0, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalled(); - }); - - expect( - renderResult.queryByTestId('hostIsolationExceptions-fleet-integration-card') - ).toBeFalsy(); - }); - - it('should render the card if there are exceptions associated', async () => { - getHostIsolationExceptionSummaryMock.mockResolvedValue({ - linux: 1, - macos: 1, - total: 1, - windows: 1, - }); - const renderResult = renderComponent(); - - await waitFor(() => { - expect(getHostIsolationExceptionSummaryMock).toHaveBeenCalled(); - }); - - expect( - renderResult.queryByTestId('hostIsolationExceptions-fleet-integration-card') - ).toBeTruthy(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx deleted file mode 100644 index 7bb464e1ba6df9..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card.tsx +++ /dev/null @@ -1,160 +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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { useUserPrivileges } from '../../../../../../../common/components/user_privileges'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; -import { pagePathGetters } from '../../../../../../../../../fleet/public'; -import { - GetExceptionSummaryResponse, - PolicyDetailsRouteState, -} from '../../../../../../../../common/endpoint/types'; -import { useAppUrl, useHttp, useToasts } from '../../../../../../../common/lib/kibana'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../../common/routing'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { getHostIsolationExceptionSummary } from '../../../../../host_isolation_exceptions/service'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { LinkWithIcon } from './link_with_icon'; -import { StyledEuiFlexItem } from './styled_components'; - -export const FleetIntegrationHostIsolationExceptionsCard = memo<{ - policyId: string; -}>(({ policyId }) => { - const toasts = useToasts(); - const http = useHttp(); - const [stats, setStats] = useState(); - const isMounted = useRef(); - const { getAppUrl } = useAppUrl(); - const policyHostIsolationExceptionsPath = getPolicyHostIsolationExceptionsPath(policyId); - const privileges = useUserPrivileges().endpointPrivileges; - - const policyHostIsolationExceptionsRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const href = useMemo( - () => ( - - - - ), - [getAppUrl, policyHostIsolationExceptionsPath, policyHostIsolationExceptionsRouteState] - ); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const summary = await getHostIsolationExceptionSummary( - http, - parsePoliciesToKQL([policyId, 'all']) - ); - if (isMounted.current) { - setStats(summary); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error', - { - defaultMessage: - 'There was an error trying to fetch host isolation exceptions stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - fetchStats(); - return () => { - isMounted.current = false; - }; - }, [http, policyId, toasts]); - - // do not render if doesn't have privileges. - // render if doesn't have privileges but has data to show - if ( - (stats === undefined && !privileges.canIsolateHost) || - (stats?.total === 0 && !privileges.canIsolateHost) - ) { - return null; - } - - return ( - - - - -
- -
-
-
- - - - {href} -
-
- ); -}); - -FleetIntegrationHostIsolationExceptionsCard.displayName = - 'FleetIntegrationHostIsolationExceptionsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx deleted file mode 100644 index f1ab47b2ea4254..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import { I18nProvider } from '@kbn/i18n-react'; -import { FleetTrustedAppsCard, FleetTrustedAppsCardProps } from './fleet_trusted_apps_card'; -import * as reactTestingLibrary from '@testing-library/react'; -import { TrustedAppsHttpService } from '../../../../../trusted_apps/service'; -import { useToasts } from '../../../../../../../common/lib/kibana'; -import { getMockTheme } from '../../../../../../../../public/common/lib/kibana/kibana_react.mock'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -jest.mock('./exception_items_summary'); -jest.mock('../../../../../trusted_apps/service'); - -jest.mock('../../../../../../../../../../../src/plugins/kibana_react/public', () => { - const originalModule = jest.requireActual( - '../../../../../../../../../../../src/plugins/kibana_react/public' - ); - const useKibana = jest.fn().mockImplementation(() => ({ - services: { - http: {}, - data: {}, - notifications: {}, - application: { - getUrlForApp: jest.fn(), - }, - }, - })); - - return { - ...originalModule, - useKibana, - }; -}); - -jest.mock('../../../../../../../common/lib/kibana'); - -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - }, -}); - -const TrustedAppsHttpServiceMock = TrustedAppsHttpService as jest.Mock; -const useToastsMock = useToasts as jest.Mock; - -const summary: GetExceptionSummaryResponse = { - windows: 3, - linux: 2, - macos: 2, - total: 7, -}; - -const customLinkMock =
; - -describe('Fleet trusted apps card', () => { - let promise: Promise; - let addDanger: jest.Mock = jest.fn(); - const renderComponent: ( - customProps?: Partial - ) => Promise = async (customProps = {}) => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - - const component = reactTestingLibrary.render( - , - { - wrapper: Wrapper, - } - ); - try { - await reactTestingLibrary.act(async () => { - await promise; - }); - } catch (err) { - return component; - } - return component; - }; - - beforeAll(() => { - useToastsMock.mockImplementation(() => { - return { - addDanger, - }; - }); - }); - beforeEach(() => { - promise = Promise.resolve(summary); - addDanger = jest.fn(); - }); - afterEach(() => { - TrustedAppsHttpServiceMock.mockReset(); - }); - it('should render correctly without policyId', async () => { - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - }); - it('should render correctly with policyId', async () => { - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => () => promise, - }; - }); - const component = await renderComponent({ policyId: 'policy-1' }); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - }); - it('should render an error toast when api call fails', async () => { - expect(addDanger).toBeCalledTimes(0); - promise = Promise.reject(new Error('error test')); - TrustedAppsHttpServiceMock.mockImplementationOnce(() => { - return { - getTrustedAppsSummary: () => promise, - }; - }); - const component = await renderComponent(); - expect(component.getByText('Trusted applications')).not.toBeNull(); - expect(component.getByTestId('manageTrustedApplications')).not.toBeNull(); - await reactTestingLibrary.waitFor(() => expect(addDanger).toBeCalledTimes(1)); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx deleted file mode 100644 index c0bc7de5b7350e..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo, useState, useEffect, useRef } from 'react'; -import { EuiPanel, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { GetExceptionSummaryResponse } from '../../../../../../../../common/endpoint/types'; - -import { useKibana, useToasts } from '../../../../../../../common/lib/kibana'; -import { ExceptionItemsSummary } from './exception_items_summary'; -import { parsePoliciesToKQL } from '../../../../../../common/utils'; -import { TrustedAppsHttpService } from '../../../../../trusted_apps/service'; -import { - StyledEuiFlexGridGroup, - StyledEuiFlexGridItem, - StyledEuiFlexItem, -} from './styled_components'; - -export interface FleetTrustedAppsCardProps { - customLink: React.ReactNode; - policyId?: string; - cardSize?: 'm' | 'l'; -} - -export const FleetTrustedAppsCard = memo( - ({ customLink, policyId, cardSize = 'l' }) => { - const { - services: { http }, - } = useKibana(); - const toasts = useToasts(); - const [stats, setStats] = useState(); - const trustedAppsApi = useMemo(() => new TrustedAppsHttpService(http), [http]); - const isMounted = useRef(); - - useEffect(() => { - isMounted.current = true; - const fetchStats = async () => { - try { - const response = await trustedAppsApi.getTrustedAppsSummary( - policyId ? parsePoliciesToKQL([policyId, 'all']) : undefined - ); - if (isMounted.current) { - setStats(response); - } - } catch (error) { - if (isMounted.current) { - toasts.addDanger( - i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError', - { - defaultMessage: - 'There was an error trying to fetch trusted apps stats: "{error}"', - values: { error }, - } - ) - ); - } - } - }; - if (!stats) { - fetchStats(); - } - return () => { - isMounted.current = false; - }; - }, [toasts, trustedAppsApi, policyId, stats]); - - const getTitleMessage = () => ( - - ); - - const cardGrid = useMemo(() => { - if (cardSize === 'm') { - return ( - - - -
{getTitleMessage()}
-
-
- - - - {customLink} -
- ); - } else { - return ( - - - -

{getTitleMessage()}

-
-
- - - - - {customLink} - -
- ); - } - }, [cardSize, customLink, stats]); - - return ( - - {cardGrid} - - ); - } -); - -FleetTrustedAppsCard.displayName = 'FleetTrustedAppsCard'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx deleted file mode 100644 index 5722f97ff680fb..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card_wrapper.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - PackageCustomExtensionComponentProps, - pagePathGetters, -} from '../../../../../../../../../fleet/public'; -import { getTrustedAppsListPath } from '../../../../../../common/routing'; -import { ListPageRouteState } from '../../../../../../../../common/endpoint/types'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../../../fleet/common'; - -import { useAppUrl } from '../../../../../../../common/lib/kibana/hooks'; -import { LinkWithIcon } from './link_with_icon'; -import { FleetTrustedAppsCard } from './fleet_trusted_apps_card'; - -export const FleetTrustedAppsCardWrapper = memo( - ({ pkgkey }) => { - const { getAppUrl } = useAppUrl(); - const trustedAppsListUrlPath = getTrustedAppsListPath(); - - const trustedAppRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${ - pagePathGetters.integration_details_custom({ pkgkey })[1] - }`; - - return { - backButtonLabel: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', - { defaultMessage: 'Return to Endpoint Security integrations' } - ), - onBackButtonNavigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageCustomUrlPath, - }, - ], - backButtonUrl: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageCustomUrlPath, - }), - }; - }, [getAppUrl, pkgkey]); - - const customLink = useMemo( - () => ( - - - - ), - [getAppUrl, trustedAppRouteState, trustedAppsListUrlPath] - ); - return ; - } -); - -FleetTrustedAppsCardWrapper.displayName = 'FleetTrustedAppsCardWrapper'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx index d53fe308a90ec3..0da28d6ed7d1bc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/index.tsx @@ -5,22 +5,149 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer } from '@elastic/eui'; -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; +import { useHttp } from '../../../../../../common/lib/kibana/hooks'; import { PackageCustomExtensionComponentProps } from '../../../../../../../../fleet/public'; -import { FleetEventFiltersCard } from './components/fleet_event_filters_card'; -import { FleetHostIsolationExceptionsCard } from './components/fleet_host_isolation_exceptions_card'; -import { FleetTrustedAppsCardWrapper } from './components/fleet_trusted_apps_card_wrapper'; +import { ReactQueryClientProvider } from '../../../../../../common/containers/query_client/query_client_provider'; +import { FleetArtifactsCard } from './components/fleet_artifacts_card'; +import { + getBlocklistsListPath, + getEventFiltersListPath, + getHostIsolationExceptionsListPath, + getTrustedAppsListPath, +} from '../../../../../common/routing'; +import { TrustedAppsApiClient } from '../../../../trusted_apps/service/trusted_apps_api_client'; +import { EventFiltersApiClient } from '../../../../event_filters/service/event_filters_api_client'; +import { HostIsolationExceptionsApiClient } from '../../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { BlocklistsApiClient } from '../../../../blocklist/services'; + +export const TRUSTED_APPS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch trusted applications stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const EVENT_FILTERS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const HOST_ISOLATION_EXCEPTIONS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummarySummary.error', + { + defaultMessage: + 'There was an error trying to fetch host isolation exceptions stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; + +export const BLOCKLISTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetCustomExtension.blocklistsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch blocklist stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), +}; export const EndpointPackageCustomExtension = memo( (props) => { + const http = useHttp(); + const trustedAppsApiClientInstance = useMemo( + () => TrustedAppsApiClient.getInstance(http), + [http] + ); + + const eventFiltersApiClientInstance = useMemo( + () => EventFiltersApiClient.getInstance(http), + [http] + ); + + const hostIsolationExceptionsApiClientInstance = useMemo( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const bloklistsApiClientInstance = useMemo(() => BlocklistsApiClient.getInstance(http), [http]); + return (
- - - - - + + + + + + + + +
); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx index 4f49ff91b5a8dd..690cb2dc734bd2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_policy_edit_extension.tsx @@ -13,12 +13,15 @@ import { useDispatch } from 'react-redux'; import { PackagePolicyEditExtensionComponentProps, NewPackagePolicy, - pagePathGetters, } from '../../../../../../../fleet/public'; -import { INTEGRATIONS_PLUGIN_ID } from '../../../../../../../fleet/common'; -import { useAppUrl } from '../../../../../common/lib/kibana/hooks'; -import { PolicyDetailsRouteState } from '../../../../../../common/endpoint/types'; -import { getPolicyDetailPath, getPolicyTrustedAppsPath } from '../../../../common/routing'; +import { useHttp } from '../../../../../common/lib/kibana/hooks'; +import { + getPolicyDetailPath, + getPolicyTrustedAppsPath, + getPolicyBlocklistsPath, + getPolicyHostIsolationExceptionsPath, + getPolicyEventFiltersPath, +} from '../../../../common/routing'; import { PolicyDetailsForm } from '../policy_details_form'; import { AppAction } from '../../../../../common/store/actions'; import { usePolicyDetailsSelector } from '../policy_hooks'; @@ -27,10 +30,106 @@ import { policyDetails, policyDetailsForUpdate, } from '../../store/policy_details/selectors'; -import { FleetTrustedAppsCard } from './endpoint_package_custom_extension/components/fleet_trusted_apps_card'; -import { LinkWithIcon } from './endpoint_package_custom_extension/components/link_with_icon'; -import { FleetIntegrationHostIsolationExceptionsCard } from './endpoint_package_custom_extension/components/fleet_integration_host_isolation_exceptions_card'; -import { FleetIntegrationEventFiltersCard } from './endpoint_package_custom_extension/components/fleet_integration_event_filters_card'; + +import { ReactQueryClientProvider } from '../../../../../common/containers/query_client/query_client_provider'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { FleetIntegrationArtifactsCard } from './endpoint_package_custom_extension/components/fleet_integration_artifacts_card'; +import { BlocklistsApiClient } from '../../../blocklist/services'; +import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; +import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { TrustedAppsApiClient } from '../../../trusted_apps/service/trusted_apps_api_client'; +import { SEARCHABLE_FIELDS as BLOCKLIST_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; +import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; +import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../event_filters/constants'; +import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; + +export const BLOCKLISTS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate('xpack.securitySolution.endpoint.fleetIntegrationCard.blocklistsSummary.error', { + defaultMessage: 'There was an error trying to fetch blocklists stats: "{error}"', + values: { error }, + }), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const HOST_ISOLATION_EXCEPTIONS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.hostIsolationExceptionsSummary.error', + { + defaultMessage: + 'There was an error trying to fetch host isolation exceptions stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const EVENT_FILTERS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.eventFiltersSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch event filters stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; +export const TRUSTED_APPS_LABELS = { + artifactsSummaryApiError: (error: string) => + i18n.translate( + 'xpack.securitySolution.endpoint.fleetIntegrationCard.trustedAppsSummarySummary.error', + { + defaultMessage: 'There was an error trying to fetch trusted apps stats: "{error}"', + values: { error }, + } + ), + cardTitle: ( + + ), + linkLabel: ( + + ), +}; + /** * Exports Endpoint-specific package policy instructions * for use in the Ingest app create / edit package policy @@ -38,10 +137,10 @@ import { FleetIntegrationEventFiltersCard } from './endpoint_package_custom_exte export const EndpointPolicyEditExtension = memo( ({ policy, onChange }) => { return ( - <> + - + ); } ); @@ -55,8 +154,26 @@ const WrappedPolicyDetailsForm = memo<{ const updatedPolicy = usePolicyDetailsSelector(policyDetailsForUpdate); const endpointPolicyDetails = usePolicyDetailsSelector(policyDetails); const endpointDetailsLoadingError = usePolicyDetailsSelector(apiError); - const { getAppUrl } = useAppUrl(); const [, setLastUpdatedPolicy] = useState(updatedPolicy); + const privileges = useUserPrivileges().endpointPrivileges; + + const http = useHttp(); + const blocklistsApiClientInstance = useMemo(() => BlocklistsApiClient.getInstance(http), [http]); + + const hostIsolationExceptionsApiClientInstance = useMemo( + () => HostIsolationExceptionsApiClient.getInstance(http), + [http] + ); + + const eventFiltersApiClientInstance = useMemo( + () => EventFiltersApiClient.getInstance(http), + [http] + ); + + const trustedAppsApiClientInstance = useMemo( + () => TrustedAppsApiClient.getInstance(http), + [http] + ); // When the form is initially displayed, trigger the Redux middleware which is based on // the location information stored via the `userChangedUrl` action. @@ -109,54 +226,6 @@ const WrappedPolicyDetailsForm = memo<{ }); }, [onChange, updatedPolicy]); - const policyTrustedAppsPath = useMemo(() => getPolicyTrustedAppsPath(policyId), [policyId]); - const policyTrustedAppRouteState = useMemo(() => { - const fleetPackageIntegrationCustomUrlPath = `#${ - pagePathGetters.integration_policy_edit({ packagePolicyId: policyId })[1] - }`; - - return { - backLink: { - label: i18n.translate( - 'xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel', - { - defaultMessage: `Back to Fleet integration policy`, - } - ), - navigateTo: [ - INTEGRATIONS_PLUGIN_ID, - { - path: fleetPackageIntegrationCustomUrlPath, - }, - ], - href: getAppUrl({ - appId: INTEGRATIONS_PLUGIN_ID, - path: fleetPackageIntegrationCustomUrlPath, - }), - }, - }; - }, [getAppUrl, policyId]); - - const policyTrustedAppsLink = useMemo( - () => ( - - - - ), - [getAppUrl, policyTrustedAppsPath, policyTrustedAppRouteState] - ); - return (
<> @@ -170,15 +239,42 @@ const WrappedPolicyDetailsForm = memo<{ - + + - + - +
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index d1c350632d7cb9..01089552be251c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -259,6 +259,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should isHostIsolationPanelOpen={false} loadingEventDetails={true} onAddIsolationStatusClick={[Function]} + refetchFlyoutData={[Function]} timelineId="test" > { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index e97568a4ae52dd..31e2f74f5bf4fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -34,6 +34,7 @@ interface EventDetailsFooterProps { loadingEventDetails: boolean; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; timelineId: string; + refetchFlyoutData: () => Promise; } interface AddExceptionModalWrapperData { @@ -55,6 +56,7 @@ export const EventDetailsFooterComponent = React.memo( timelineId, globalQuery, timelineQuery, + refetchFlyoutData, }: EventDetailsFooterProps & PropsFromRedux) => { const ruleIndex = useMemo( () => @@ -122,6 +124,7 @@ export const EventDetailsFooterComponent = React.memo( onAddEventFilterClick={onAddEventFilterClick} onAddExceptionTypeClick={onAddExceptionTypeClick} onAddIsolationStatusClick={onAddIsolationStatusClick} + refetchFlyoutData={refetchFlyoutData} refetch={refetchAll} indexName={expandedEvent.indexName} timelineId={timelineId} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 7577789408a8f3..825a81f1984f3d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -81,14 +81,16 @@ const EventDetailsPanelComponent: React.FC = ({ tabType, timelineId, }) => { - const [loading, detailsData, rawEventData, ecsData] = useTimelineEventsDetails({ - docValueFields, - entityType, - indexName: expandedEvent.indexName ?? '', - eventId: expandedEvent.eventId ?? '', - runtimeMappings, - skip: !expandedEvent.eventId, - }); + const [loading, detailsData, rawEventData, ecsData, refetchFlyoutData] = useTimelineEventsDetails( + { + docValueFields, + entityType, + indexName: expandedEvent.indexName ?? '', + eventId: expandedEvent.eventId ?? '', + runtimeMappings, + skip: !expandedEvent.eventId, + } + ); const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false); @@ -240,6 +242,7 @@ const EventDetailsPanelComponent: React.FC = ({ detailsData={detailsData} detailsEcsData={ecsData} expandedEvent={expandedEvent} + refetchFlyoutData={refetchFlyoutData} handleOnEventClosed={handleOnEventClosed} isHostIsolationPanelOpen={isHostIsolationPanelOpen} loadingEventDetails={loading} @@ -277,6 +280,7 @@ const EventDetailsPanelComponent: React.FC = ({ isHostIsolationPanelOpen={isHostIsolationPanelOpen} loadingEventDetails={loading} onAddIsolationStatusClick={showHostIsolationPanel} + refetchFlyoutData={refetchFlyoutData} timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 730c4e4b19f847..cc60e9421d26be 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -5,13 +5,12 @@ * 2.0. */ -import { isEmpty, noop } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { Subscription } from 'rxjs'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { inputsModel } from '../../../common/store'; import { useKibana } from '../../../common/lib/kibana'; import { DocValueFields, @@ -51,10 +50,12 @@ export const useTimelineEventsDetails = ({ boolean, EventsArgs['detailsData'], object | undefined, - EventsArgs['ecs'] + EventsArgs['ecs'], + () => Promise ] => { + const asyncNoop = () => Promise.resolve(); const { data } = useKibana().services; - const refetch = useRef(noop); + const refetch = useRef<() => Promise>(asyncNoop); const abortCtrl = useRef(new AbortController()); const searchSubscription$ = useRef(new Subscription()); const [loading, setLoading] = useState(false); @@ -141,5 +142,5 @@ export const useTimelineEventsDetails = ({ }; }, [timelineDetailsRequest, timelineDetailsSearch]); - return [loading, timelineDetailsResponse, rawEventData, ecsData]; + return [loading, timelineDetailsResponse, rawEventData, ecsData, refetch.current]; }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0843867d79222d..3aaea1021494d3 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5920,9 +5920,6 @@ "xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel": "Impact", "xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel": "Score", "xpack.apm.correlations.failedTransactions.errorTitle": "Une erreur est survenue lors de l'exécution de corrélations sur les transactions ayant échoué", - "xpack.apm.correlations.failedTransactions.highImpactText": "Élevé", - "xpack.apm.correlations.failedTransactions.lowImpactText": "Bas", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "Moyen", "xpack.apm.correlations.failedTransactions.panelTitle": "Transactions ayant échoué", "xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel": "Filtre", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription": "Score de corrélation [0-1] d'un attribut ; plus le score est élevé, plus un attribut augmente la latence.", @@ -21012,16 +21009,10 @@ "xpack.securitySolution.endpoint.details.policyStatusValue": "{policyStatus, select, success {Succès} warning {Avertissement} failure {Échec} other {Inconnu}}", "xpack.securitySolution.endpoint.detailsActions.buttonLabel": "Entreprendre une action", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "Retour à l'intégration du point de terminaison", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "Filtres d'événements", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "Une erreur s'est produite lors de la tentative de récupération des statistiques de filtres d'événements : \"{error}\"", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "Total", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "Gérer les filtres d'événements", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "Gérer les applications de confiance", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "Applications de confiance", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "Une erreur s'est produite lors de la tentative de récupération des statistiques des applications de confiance : \"{error}\"", "xpack.securitySolution.endpoint.hostIsolation.cancel": "Annuler", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "Impossible de trouver les cas associés", "xpack.securitySolution.endpoint.hostIsolation.comment": "Commentaire", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ff74a38cae97eb..6969f983ab4302 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2655,7 +2655,6 @@ "discover.advancedSettings.defaultColumnsText": "デフォルトでDiscoverアプリに表示される列。空の場合、ドキュメントの概要が表示されます。", "discover.advancedSettings.defaultColumnsTitle": "デフォルトの列", "discover.advancedSettings.disableDocumentExplorer": "ドキュメントエクスプローラーまたはクラシックビュー", - "discover.advancedSettings.disableDocumentExplorerDescription": "クラシックビューではなく、ドキュメントエクスプローラーを使用するには、このオプションをオフにします。ドキュメントエクスプローラーでは、データの並べ替え、列のサイズ変更、全画面表示といった優れた機能を使用できます。", "discover.advancedSettings.discover.fieldStatisticsLinkText": "フィールド統計情報ビュー", "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "新しいデータビューで使用できない列を削除します。", "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "データビューを変更するときに列を修正", @@ -6913,9 +6912,9 @@ "xpack.apm.correlations.failedTransactions.helpPopover.performanceExplanation": "この分析は多数の属性に対して統計検索を実行します。広い時間範囲やトランザクションスループットが高いサービスでは、時間がかかる場合があります。パフォーマンスを改善するには、時間範囲を絞り込みます。", "xpack.apm.correlations.failedTransactions.helpPopover.tableExplanation": "表はスコア別に並べ替えられます。これは高、中、低影響度にマッピングされます。影響度が高い属性は、失敗したトランザクションの原因である可能性が高くなります。", "xpack.apm.correlations.failedTransactions.helpPopover.title": "失敗したトランザクションの相関関係", - "xpack.apm.correlations.failedTransactions.highImpactText": "高", - "xpack.apm.correlations.failedTransactions.lowImpactText": "低", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "中", + "xpack.apm.correlations.highImpactText": "高", + "xpack.apm.correlations.lowImpactText": "低", + "xpack.apm.correlations.mediumImpactText": "中", "xpack.apm.correlations.failedTransactions.panelTitle": "失敗したトランザクションの遅延分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相関関係", "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "{fieldName}のフィルター:\"{value}\"", @@ -7060,8 +7059,6 @@ "xpack.apm.fleet_integration.settings.apm.urlLabel": "URL", "xpack.apm.fleet_integration.settings.apm.writeTimeoutLabel": "応答を書き込む最大時間", "xpack.apm.fleet_integration.settings.apmAgent.description": "{title}アプリケーションの計測を構成します。", - "xpack.apm.fleet_integration.settings.betaBadgeLabel": "ベータ", - "xpack.apm.fleet_integration.settings.betaBadgeTooltip": "このモジュールはGAではありません。不具合が発生したら報告してください。", "xpack.apm.fleet_integration.settings.disabledLabel": "無効", "xpack.apm.fleet_integration.settings.enabledLabel": "有効", "xpack.apm.fleet_integration.settings.optionalLabel": "オプション", @@ -20958,7 +20955,6 @@ "xpack.observability.seriesEditor.edit": "系列を編集", "xpack.observability.seriesEditor.hide": "系列を非表示", "xpack.observability.seriesEditor.sampleDocuments": "新しいタブでサンプルドキュメントを表示", - "xpack.observability.status.addIntegrationLink": "追加", "xpack.observability.status.learnMoreButton": "詳細", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "ワイルドカード*{wildcard}*を使用", @@ -24055,26 +24051,11 @@ "xpack.securitySolution.endpoint.effectedPolicySelect.global": "グローバル", "xpack.securitySolution.endpoint.effectedPolicySelect.perPolicy": "ポリシー単位", "xpack.securitySolution.endpoint.eventFilters.fleetIntegration.title": "イベントフィルター", - "xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel": "Fleet統合ポリシーに戻る", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "Endpoint Security統合に戻る", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "イベントフィルター", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersManageLabel": "イベントフィルターの管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error": "イベントフィルター統計情報の取得中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "イベントフィルター統計情報の取得中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "合計", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsManageLabel": "ホスト分離例外の管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel": "Endpoint Security統合に戻る", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error": "ホスト分離例外統計情報の取得中にエラーが発生しました:\"{error}\"", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.label": "ホスト分離例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.manageLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "信頼できるアプリケーションを管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppshortLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "信頼できるアプリケーション", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "信頼できるアプリ統計情報の取得中にエラーが発生しました:\"{error}\"", "xpack.securitySolution.endpoint.hostIsolation.cancel": "キャンセル", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "関連付けられたケースが見つかりませんでした", "xpack.securitySolution.endpoint.hostIsolation.comment": "コメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3134de6bd6f44d..e1b4231f9479d2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2662,7 +2662,6 @@ "discover.advancedSettings.defaultColumnsText": "Discover 应用中默认显示的列。如果为空,将显示文档摘要。", "discover.advancedSettings.defaultColumnsTitle": "默认列", "discover.advancedSettings.disableDocumentExplorer": "Document Explorer 或经典视图", - "discover.advancedSettings.disableDocumentExplorerDescription": "要使用新的 Document Explorer,而非经典视图,请关闭此选项。Document Explorer 提供了更合理的数据排序、可调整大小的列和全屏视图。", "discover.advancedSettings.discover.fieldStatisticsLinkText": "字段统计信息视图", "discover.advancedSettings.discover.modifyColumnsOnSwitchText": "移除新数据视图中不存在的列。", "discover.advancedSettings.discover.modifyColumnsOnSwitchTitle": "在更改数据视图时修改列", @@ -6928,9 +6927,9 @@ "xpack.apm.correlations.failedTransactions.helpPopover.performanceExplanation": "此分析会对大量属性执行统计搜索。对于较大时间范围和具有高事务吞吐量的服务,这可能需要些时间。减少时间范围以改善性能。", "xpack.apm.correlations.failedTransactions.helpPopover.tableExplanation": "表按分数排序,分数映射高、中或低影响级别。具有高影响级别的属性更可能造成事务失败。", "xpack.apm.correlations.failedTransactions.helpPopover.title": "失败事务相关性", - "xpack.apm.correlations.failedTransactions.highImpactText": "高", - "xpack.apm.correlations.failedTransactions.lowImpactText": "低", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "中", + "xpack.apm.correlations.highImpactText": "高", + "xpack.apm.correlations.lowImpactText": "低", + "xpack.apm.correlations.mediumImpactText": "中", "xpack.apm.correlations.failedTransactions.panelTitle": "失败事务延迟分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相关性", "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "筛留 {fieldName}:“{value}”", @@ -7075,8 +7074,6 @@ "xpack.apm.fleet_integration.settings.apm.urlLabel": "URL", "xpack.apm.fleet_integration.settings.apm.writeTimeoutLabel": "写入响应的最大持续时间", "xpack.apm.fleet_integration.settings.apmAgent.description": "为 {title} 应用程序配置检测。", - "xpack.apm.fleet_integration.settings.betaBadgeLabel": "公测版", - "xpack.apm.fleet_integration.settings.betaBadgeTooltip": "此模块不是 GA 版。请通过报告错误来帮助我们。", "xpack.apm.fleet_integration.settings.disabledLabel": "已禁用", "xpack.apm.fleet_integration.settings.enabledLabel": "已启用", "xpack.apm.fleet_integration.settings.optionalLabel": "可选", @@ -20986,7 +20983,6 @@ "xpack.observability.seriesEditor.edit": "编辑序列", "xpack.observability.seriesEditor.hide": "隐藏序列", "xpack.observability.seriesEditor.sampleDocuments": "在新选项卡中查看样例文档", - "xpack.observability.status.addIntegrationLink": "添加", "xpack.observability.status.learnMoreButton": "了解详情", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "使用通配符 *{wildcard}*", @@ -24084,26 +24080,11 @@ "xpack.securitySolution.endpoint.effectedPolicySelect.global": "全局", "xpack.securitySolution.endpoint.effectedPolicySelect.perPolicy": "按策略", "xpack.securitySolution.endpoint.eventFilters.fleetIntegration.title": "事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.artifacts.backButtonLabel": "返回到 Fleet 集成策略", "xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel": "返回到 Endpoint Security 集成", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersLabel": "事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersManageLabel": "管理事件筛选", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummary.error": "尝试提取事件筛选统计时出错:“{error}”", - "xpack.securitySolution.endpoint.fleetCustomExtension.eventFiltersSummaryError": "尝试提取事件筛选统计时出错:“{error}”", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.linux": "Linux", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.macos": "Mac", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.total": "合计", "xpack.securitySolution.endpoint.fleetCustomExtension.exceptionItemsSummary.windows": "Windows", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsManageLabel": "管理主机隔离例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.backButtonLabel": "返回到 Endpoint Security 集成", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.error": "尝试提取主机隔离例外统计时出错:“{error}”", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.label": "主机隔离例外", - "xpack.securitySolution.endpoint.fleetCustomExtension.hostIsolationExceptionsSummary.manageLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageEventFiltersLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppLinkLabel": "管理受信任的应用程序", - "xpack.securitySolution.endpoint.fleetCustomExtension.manageTrustedAppshortLinkLabel": "管理", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsLabel": "受信任的应用程序", - "xpack.securitySolution.endpoint.fleetCustomExtension.trustedAppsSummaryError": "尝试提取受信任应用统计时出错:“{error}”", "xpack.securitySolution.endpoint.hostIsolation.cancel": "取消", "xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title": "无法找到关联案例", "xpack.securitySolution.endpoint.hostIsolation.comment": "注释", diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index 182069665c4e73..03beae1108bbdd 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -68,17 +68,24 @@ async function copySourceAndBabelify() { buffer: true, nodir: true, ignore: [ - '**/*.{md,asciidoc}', + '**/*.{md,mdx,asciidoc}', '**/jest.config.js', + '**/jest.config.dev.js', + '**/jest_setup.js', + '**/jest.integration.config.js', + '**/*.stories.js', '**/*.{test,test.mocks,mock,mocks}.*', '**/*.d.ts', '**/node_modules/**', '**/public/**/*.{js,ts,tsx,json,scss}', - '**/{__tests__,__mocks__,__snapshots__,__fixtures__,__jest__,cypress}/**', + '**/{test,__tests__,__mocks__,__snapshots__,__fixtures__,__jest__,cypress,fixtures}/**', 'plugins/*/target/**', 'plugins/canvas/shareable_runtime/test/**', 'plugins/screenshotting/chromium/**', 'plugins/telemetry_collection_xpack/schema/**', // Skip telemetry schemas + 'plugins/apm/ftr_e2e/**', + 'plugins/apm/scripts/**', + 'plugins/lists/server/scripts/**', ], allowEmpty: true, } diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 3f6e3f5137c326..78e5dd1f2f2c3d 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -18,7 +18,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const toasts = getService('toasts'); - describe('Kibana spaces page meets a11y validations', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100968 + describe.skip('Kibana spaces page meets a11y validations', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); await PageObjects.common.navigateToApp('home'); diff --git a/x-pack/test/accessibility/apps/upgrade_assistant.ts b/x-pack/test/accessibility/apps/upgrade_assistant.ts index 850cb5de52bbad..1f7fd2a654bcad 100644 --- a/x-pack/test/accessibility/apps/upgrade_assistant.ts +++ b/x-pack/test/accessibility/apps/upgrade_assistant.ts @@ -143,8 +143,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - // Failing: See https://github.com/elastic/kibana/issues/115859 - it.skip('Index settings deprecation flyout', async () => { + it('Index settings deprecation flyout', async () => { await PageObjects.upgradeAssistant.clickEsDeprecation( 'indexSettings' // An index setting deprecation was added in the before() hook so should be guaranteed ); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts new file mode 100644 index 00000000000000..55d4a72643c86a --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts @@ -0,0 +1,456 @@ +/* + * 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 { Spaces } from '../../scenarios'; +import { + getUrlPrefix, + ObjectRemover, + getTestRuleData, + getEventLog, + ESTestIndexTool, +} from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetExecutionLogTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('es'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + const dateStart = new Date(Date.now() - 600000).toISOString(); + + describe('getExecutionLog', () => { + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + afterEach(() => objectRemover.removeAll()); + + it(`handles non-existent rule`, async () => { + await supertest + .get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rule/1/_execution_log?date_start=${dateStart}` + ) + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + + it('gets execution log for rule with executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(2); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(2); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // default sort is `desc` by timestamp + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(dateStart)); + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(new Date().toISOString())); + + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // no-op rule doesn't query ES + expect(log.total_search_duration_ms).to.equal(0); + expect(log.es_search_duration_ms).to.equal(0); + } + }); + + it('gets execution log for rule with no executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data).to.eql([]); + }); + + it('gets execution log for rule that performs ES searches', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.multipleSearches', + params: { + numSearches: 2, + delay: `2s`, + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // rule executes 2 searches with delay of 2 seconds each + // setting compare threshold lower to avoid flakiness + expect(log.total_search_duration_ms).to.be.greaterThan(2000); + expect(log.es_search_duration_ms).to.be.greaterThan(2000); + } + }); + + it('gets execution log for rule that errors', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.throw', + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('failure'); + expect(log.timed_out).to.equal(false); + } + }); + + it('gets execution log for rule that times out', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternLongRunning', + params: { + pattern: [true, true, true, true], + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(true); + } + }); + + it('gets execution log for rule that triggers actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'noop connector', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(1); + expect(log.num_errored_actions).to.equal(0); + } + }); + + it('gets execution log for rule that has failed actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'connector that throws', + connector_type_id: 'test.throw', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(1); + } + }); + + it('handles date_end if specified', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + + // set the date end to date start - should filter out all execution logs + const earlierDateStart = new Date(new Date(dateStart).getTime() - 900000).toISOString(); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${earlierDateStart}&date_end=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data.length).to.eql(0); + }); + + it('handles sort query parameter', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '5s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 3 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}&sort=[{"timestamp":{"order":"asc"}}]` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(3); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(3); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // sorting by `asc` timestamp + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + } + }); + + it(`handles invalid date_start`, async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=X0X0-08-08T08:08:08.008Z` + ) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Invalid date for parameter dateStart: "X0X0-08-08T08:08:08.008Z"', + }); + }); + }); + + async function waitForEvents( + id: string, + provider: string, + actions: Map< + string, + { + gte: number; + } + > + ) { + await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider, + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 242c6ffcba10f4..14c8268ce80e08 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./get_alert_summary')); + loadTestFile(require.resolve('./get_execution_log')); loadTestFile(require.resolve('./rule_types')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./execution_status')); diff --git a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts index 737e2c21cf0f66..f943378201dfd2 100644 --- a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts @@ -31,7 +31,8 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - describe('update_filters', function () { + // FLAKY: https://github.com/elastic/kibana/issues/127678 + describe.skip('update_filters', function () { const updateFilterRequestBody = { description: 'Updated filter #1', removeItems: items, diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts index fedf27f2cc1640..4ee7f6966b64af 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import { JobType } from '../../../../../plugins/ml/common/types/saved_objects'; +import { MlSavedObjectType } from '../../../../../plugins/ml/common/types/saved_objects'; export default ({ getService }: FtrProviderContext) => { const ml = getService('ml'); @@ -22,7 +22,7 @@ export default ({ getService }: FtrProviderContext) => { const idSpace2 = 'space2'; async function runRequest( - jobType: JobType, + mlSavedObjectType: MlSavedObjectType, ids: string[], user: USER, expectedStatusCode: number, @@ -32,7 +32,7 @@ export default ({ getService }: FtrProviderContext) => { .post( `${ space ? `/s/${space}` : '' - }/api/ml/saved_objects/can_delete_ml_space_aware_item/${jobType}` + }/api/ml/saved_objects/can_delete_ml_space_aware_item/${mlSavedObjectType}` ) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts index 88f93496c5ad69..149d75429d9772 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts @@ -10,7 +10,7 @@ import { cloneDeep } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import { JobType, TrainedModelType } from '../../../../../plugins/ml/common/types/saved_objects'; +import { MlSavedObjectType } from '../../../../../plugins/ml/common/types/saved_objects'; export default ({ getService }: FtrProviderContext) => { const ml = getService('ml'); @@ -35,14 +35,14 @@ export default ({ getService }: FtrProviderContext) => { async function runSyncCheckRequest( user: USER, - jobType: JobType | TrainedModelType, + mlSavedObjectType: MlSavedObjectType, expectedStatusCode: number ) { const { body, status } = await supertest .post(`/s/${idSpace1}/api/ml/saved_objects/sync_check`) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) - .send({ jobType }); + .send({ mlSavedObjectType }); ml.api.assertResponseStatusCode(expectedStatusCode, status, body); return body; diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts b/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts index 680b51d55ebd06..ccd4b60d241fbe 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/cloud_backup_status.ts @@ -51,6 +51,10 @@ export default function ({ getService }: FtrProviderContext) { }; describe('Cloud backup status', function () { + // file system repositories are not supported in cloud + this.tags(['skipCloud']); + this.onlyEsVersion('<=7'); + describe('get', () => { describe('with backups present', () => { // Needs SnapshotInfo type https://github.com/elastic/elasticsearch-specification/issues/685 diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts b/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts index b221e5e3159520..7ca6fbc9ed4d58 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/cluster_settings.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); - describe.skip('Cluster settings', () => { + describe('Cluster settings', function () { + this.onlyEsVersion('<=7'); + describe('POST /api/upgrade_assistant/cluster_settings', () => { before(async () => { try { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts index 2e9131a06b5e32..66bf9a46d05d52 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecation_logs.ts @@ -21,7 +21,9 @@ export default function ({ getService }: FtrProviderContext) { const { createDeprecationLog, deleteDeprecationLogs } = initHelpers(getService); - describe('Elasticsearch deprecation logs', () => { + describe('Elasticsearch deprecation logs', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/deprecation_logging', () => { describe('/count', () => { it('should filter out the deprecation from Elastic products', async () => { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts index 33706ae42e4c54..7995408eeb1ce1 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/es_deprecations.ts @@ -12,7 +12,9 @@ export default function ({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const security = getService('security'); - describe('Elasticsearch deprecations', () => { + describe('Elasticsearch deprecations', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/es_deprecations', () => { describe('error handling', () => { it('handles auth error', async () => { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/index.ts b/x-pack/test/api_integration/apis/upgrade_assistant/index.ts index 961f95714eaa00..21aeea27e61740 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/index.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/index.ts @@ -8,7 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('Upgrade Assistant', () => { + describe('Upgrade Assistant', function () { loadTestFile(require.resolve('./upgrade_assistant')); loadTestFile(require.resolve('./cloud_backup_status')); loadTestFile(require.resolve('./privileges')); @@ -16,5 +16,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./es_deprecation_logs')); loadTestFile(require.resolve('./remote_clusters')); loadTestFile(require.resolve('./cluster_settings')); + loadTestFile(require.resolve('./version_precheck')); + loadTestFile(require.resolve('./node_disk_space')); }); } diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts b/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts index fce68f4a344c94..0a023c8a2065e4 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/node_disk_space.ts @@ -13,7 +13,9 @@ import { API_BASE_PATH } from '../../../../plugins/upgrade_assistant/common/cons export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - describe('Node disk space', () => { + describe('Node disk space', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/node_disk_space', () => { it('returns an array of nodes', async () => { const { body: apiRequestResponse } = await supertest diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts b/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts index c5c00c9a33685d..9bac04bf1d48ee 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/privileges.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - describe('Privileges', () => { + describe('Privileges', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/privileges', () => { it('User with with index privileges', async () => { const { body } = await supertest diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts b/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts index 5d8dcaf339068d..412f5d2a0c7fda 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/remote_clusters.ts @@ -15,7 +15,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); - describe('Remote clusters', () => { + describe('Remote clusters', function () { + this.onlyEsVersion('<=7'); + describe('GET /api/upgrade_assistant/remote_clusters', () => { before(async () => { try { diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts b/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts index 836048f3792149..8a47c90c81ff3c 100644 --- a/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts +++ b/x-pack/test/api_integration/apis/upgrade_assistant/upgrade_assistant.ts @@ -14,7 +14,9 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const supertest = getService('supertest'); - describe.skip('Upgrade Assistant', () => { + describe('Upgrade Assistant', function () { + this.onlyEsVersion('<=7'); + describe('Reindex operation saved object', () => { const dotKibanaIndex = '.kibana'; const fakeSavedObjectId = 'fakeSavedObjectId'; diff --git a/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts b/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts new file mode 100644 index 00000000000000..cd85b048dc8f6a --- /dev/null +++ b/x-pack/test/api_integration/apis/upgrade_assistant/version_precheck.ts @@ -0,0 +1,218 @@ +/* + * 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 { API_BASE_PATH } from '../../../../plugins/upgrade_assistant/common/constants'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + // Tests only applicable on 7.17 + describe.skip('Elasticsearch version precheck', function () { + this.onlyEsVersion('>=8'); + + describe('Elasticsearch 8.x running against Kibana 7.last', () => { + describe('Cloud backup status APIs', () => { + it('returns 426 for GET /cloud_backup_status', async () => { + await supertest + .get(`${API_BASE_PATH}/cloud_backup_status`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Cloud upgrade status APIs', () => { + it('returns 426 for GET /cluster_upgrade_status', async () => { + await supertest + .get(`${API_BASE_PATH}/cluster_upgrade_status`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Deprecation logging APIs', () => { + it('returns 426 for GET /deprecation_logging', async () => { + await supertest + .get(`${API_BASE_PATH}/deprecation_logging`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for PUT /deprecation_logging', async () => { + await supertest + .put(`${API_BASE_PATH}/deprecation_logging`) + .set('kbn-xsrf', 'xxx') + .send({ + isEnabled: true, + }) + .expect(426); + }); + + it('returns 426 for DELETE /deprecation_logging/cache', async () => { + await supertest + .delete(`${API_BASE_PATH}/deprecation_logging/cache`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /deprecation_logging/count', async () => { + await supertest + .get(`${API_BASE_PATH}/deprecation_logging/count?from=2021-08-23T07:32:34.782Z`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('ES deprecations APIs', () => { + it('returns 426 for GET /es_deprecations', async () => { + await supertest + .get(`${API_BASE_PATH}/es_deprecations`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Remote clusters APIs', () => { + it('returns 426 for GET /remote_clusters', async () => { + await supertest + .get(`${API_BASE_PATH}/remote_clusters`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('System indices migration APIs', () => { + it('returns 426 for GET /system_indices_migration', async () => { + await supertest + .get(`${API_BASE_PATH}/system_indices_migration`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /system_indices_migration', async () => { + await supertest + .post(`${API_BASE_PATH}/system_indices_migration`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Status APIs', () => { + it('returns 426 for GET /status', async () => { + await supertest.get(`${API_BASE_PATH}/status`).set('kbn-xsrf', 'xxx').expect(426); + }); + }); + + describe('Privileges APIs', () => { + it('returns 426 for GET /privileges', async () => { + await supertest.get(`${API_BASE_PATH}/privileges`).set('kbn-xsrf', 'xxx').expect(426); + }); + }); + + describe('Index settings APIs', () => { + it('returns 426 for POST /{indexName}/index_settings', async () => { + await supertest + .post(`${API_BASE_PATH}/test_index/index_settings`) + .set('kbn-xsrf', 'xxx') + .send({ + settings: ['index_settings'], + }) + .expect(426); + }); + }); + + describe('Cluster settings APIs', () => { + it('returns 426 for POST /cluster_settings', async () => { + await supertest + .post(`${API_BASE_PATH}/cluster_settings`) + .set('kbn-xsrf', 'xxx') + .send({ + settings: ['cluster_settings'], + }) + .expect(426); + }); + }); + + describe('Machine learning APIs', () => { + it('returns 426 for GET /ml_snapshots/{jobId}/{snapshotId}', async () => { + await supertest + .get(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /ml_upgrade_mode', async () => { + await supertest + .get(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /ml_snapshots', async () => { + await supertest + .post(`${API_BASE_PATH}/ml_snapshots`) + .set('kbn-xsrf', 'xxx') + .send({ + snapshotId: 'snapshot_1', + jobId: 'job_1', + }) + .expect(426); + }); + + it('returns 426 for DELETE /ml_snapshots/{jobId}/{snapshotId}', async () => { + await supertest + .delete(`${API_BASE_PATH}/ml_snapshots/job_1/snapshot_1`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + }); + + describe('Reindex APIs', () => { + it('returns 426 for POST /reindex/{indexName}', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/test_index`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for GET /reindex/{indexName}', async () => { + await supertest + .get(`${API_BASE_PATH}/reindex/test_index`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /reindex/{indexName}/cancel', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/test_index/cancel`) + .set('kbn-xsrf', 'xxx') + .send({ + indexNames: ['test_index'], + }) + .expect(426); + }); + + it('returns 426 for GET /reindex/batch/queue', async () => { + await supertest + .get(`${API_BASE_PATH}/reindex/batch/queue`) + .set('kbn-xsrf', 'xxx') + .expect(426); + }); + + it('returns 426 for POST /reindex/batch', async () => { + await supertest + .post(`${API_BASE_PATH}/reindex/batch`) + .set('kbn-xsrf', 'xxx') + .send({ + indexNames: ['test_index'], + }) + .expect(426); + }); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts index e5062961e2f2c6..1cdd59777c1c54 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts @@ -254,7 +254,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(correlation?.fieldValue).to.be('success'); expect(correlation?.correlation).to.be(0.6275246559191225); expect(correlation?.ksTest).to.be(4.806503252860024e-13); - expect(correlation?.histogram.length).to.be(101); + expect(correlation?.histogram?.length).to.be(101); const fieldStats = finalRawResponse?.fieldStats?.[0]; expect(typeof fieldStats).to.be('object'); diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 253e19c2db1b1f..8dc12750b81098 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -21,7 +21,8 @@ export default function (providerContext: FtrProviderContext) { describe('setup api', async () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); - describe('setup performs upgrades', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/118479 + describe.skip('setup performs upgrades', async () => { const oldEndpointVersion = '0.13.0'; beforeEach(async () => { const url = '/api/fleet/epm/packages/endpoint'; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts deleted file mode 100644 index 69a1f82cd3a678..00000000000000 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ /dev/null @@ -1,273 +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 { FtrProviderContext } from '../ftr_provider_context'; -import { UsageStats } from '../services/usage'; - -// These all have the domain name portion stripped out. The api infrastructure assumes it when we post to it anyhow. -const PDF_PRINT_DASHBOARD_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F2ae34a60-3dd4-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%27145ced90-3dcb-11e8-8660-4d65aa086b3c!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:visualization,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!f,title:!%27couple%2Bpanels!%27,viewMode:view)%27),title:%27couple%20panels%27)'; -const PDF_PRESERVE_DASHBOARD_FILTER_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:439,width:1362),id:preserve_layout),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F61c58ad0-3dd3-11e8-b2b9-5d5dc1715159%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(description:!%27!%27,filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal:(query:dog,type:phrase))))),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!%271!%27,w:24,x:0,y:0),id:!%2750643b60-3dd3-11e8-b2b9-5d5dc1715159!%27,panelIndex:!%271!%27,type:visualization,version:!%276.3.0!%27),(embeddableConfig:(),gridData:(h:15,i:!%272!%27,w:24,x:24,y:0),id:a16d1990-3dca-11e8-8660-4d65aa086b3c,panelIndex:!%272!%27,type:search,version:!%276.3.0!%27)),query:(language:lucene,query:!%27!%27),timeRestore:!!t,title:!%27dashboard%2Bwith%2Bfilter!%27,viewMode:view)%27),title:%27dashboard%20with%20filter%27)'; -const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(dimensions:(height:441,width:1002),id:preserve_layout),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2F3fe22200-3dcb-11e8-8660-4d65aa086b3c%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!(),linked:!!f,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:bytes,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Rendering%2BTest:%2Bpie!%27,type:pie))%27),title:%27Rendering%20Test:%20pie%27)'; -const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = - '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FNew_York,layout:(id:print),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2Fbefdb6b0-3e59-11e8-9fc3-39e49624228e%3F_g%3D(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!%27Mon%2BApr%2B09%2B2018%2B17:56:08%2BGMT-0400!%27,mode:absolute,to:!%27Wed%2BApr%2B11%2B2018%2B17:56:08%2BGMT-0400!%27))%26_a%3D(filters:!!((!%27$state!%27:(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!%271!%27,otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!%27Filter%2BTest:%2Banimals:%2Blinked%2Bto%2Bsearch%2Bwith%2Bfilter!%27,type:pie))%27),title:%27Filter%20Test:%20animals:%20linked%20to%20search%20with%20filter%27)'; -const JOB_PARAMS_CSV_DEFAULT_SPACE = - `/api/reporting/generate/csv_searchsource?jobParams=(columns:!(order_date,category,customer_full_name,taxful_total_price,currency),objectType:search,searchSource:(fields:!((field:'*',include_unmapped:true))` + - `,filter:!((meta:(field:order_date,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,params:()),query:(range:(order_date:(format:strict_date_optional_time,gte:'2019-06-02T12:28:40.866Z'` + - `,lte:'2019-07-18T20:59:57.136Z'))))),index:aac3e500-f2c7-11ea-8250-fb138aa491e7,parent:(filter:!(),highlightAll:!t,index:aac3e500-f2c7-11ea-8250-fb138aa491e7` + - `,query:(language:kuery,query:''),version:!t),sort:!((order_date:desc)),trackTotalHits:!t))`; -const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; -const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const reportingAPI = getService('reportingAPI'); - const retry = getService('retry'); - const usageAPI = getService('usageAPI'); - - describe('Usage', () => { - const deleteAllReports = () => reportingAPI.deleteAllReports(); - beforeEach(deleteAllReports); - after(deleteAllReports); - - describe('initial state', () => { - let usage: UsageStats; - - before(async () => { - await retry.try(async () => { - // use retry for stability - usage API could return 503 - usage = (await usageAPI.getUsageStats()) as UsageStats; - }); - }); - - it('shows reporting as available and enabled', async () => { - expect(usage.reporting.available).to.be(true); - expect(usage.reporting.enabled).to.be(true); - }); - - it('all counts are 0', async () => { - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 0); - }); - }); - - describe('from archive data', () => { - it('generated from 6.2', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_2'); - const usage = await usageAPI.getUsageStats(); - - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 7); - - // These statistics weren't tracked until 6.3 - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); - - await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_2'); - }); - - it('generated from 6.3', async () => { - await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_3'); - const usage = await usageAPI.getUsageStats(); - - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 12); - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 3); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 3); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 3); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 3); - - await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_3'); - }); - }); - - describe('from new jobs posted', () => { - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); - await esArchiver.load(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.initEcommerce(); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.teardownEcommerce(); - }); - - it('should handle csv_searchsource', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([reportingAPI.postJob(JOB_PARAMS_CSV_DEFAULT_SPACE)]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 1); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); - }); - - it('should handle preserve_layout pdf', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRESERVE_DASHBOARD_FILTER_6_3), - reportingAPI.postJob(PDF_PRESERVE_PIE_VISUALIZATION_6_3), - ]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 2); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); - }); - - it('should handle print_layout pdf', async () => { - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRINT_DASHBOARD_6_3), - reportingAPI.postJob(PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), - ]) - ); - - const usage = await usageAPI.getUsageStats(); - reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); - reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); - - reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1); - reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); - reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2); - }); - }); - - describe(`metrics and stats`, () => { - let reporting: UsageStats['reporting']; - let last7Days: UsageStats['reporting']['last_7_days']; - - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); - await esArchiver.load(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.initEcommerce(); - - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(PDF_PRINT_DASHBOARD_6_3), - reportingAPI.postJob(PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), - reportingAPI.postJob(JOB_PARAMS_CSV_DEFAULT_SPACE), - ]) - ); - - ({ reporting } = await usageAPI.getUsageStats()); - ({ last_7_days: last7Days } = reporting); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); - await reportingAPI.teardownEcommerce(); - }); - - it('includes report stats', async () => { - // over all time - expectSnapshot(reporting._all).toMatchInline(`undefined`); - expect(reporting.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); - expectSnapshot(reporting.status).toMatchInline(` - Object { - "completed": 3, - "failed": 0, - } - `); - - // over last 7 days - expectSnapshot(last7Days._all).toMatchInline(`undefined`); - expect(last7Days.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); - expectSnapshot(last7Days.status).toMatchInline(` - Object { - "completed": 3, - "failed": 0, - } - `); - }); - - it('includes report statuses', async () => { - expectSnapshot(reporting.statuses).toMatchInline(`Object {}`); - - expectSnapshot(last7Days.statuses).toMatchInline(`Object {}`); - }); - - it('includes report metrics (not for job types under last_7_days)', async () => { - expect(reporting.printable_pdf.sizes).keys([ - '1_0', - '25_0', - '50_0', - '5_0', - '75_0', - '95_0', - '99_0', - ]); - expectSnapshot(reporting.printable_pdf.metrics?.pdf_pages).toMatchInline(` - Object { - "values": Object { - "50_0": 1, - "75_0": 1, - "95_0": 1, - "99_0": 1, - }, - } - `); - expectSnapshot(reporting.csv_searchsource.metrics?.csv_rows).toMatchInline(` - Object { - "values": Object { - "50_0": 71, - "75_0": 71, - "95_0": 71, - "99_0": 71, - }, - } - `); - }); - }); - }); -} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts new file mode 100644 index 00000000000000..510e94cf95f0d9 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// These all have the domain name portion stripped out. The api infrastructure assumes it when we post to it anyhow. +export const PDF_PRINT_DASHBOARD_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(id:print),objectType:dashboard,relativeUrls:!('/app/kibana#/dashboard/2ae34a60-3dd4-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(description:!'!',filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!'1!',w:24,x:0,y:0),id:!'145ced90-3dcb-11e8-8660-4d65aa086b3c!',panelIndex:!'1!',type:visualization,version:!'6.3.0!'),(embeddableConfig:(),gridData:(h:15,i:!'2!',w:24,x:24,y:0),id:e2023110-3dcb-11e8-8660-4d65aa086b3c,panelIndex:!'2!',type:visualization,version:!'6.3.0!')),query:(language:lucene,query:!'!'),timeRestore:!!f,title:!'couple+panels!',viewMode:view)'),title:'couple panels')` +)}`; + +export const PDF_PRESERVE_DASHBOARD_FILTER_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(dimensions:(height:439,width:1362),id:preserve_layout),objectType:dashboard,relativeUrls:!('/app/kibana#/dashboard/61c58ad0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(description:!'!',filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal:(query:dog,type:phrase))))),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(),gridData:(h:15,i:!'1!',w:24,x:0,y:0),id:!'50643b60-3dd3-11e8-b2b9-5d5dc1715159!',panelIndex:!'1!',type:visualization,version:!'6.3.0!'),(embeddableConfig:(),gridData:(h:15,i:!'2!',w:24,x:24,y:0),id:a16d1990-3dca-11e8-8660-4d65aa086b3c,panelIndex:!'2!',type:search,version:!'6.3.0!')),query:(language:lucene,query:!'!'),timeRestore:!!t,title:!'dashboard+with+filter!',viewMode:view)'),title:'dashboard with filter')` +)}`; + +export const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(dimensions:(height:441,width:1002),id:preserve_layout),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/3fe22200-3dcb-11e8-8660-4d65aa086b3c?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!(),linked:!!f,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:bytes,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Rendering+Test:+pie!',type:pie))'),title:'Rendering Test: pie')` +)}`; + +export const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( + `(browserTimezone:America/New_York,layout:(id:print),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')` +)}`; + +export const JOB_PARAMS_CSV_DEFAULT_SPACE = `/api/reporting/generate/csv_searchsource?jobParams=${encodeURIComponent( + `(columns:!(order_date,category,customer_full_name,taxful_total_price,currency),objectType:search,searchSource:(fields:!((field:'*',include_unmapped:true)),filter:!((meta:(field:order_date,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,params:()),query:(range:(order_date:(format:strict_date_optional_time,gte:'2019-06-02T12:28:40.866Z',lte:'2019-07-18T20:59:57.136Z'))))),index:aac3e500-f2c7-11ea-8250-fb138aa491e7,parent:(filter:!(),highlightAll:!t,index:aac3e500-f2c7-11ea-8250-fb138aa491e7,query:(language:kuery,query:''),version:!t),sort:!((order_date:desc)),trackTotalHits:!t))` +)}`; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts new file mode 100644 index 00000000000000..f81b29cb655724 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/archived_data.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe('from archive data', () => { + it('generated from 6.2', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + const usage = await usageAPI.getUsageStats(); + + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 7); + + // These statistics weren't tracked until 6.3 + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); + + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + }); + + it('generated from 6.3', async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_3'); + const usage = await usageAPI.getUsageStats(); + + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 12); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 3); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 3); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 3); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 3); + + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_3'); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts new file mode 100644 index 00000000000000..5b6dc7cc31ab01 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Usage', () => { + const deleteAllReports = () => reportingAPI.deleteAllReports(); + beforeEach(deleteAllReports); + after(deleteAllReports); + + loadTestFile(require.resolve('./archived_data')); + loadTestFile(require.resolve('./initial')); + loadTestFile(require.resolve('./metrics')); + loadTestFile(require.resolve('./new_jobs')); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts new file mode 100644 index 00000000000000..3104de755da881 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/initial.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { UsageStats } from '../../services/usage'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + const retry = getService('retry'); + const usageAPI = getService('usageAPI'); + + describe('initial state', () => { + let usage: UsageStats; + + before(async () => { + await retry.try(async () => { + // use retry for stability - usage API could return 503 + usage = (await usageAPI.getUsageStats()) as UsageStats; + }); + }); + + it('shows reporting as available and enabled', async () => { + expect(usage.reporting.available).to.be(true); + expect(usage.reporting.enabled).to.be(true); + }); + + it('all counts are 0', async () => { + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 0); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts new file mode 100644 index 00000000000000..1ba9f5b55570e7 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts @@ -0,0 +1,111 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { UsageStats } from '../../services/usage'; +import * as urls from './_post_urls'; + +const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; +const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe(`metrics and stats`, () => { + let reporting: UsageStats['reporting']; + let last7Days: UsageStats['reporting']['last_7_days']; + + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); + await esArchiver.load(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.initEcommerce(); + + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRINT_DASHBOARD_6_3), + reportingAPI.postJob(urls.PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), + reportingAPI.postJob(urls.JOB_PARAMS_CSV_DEFAULT_SPACE), + ]) + ); + + ({ reporting } = await usageAPI.getUsageStats()); + ({ last_7_days: last7Days } = reporting); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.teardownEcommerce(); + }); + + it('includes report stats', async () => { + // over all time + expectSnapshot(reporting._all).toMatchInline(`undefined`); + expect(reporting.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); + expectSnapshot(reporting.status).toMatchInline(` + Object { + "completed": 3, + "failed": 0, + } + `); + + // over last 7 days + expectSnapshot(last7Days._all).toMatchInline(`undefined`); + expect(last7Days.output_size).keys(['1_0', '25_0', '50_0', '5_0', '75_0', '95_0', '99_0']); + expectSnapshot(last7Days.status).toMatchInline(` + Object { + "completed": 3, + "failed": 0, + } + `); + }); + + it('includes report statuses', async () => { + expectSnapshot(reporting.statuses).toMatchInline(`Object {}`); + + expectSnapshot(last7Days.statuses).toMatchInline(`Object {}`); + }); + + it('includes report metrics (not for job types under last_7_days)', async () => { + expect(reporting.printable_pdf.sizes).keys([ + '1_0', + '25_0', + '50_0', + '5_0', + '75_0', + '95_0', + '99_0', + ]); + expectSnapshot(reporting.printable_pdf.metrics?.pdf_pages).toMatchInline(` + Object { + "values": Object { + "50_0": 1, + "75_0": 1, + "95_0": 1, + "99_0": 1, + }, + } + `); + expectSnapshot(reporting.csv_searchsource.metrics?.csv_rows).toMatchInline(` + Object { + "values": Object { + "50_0": 71, + "75_0": 71, + "95_0": 71, + "99_0": 71, + }, + } + `); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts new file mode 100644 index 00000000000000..086f3373e2c71f --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import * as urls from './_post_urls'; + +const OSS_KIBANA_ARCHIVE_PATH = 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'; +const OSS_DATA_ARCHIVE_PATH = 'test/functional/fixtures/es_archiver/dashboard/current/data'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const reportingAPI = getService('reportingAPI'); + const usageAPI = getService('usageAPI'); + + describe('from new jobs posted', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load(OSS_KIBANA_ARCHIVE_PATH); + await esArchiver.load(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.initEcommerce(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload(OSS_DATA_ARCHIVE_PATH); + await reportingAPI.teardownEcommerce(); + }); + + it('should handle csv_searchsource', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([reportingAPI.postJob(urls.JOB_PARAMS_CSV_DEFAULT_SPACE)]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 0); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 1); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 0); + }); + + it('should handle preserve_layout pdf', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRESERVE_DASHBOARD_FILTER_6_3), + reportingAPI.postJob(urls.PDF_PRESERVE_PIE_VISUALIZATION_6_3), + ]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 2); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); + }); + + it('should handle print_layout pdf', async () => { + await reportingAPI.expectAllJobsToFinishSuccessfully( + await Promise.all([ + reportingAPI.postJob(urls.PDF_PRINT_DASHBOARD_6_3), + reportingAPI.postJob(urls.PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3), + ]) + ); + + const usage = await usageAPI.getUsageStats(); + reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); + reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); + + reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1); + reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); + reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts index 45e6b410baee5f..4f9b7ad7c0401c 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts @@ -47,7 +47,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should show the Trusted Apps page when link is clicked', async () => { await (await fleetIntegrations.findIntegrationDetailCustomTab()).click(); - await (await testSubjects.find('linkToTrustedApps')).click(); + await (await testSubjects.find('trustedApps-artifactsLink')).click(); await trustedApps.ensureIsOnTrustedAppsListPage(); }); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 45b52a00eb2469..cd56755192fa08 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -323,12 +323,31 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('should show trusted apps card and link should go back to policy', async () => { - await testSubjects.existOrFail('fleetTrustedAppsCard'); - await (await testSubjects.find('linkToTrustedApps')).click(); + await testSubjects.existOrFail('trustedApps-fleet-integration-card'); + await (await testSubjects.find('trustedApps-link-to-exceptions')).click(); await testSubjects.existOrFail('policyDetailsPage'); await (await testSubjects.find('policyDetailsBackLink')).click(); await testSubjects.existOrFail('endpointIntegrationPolicyForm'); }); + it('should show event filters card and link should go back to policy', async () => { + await testSubjects.existOrFail('eventFilters-fleet-integration-card'); + await (await testSubjects.find('eventFilters-link-to-exceptions')).click(); + await testSubjects.existOrFail('policyDetailsPage'); + await (await testSubjects.find('policyDetailsBackLink')).click(); + await testSubjects.existOrFail('endpointIntegrationPolicyForm'); + }); + it('should show blocklists card and link should go back to policy', async () => { + await testSubjects.existOrFail('blocklists-fleet-integration-card'); + const blocklistsCard = await testSubjects.find('blocklists-fleet-integration-card'); + await pageObjects.ingestManagerCreatePackagePolicy.scrollToCenterOfWindow(blocklistsCard); + await (await testSubjects.find('blocklists-link-to-exceptions')).click(); + await testSubjects.existOrFail('policyDetailsPage'); + await (await testSubjects.find('policyDetailsBackLink')).click(); + await testSubjects.existOrFail('endpointIntegrationPolicyForm'); + }); + it('should not show host isolation exceptions card because no entries', async () => { + await testSubjects.missingOrFail('hostIsolationExceptions-fleet-integration-card'); + }); }); }); } diff --git a/yarn.lock b/yarn.lock index 5f9ddfa8c8f97f..6df682be18360b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6363,10 +6363,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@12.20.24", "@types/node@16.10.2", "@types/node@>= 8", "@types/node@>=8.9.0", "@types/node@^10.1.0", "@types/node@^14.0.10", "@types/node@^14.14.31": - version "16.10.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.2.tgz#5764ca9aa94470adb4e1185fe2e9f19458992b2e" - integrity sha512-zCclL4/rx+W5SQTzFs9wyvvyCwoK9QtBpratqz2IYJ3O8Umrn0m3nsTv0wQBk9sRGpvUe9CwPDrQFB10f1FIjQ== +"@types/node@*", "@types/node@12.20.24", "@types/node@16.11.7", "@types/node@>= 8", "@types/node@>=8.9.0", "@types/node@^10.1.0", "@types/node@^14.0.10", "@types/node@^14.14.31": + version "16.11.7" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.7.tgz#36820945061326978c42a01e56b61cd223dfdc42" + integrity sha512-QB5D2sqfSjCmTuWcBWyJ+/44bcjO7VbjSbOE0ucoVbAsSNQc4Lt6QkgkVXkTDwkL4z/beecZNDvVX15D4P8Jbw== "@types/nodemailer@^6.4.0": version "6.4.0"