diff --git a/.eslintrc.js b/.eslintrc.js index c33f4de15b919..3cac46e7d2605 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -91,7 +91,6 @@ module.exports = { { files: ['x-pack/plugins/canvas/**/*.{js,ts,tsx}'], rules: { - 'react-hooks/exhaustive-deps': 'off', 'jsx-a11y/click-events-have-key-events': 'off', }, }, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4bf659345d387..959c12af90463 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -661,18 +661,23 @@ To build the docs, you must clone the [elastic/docs](https://github.com/elastic/ repo as a sibling of your kibana repo. Follow the instructions in that project's README for getting the docs tooling set up. -**To build the docs and open them in your browser:** +**To build the Kibana docs and open them in your browser:** + +```bash +./docs/build_docs --doc kibana/docs/index.asciidoc --chunk 1 --open +``` +or ```bash node scripts/docs.js --open ``` -### Release Notes Process +### Release Notes process Part of this process only applies to maintainers, since it requires access to GitHub labels. -Kibana publishes major, minor and patch releases periodically through the year. During this process we run a script against this repo to collect the applicable PRs against that release and generate [Release Notes](https://www.elastic.co/guide/en/kibana/current/release-notes.html). -To include your change in the Release Notes: +Kibana publishes [Release Notes](https://www.elastic.co/guide/en/kibana/current/release-notes.html) for major and minor releases. To generate the Release Notes, the writers run a script against this repo to collect the merged PRs against the release. +To include your PRs in the Release Notes: 1. In the title, summarize what the PR accomplishes in language that is meaningful to the user. In general, use present tense (for example, Adds, Fixes) in sentence case. 2. Label the PR with the targeted version (ex: `v7.3.0`). @@ -681,9 +686,9 @@ To include your change in the Release Notes: * For an external-facing fix, use `release_note:fix`. Exception: docs, build, and test fixes do not go in the Release Notes. Neither fixes for issues that were only on `master` and never have been released. * For a deprecated feature, use `release_note:deprecation`. * For a breaking change, use `release_note:breaking`. - * To **NOT** include your changes in the Release Notes, please use `release_note:skip`. + * To **NOT** include your changes in the Release Notes, use `release_note:skip`. -We also produce a blog post that details more important breaking API changes every minor and major release. If the PR includes a breaking API change, apply the label `release_note:dev_docs`. Additionally add a brief summary of the break at the bottom of the PR using the format below: +We also produce a blog post that details more important breaking API changes in every major and minor release. When your PR includes a breaking API change, add the `release_note:dev_docs` label, and add a brief summary of the break at the bottom of the PR using the format below: ``` # Dev Docs diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md new file mode 100644 index 0000000000000..1923f0e2e4ea1 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [getSearchParamsFromRequest](./kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md) + +## getSearchParamsFromRequest() function + +Signature: + +```typescript +export declare function getSearchParamsFromRequest(searchRequest: SearchRequest, dependencies: { + injectedMetadata: CoreStart['injectedMetadata']; + uiSettings: IUiSettingsClient; +}): { + rest_total_hits_as_int: boolean; + ignore_unavailable: boolean; + ignore_throttled: boolean; + max_concurrent_shard_requests: any; + preference: any; + timeout: string | undefined; + index: any; + body: any; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| searchRequest | SearchRequest | | +| dependencies | {
injectedMetadata: CoreStart['injectedMetadata'];
uiSettings: IUiSettingsClient;
} | | + +Returns: + +`{ + rest_total_hits_as_int: boolean; + ignore_unavailable: boolean; + ignore_throttled: boolean; + max_concurrent_shard_requests: any; + preference: any; + timeout: string | undefined; + index: any; + body: any; +}` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index e818fb009fb19..bc1eb9100e85c 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -40,6 +40,7 @@ | [getEsPreference(uiSettings, sessionId)](./kibana-plugin-plugins-data-public.getespreference.md) | | | [getQueryLog(uiSettings, storage, appName, language)](./kibana-plugin-plugins-data-public.getquerylog.md) | | | [getSearchErrorType({ message })](./kibana-plugin-plugins-data-public.getsearcherrortype.md) | | +| [getSearchParamsFromRequest(searchRequest, dependencies)](./kibana-plugin-plugins-data-public.getsearchparamsfromrequest.md) | | | [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | | diff --git a/docs/drilldowns/drilldowns.asciidoc b/docs/drilldowns/drilldowns.asciidoc new file mode 100644 index 0000000000000..2687441c99340 --- /dev/null +++ b/docs/drilldowns/drilldowns.asciidoc @@ -0,0 +1,108 @@ +[[drilldowns]] +== Use drilldowns for dashboard actions + +Drilldowns, also known as custom actions, allow you to configure a +workflow for analyzing and troubleshooting your data. +Using a drilldown, you can navigate from one dashboard to another, +taking the current time range, filters, and other parameters with you, +so the context remains the same. You can continue your analysis from a new perspective. + +For example, you might have a dashboard that shows the overall status of multiple data centers. +You can create a drilldown that navigates from this dashboard to a dashboard +that shows a single data center or server. + +[float] +[[how-drilldowns-work]] +=== How drilldowns work + +Drilldowns are {kib} actions that you configure and store +in the dashboard saved object. Drilldowns are specific to the dashboard panel +for which you create them—they are not shared across panels. +A panel can have multiple drilldowns. + +This example shows a dashboard panel that contains a pie chart. +Typically, clicking a pie slice applies the current filter. +When a panel has a drilldown, clicking a pie slice opens a menu with +the default action and your drilldowns. Refer to the <> +for instructions on how to create this drilldown. + +[role="screenshot"] +image::images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] + +Third-party developers can create drilldowns. +Refer to https://github.com/elastic/kibana/tree/master/x-pack/examples/ui_actions_enhanced_examples[this example plugin] +to learn how to code drilldowns. + +[float] +[[create-manage-drilldowns]] +=== Create and manage drilldowns + +Your dashboard must be in *Edit* mode to create a drilldown. +Once a panel has at least one drilldown, the menu also includes a *Manage drilldowns* action +for editing and deleting drilldowns. + +[role="screenshot"] +image::images/drilldown_menu.png[Panel menu with Create drilldown and Manage drilldown actions] + +[float] +[[drilldowns-example]] +=== Try it: Create a drilldown + +This example shows how to create the *Host Overview* drilldown shown earlier in this doc. + +[float] +==== Set up the dashboards + +. Add the <> data set. + +. Create a new dashboard, called `Host Overview`, and include these visualizations +from the sample data set: ++ +[%hardbreaks] +*[Logs] Heatmap* +*[Logs] Visitors by OS* +*[Logs] Host, Visits, and Bytes Table* +*[Logs] Total Requests and Bytes* ++ +TIP: If you don’t see data for a panel, try changing the time range. + +. Open the *[Logs] Web traffic* dashboard. + +. Set a search and filter. ++ +[%hardbreaks] +Search: `extension.keyword:( “gz” or “css” or “deb”)` +Filter: `geo.src : CN` + +[float] +==== Create the drilldown + + +. In the dashboard menu bar, click *Edit*. + +. In *[Logs] Visitors by OS*, open the panel menu, and then select *Create drilldown*. + +. Give the drilldown a name. + +. Select *Host Overview* as the destination dashboard. + +. Keep both filters enabled so that the drilldown carries over the global filters and date range. ++ +Your input should look similar to this: ++ +[role="screenshot"] +image::images/drilldown_create.png[Create drilldown with entries for drilldown name and destination] + +. Click *Create drilldown.* + +. Save the dashboard. ++ +If you don’t save the drilldown, and then navigate away, the drilldown is lost. + +. In *[Logs] Visitors by OS*, click the `win 8` slice of the pie, and then select the name of your drilldown. ++ +[role="screenshot"] +image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to another dashboard] ++ +You are navigated to your destination dashboard. Verify that the search query, filters, +and time range are carried over. diff --git a/docs/drilldowns/images/drilldown_create.png b/docs/drilldowns/images/drilldown_create.png new file mode 100644 index 0000000000000..1573a3dfb4f4f Binary files /dev/null and b/docs/drilldowns/images/drilldown_create.png differ diff --git a/docs/drilldowns/images/drilldown_menu.png b/docs/drilldowns/images/drilldown_menu.png new file mode 100644 index 0000000000000..28e0d995f840d Binary files /dev/null and b/docs/drilldowns/images/drilldown_menu.png differ diff --git a/docs/drilldowns/images/drilldown_on_panel.png b/docs/drilldowns/images/drilldown_on_panel.png new file mode 100644 index 0000000000000..591f3280c7eca Binary files /dev/null and b/docs/drilldowns/images/drilldown_on_panel.png differ diff --git a/docs/drilldowns/images/drilldown_on_piechart.gif b/docs/drilldowns/images/drilldown_on_piechart.gif new file mode 100644 index 0000000000000..c9b3311df0325 Binary files /dev/null and b/docs/drilldowns/images/drilldown_on_piechart.gif differ diff --git a/docs/settings/telemetry-settings.asciidoc b/docs/settings/telemetry-settings.asciidoc index a5f5d1c44ffac..89c018a86eca6 100644 --- a/docs/settings/telemetry-settings.asciidoc +++ b/docs/settings/telemetry-settings.asciidoc @@ -29,7 +29,7 @@ See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to lea | Set to `'server'` to report the cluster statistics from the {kib} server. If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes it is behind a firewall and falls back to `'browser'` to send it from users' browsers - when they are navigating through {kib}. Defaults to 'browser'. + when they are navigating through {kib}. Defaults to `'server'`. | `telemetry.optIn` | Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through diff --git a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc index b3e401256f27b..e41bd0939495d 100644 --- a/docs/user/alerting/action-types/pre-configured-connectors.asciidoc +++ b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc @@ -28,7 +28,7 @@ two out-of-the box connectors: <> and < actionTypeId: .slack <2> name: 'Slack #xyz' <3> - config: <4> + secrets: <4> webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' webhook-service: actionTypeId: .webhook diff --git a/docs/user/dashboard.asciidoc b/docs/user/dashboard.asciidoc index 1614f00f37ac7..b90593c46db4c 100644 --- a/docs/user/dashboard.asciidoc +++ b/docs/user/dashboard.asciidoc @@ -159,6 +159,8 @@ When you're finished adding and arranging the panels, save the dashboard. . Enter the dashboard *Title* and optional *Description*, then *Save* the dashboard. +include::{kib-repo-dir}/drilldowns/drilldowns.asciidoc[] + [[sharing-dashboards]] == Share the dashboard diff --git a/package.json b/package.json index 9423ee31c8bbe..419edcf268356 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "@elastic/charts": "19.2.0", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.8.0", - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", @@ -298,7 +298,7 @@ "@elastic/eslint-config-kibana": "0.15.0", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", - "@elastic/makelogs": "^5.0.1", + "@elastic/makelogs": "^6.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/es": "1.0.0", "@kbn/eslint-import-resolver-kibana": "2.0.0", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 93152ef1b71dc..ef2b7e7c06a25 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -55242,6 +55242,7 @@ __webpack_require__.r(__webpack_exports__); * under the License. */ +const YARN_EXEC = process.env.npm_execpath || 'yarn'; /** * Install all dependencies in the given directory @@ -55250,7 +55251,7 @@ async function installInDir(directory, extraArgs = []) { const options = ['install', '--non-interactive', ...extraArgs]; // We pass the mutex flag to ensure only one instance of yarn runs at any // given time (e.g. to avoid conflicts). - await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])('yarn', options, { + await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])(YARN_EXEC, options, { cwd: directory }); } @@ -55262,7 +55263,7 @@ async function runScriptInPackage(script, args, pkg) { const execOpts = { cwd: pkg.path }; - await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])('yarn', ['run', script, ...args], execOpts); + await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])(YARN_EXEC, ['run', script, ...args], execOpts); } /** * Run script in the given directory @@ -55277,7 +55278,7 @@ function runScriptInPackageStreaming({ const execOpts = { cwd: pkg.path }; - return Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawnStreaming"])('yarn', ['run', script, ...args], execOpts, { + return Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawnStreaming"])(YARN_EXEC, ['run', script, ...args], execOpts, { prefix: pkg.name, debug }); @@ -55285,7 +55286,7 @@ function runScriptInPackageStreaming({ async function yarnWorkspacesInfo(directory) { const { stdout - } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])('yarn', ['--json', 'workspaces', 'info'], { + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_0__["spawn"])(YARN_EXEC, ['--json', 'workspaces', 'info'], { cwd: directory, stdio: 'pipe' }); diff --git a/packages/kbn-pm/src/utils/scripts.ts b/packages/kbn-pm/src/utils/scripts.ts index 728ac4287b1ce..6b1dc729906f2 100644 --- a/packages/kbn-pm/src/utils/scripts.ts +++ b/packages/kbn-pm/src/utils/scripts.ts @@ -20,6 +20,8 @@ import { spawn, spawnStreaming } from './child_process'; import { Project } from './project'; +const YARN_EXEC = process.env.npm_execpath || 'yarn'; + interface WorkspaceInfo { location: string; workspaceDependencies: string[]; @@ -37,7 +39,7 @@ export async function installInDir(directory: string, extraArgs: string[] = []) // We pass the mutex flag to ensure only one instance of yarn runs at any // given time (e.g. to avoid conflicts). - await spawn('yarn', options, { + await spawn(YARN_EXEC, options, { cwd: directory, }); } @@ -50,7 +52,7 @@ export async function runScriptInPackage(script: string, args: string[], pkg: Pr cwd: pkg.path, }; - await spawn('yarn', ['run', script, ...args], execOpts); + await spawn(YARN_EXEC, ['run', script, ...args], execOpts); } /** @@ -71,14 +73,14 @@ export function runScriptInPackageStreaming({ cwd: pkg.path, }; - return spawnStreaming('yarn', ['run', script, ...args], execOpts, { + return spawnStreaming(YARN_EXEC, ['run', script, ...args], execOpts, { prefix: pkg.name, debug, }); } export async function yarnWorkspacesInfo(directory: string): Promise { - const { stdout } = await spawn('yarn', ['--json', 'workspaces', 'info'], { + const { stdout } = await spawn(YARN_EXEC, ['--json', 'workspaces', 'info'], { cwd: directory, stdio: 'pipe', }); diff --git a/packages/kbn-storybook/storybook_config/webpack.config.js b/packages/kbn-storybook/storybook_config/webpack.config.js index 2dd051882bb4b..543bb47656df8 100644 --- a/packages/kbn-storybook/storybook_config/webpack.config.js +++ b/packages/kbn-storybook/storybook_config/webpack.config.js @@ -17,7 +17,7 @@ * under the License. */ -const { resolve } = require('path'); +const { parse, resolve } = require('path'); const webpack = require('webpack'); const { stringifyRequest } = require('loader-utils'); const CopyWebpackPlugin = require('copy-webpack-plugin'); @@ -95,6 +95,27 @@ module.exports = async ({ config }) => { }, }, }, + { + loader: 'resolve-url-loader', + options: { + // If you don't have arguments (_, __) to the join function, the + // resolve-url-loader fails with a loader misconfiguration error. + // + // eslint-disable-next-line no-unused-vars + join: (_, __) => (uri, base) => { + if (!base || !parse(base).dir.includes('legacy')) { + return null; + } + + // URIs on mixins in src/legacy/public/styles need to be resolved. + if (uri.startsWith('ui/assets')) { + return resolve(REPO_ROOT, 'src/core/server/core_app/', uri.replace('ui/', '')); + } + + return null; + }, + }, + }, { loader: 'sass-loader', options: { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 744a656c54a7f..2c9251b03059a 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "19.2.0", - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index f5b17f8d214e9..60963c0acb990 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -1014,7 +1014,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1662,7 +1658,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -3119,38 +3111,43 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` - - - + + +
- - - + + +
@@ -4717,7 +4715,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` @@ -5120,38 +5114,43 @@ exports[`CollapsibleNav renders the default nav 3`] = ` - - - + + +
@@ -9277,7 +9273,7 @@ exports[`Header renders 3`] = `
@@ -9764,38 +9756,43 @@ exports[`Header renders 3`] = ` - - - + + +
@@ -14302,6 +14300,7 @@ exports[`Header renders 4`] = ` > diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js index 6d6eb69e66792..485390dc50a79 100644 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js +++ b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js @@ -44,7 +44,7 @@ import vegaMapImage256 from './vega_map_image_256.png'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { VegaParser } from '../../../../../../plugins/vis_type_vega/public/data_model/vega_parser'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SearchCache } from '../../../../../../plugins/vis_type_vega/public/data_model/search_cache'; +import { SearchAPI } from '../../../../../../plugins/vis_type_vega/public/data_model/search_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { createVegaTypeDefinition } from '../../../../../../plugins/vis_type_vega/public/vega_type'; @@ -205,7 +205,14 @@ describe('VegaVisualizations', () => { try { vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser(vegaliteGraph, new SearchCache()); + const vegaParser = new VegaParser( + vegaliteGraph, + new SearchAPI({ + search: npStart.plugins.data.search, + uiSettings: npStart.core.uiSettings, + injectedMetadata: npStart.core.injectedMetadata, + }) + ); await vegaParser.parseAsync(); await vegaVis.render(vegaParser, vis.params, { data: true }); @@ -227,7 +234,14 @@ describe('VegaVisualizations', () => { let vegaVis; try { vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser(vegaGraph, new SearchCache()); + const vegaParser = new VegaParser( + vegaGraph, + new SearchAPI({ + search: npStart.plugins.data.search, + uiSettings: npStart.core.uiSettings, + injectedMetadata: npStart.core.injectedMetadata, + }) + ); await vegaParser.parseAsync(); await vegaVis.render(vegaParser, vis.params, { data: true }); @@ -243,7 +257,14 @@ describe('VegaVisualizations', () => { let vegaVis; try { vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser(vegaTooltipGraph, new SearchCache()); + const vegaParser = new VegaParser( + vegaTooltipGraph, + new SearchAPI({ + search: npStart.plugins.data.search, + uiSettings: npStart.core.uiSettings, + injectedMetadata: npStart.core.injectedMetadata, + }) + ); await vegaParser.parseAsync(); await vegaVis.render(vegaParser, vis.params, { data: true }); @@ -285,7 +306,14 @@ describe('VegaVisualizations', () => { let vegaVis; try { vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser(vegaMapGraph, new SearchCache()); + const vegaParser = new VegaParser( + vegaMapGraph, + new SearchAPI({ + search: npStart.plugins.data.search, + uiSettings: npStart.core.uiSettings, + injectedMetadata: npStart.core.injectedMetadata, + }) + ); await vegaParser.parseAsync(); domNode.style.width = '256px'; @@ -324,7 +352,11 @@ describe('VegaVisualizations', () => { } ] }`, - new SearchCache() + new SearchAPI({ + search: npStart.plugins.data.search, + uiSettings: npStart.core.uiSettings, + injectedMetadata: npStart.core.injectedMetadata, + }) ); await vegaParser.parseAsync(); diff --git a/src/legacy/server/sass/build.test.js b/src/legacy/server/sass/build.test.js index 46a898c30f84e..71c43ac010962 100644 --- a/src/legacy/server/sass/build.test.js +++ b/src/legacy/server/sass/build.test.js @@ -33,6 +33,8 @@ afterEach(async () => { }); it('builds light themed SASS', async () => { + // Increased timeout from 5000ms due to intermittent timeout failures + jest.setTimeout(60000); const targetPath = resolve(TMP, 'style.css'); await new Build({ sourcePath: FIXTURE, diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index eb3f937a4168b..301ff8d3f67d8 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -358,6 +358,7 @@ export { ISearchSource, parseSearchSourceJSON, injectSearchSourceReferences, + getSearchParamsFromRequest, extractSearchSourceReferences, SearchSourceFields, EsQuerySortValue, diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 7054575e8ef9e..bd3ec0d3f2294 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -30,6 +30,7 @@ import { IconType } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n/react'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { IUiSettingsClient } from 'src/core/public'; +import { IUiSettingsClient as IUiSettingsClient_3 } from 'kibana/public'; import { Location } from 'history'; import { LocationDescriptorObject } from 'history'; import { MaybePromise } from '@kbn/utility-types'; @@ -641,6 +642,23 @@ export function getQueryLog(uiSettings: IUiSettingsClient, storage: IStorageWrap // @public (undocumented) export function getSearchErrorType({ message }: Pick): "UNSUPPORTED_QUERY" | undefined; +// Warning: (ae-missing-release-tag) "getSearchParamsFromRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getSearchParamsFromRequest(searchRequest: SearchRequest, dependencies: { + injectedMetadata: CoreStart['injectedMetadata']; + uiSettings: IUiSettingsClient_3; +}): { + rest_total_hits_as_int: boolean; + ignore_unavailable: boolean; + ignore_throttled: boolean; + max_concurrent_shard_requests: any; + preference: any; + timeout: string | undefined; + index: any; + body: any; +}; + // Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1851,20 +1869,20 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/fetch/get_search_params.ts b/src/plugins/data/public/search/fetch/get_search_params.ts index 60bdc9ed6473a..f2ad243ce72d0 100644 --- a/src/plugins/data/public/search/fetch/get_search_params.ts +++ b/src/plugins/data/public/search/fetch/get_search_params.ts @@ -17,8 +17,9 @@ * under the License. */ -import { IUiSettingsClient } from 'kibana/public'; +import { IUiSettingsClient, CoreStart } from 'kibana/public'; import { UI_SETTINGS } from '../../../common'; +import { SearchRequest } from './types'; const sessionId = Date.now(); @@ -53,3 +54,18 @@ export function getPreference(config: IUiSettingsClient) { export function getTimeout(esShardTimeout: number) { return esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined; } + +export function getSearchParamsFromRequest( + searchRequest: SearchRequest, + dependencies: { injectedMetadata: CoreStart['injectedMetadata']; uiSettings: IUiSettingsClient } +) { + const { injectedMetadata, uiSettings } = dependencies; + const esShardTimeout = injectedMetadata.getInjectedVar('esShardTimeout') as number; + const searchParams = getSearchParams(uiSettings, esShardTimeout); + + return { + index: searchRequest.index.title || searchRequest.index, + body: searchRequest.body, + ...searchParams, + }; +} diff --git a/src/plugins/data/public/search/fetch/index.ts b/src/plugins/data/public/search/fetch/index.ts index 39845ec31bfaa..ab856d681ba12 100644 --- a/src/plugins/data/public/search/fetch/index.ts +++ b/src/plugins/data/public/search/fetch/index.ts @@ -20,6 +20,7 @@ export * from './types'; export { getSearchParams, + getSearchParamsFromRequest, getPreference, getTimeout, getIgnoreThrottled, diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index 53686f9be9b4d..1b5395e1071c5 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -44,6 +44,7 @@ export { SearchRequest, SearchResponse, getSearchErrorType, + getSearchParamsFromRequest, } from './fetch'; export { diff --git a/src/plugins/data/public/search/search_source/search_source.ts b/src/plugins/data/public/search/search_source/search_source.ts index b926739112e0e..a33cda964bd1d 100644 --- a/src/plugins/data/public/search/search_source/search_source.ts +++ b/src/plugins/data/public/search/search_source/search_source.ts @@ -77,7 +77,7 @@ import { filterDocvalueFields } from './filter_docvalue_fields'; import { fieldWildcardFilter } from '../../../../kibana_utils/public'; import { IIndexPattern, ISearchGeneric, SearchRequest } from '../..'; import { SearchSourceOptions, SearchSourceFields } from './types'; -import { FetchOptions, RequestFailure, getSearchParams, handleResponse } from '../fetch'; +import { FetchOptions, RequestFailure, handleResponse, getSearchParamsFromRequest } from '../fetch'; import { getEsQueryConfig, buildEsQuery, Filter, UI_SETTINGS } from '../../../common'; import { getHighlightRequest } from '../../../common/field_formats'; @@ -204,13 +204,12 @@ export class SearchSource { */ private fetch$(searchRequest: SearchRequest, signal?: AbortSignal) { const { search, injectedMetadata, uiSettings } = this.dependencies; - const esShardTimeout = injectedMetadata.getInjectedVar('esShardTimeout') as number; - const searchParams = getSearchParams(uiSettings, esShardTimeout); - const params = { - index: searchRequest.index.title || searchRequest.index, - body: searchRequest.body, - ...searchParams, - }; + + const params = getSearchParamsFromRequest(searchRequest, { + injectedMetadata, + uiSettings, + }); + return search({ params, indexType: searchRequest.indexType }, { signal }).pipe( map(({ rawResponse }) => handleResponse(searchRequest, rawResponse)) ); diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz index b1a5dff7b2b5f..d47426df12ddf 100644 Binary files a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz and b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz differ diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/field_mappings.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/field_mappings.ts index 5fecf8699bec8..c29df2c88fd74 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/field_mappings.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/field_mappings.ts @@ -173,4 +173,11 @@ export const fieldMappings = { city_name: { type: 'keyword' }, }, }, + event: { + properties: { + dataset: { + type: 'keyword', + }, + }, + }, }; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index b2392742c0197..f680329045625 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -296,7 +296,7 @@ export const getSavedObjects = (): SavedObject[] => [ title: 'kibana_sample_data_ecommerce', timeFieldName: 'order_date', fields: - '[{"name":"_id","type":"string","esTypes":["_id"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"_index","type":"string","esTypes":["_index"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"_score","type":"number","count":0,"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"name":"_source","type":"_source","esTypes":["_source"],"count":0,"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"name":"_type","type":"string","esTypes":["_type"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"category","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"category.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "category"}}},{"name":"currency","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_birth_date","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_first_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_first_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "customer_first_name"}}},{"name":"customer_full_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_full_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "customer_full_name"}}},{"name":"customer_gender","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_id","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_last_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_last_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "customer_last_name"}}},{"name":"customer_phone","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"day_of_week","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"day_of_week_i","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"email","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.city_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.continent_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.country_iso_code","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.location","type":"geo_point","esTypes":["geo_point"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.region_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"manufacturer","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"manufacturer.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "manufacturer"}}},{"name":"order_date","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"order_id","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products._id","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products._id.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "products._id"}}},{"name":"products.base_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.base_unit_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.category","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.category.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "products.category"}}},{"name":"products.created_on","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.discount_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.discount_percentage","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.manufacturer","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.manufacturer.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "products.manufacturer"}}},{"name":"products.min_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.product_id","type":"number","esTypes":["long"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.product_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.product_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent": "products.product_name"}}},{"name":"products.quantity","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.sku","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.tax_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.taxful_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.taxless_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.unit_discount_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"sku","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"taxful_total_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"taxless_total_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"total_quantity","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"total_unique_products","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"type","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"user","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true}]', + '[{"name":"_id","type":"string","esTypes":["_id"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"_index","type":"string","esTypes":["_index"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"_score","type":"number","count":0,"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"name":"_source","type":"_source","esTypes":["_source"],"count":0,"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"name":"_type","type":"string","esTypes":["_type"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"name":"category","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"category.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"category"}}},{"name":"currency","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_birth_date","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_first_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_first_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_first_name"}}},{"name":"customer_full_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_full_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_full_name"}}},{"name":"customer_gender","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_id","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"customer_last_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"customer_last_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_last_name"}}},{"name":"customer_phone","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"day_of_week","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"day_of_week_i","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"email","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"event.dataset","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.city_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.continent_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.country_iso_code","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.location","type":"geo_point","esTypes":["geo_point"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"geoip.region_name","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"manufacturer","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"manufacturer.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"manufacturer"}}},{"name":"order_date","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"order_id","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products._id","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products._id.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products._id"}}},{"name":"products.base_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.base_unit_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.category","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.category.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.category"}}},{"name":"products.created_on","type":"date","esTypes":["date"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.discount_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.discount_percentage","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.manufacturer","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.manufacturer.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.manufacturer"}}},{"name":"products.min_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.product_id","type":"number","esTypes":["long"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.product_name","type":"string","esTypes":["text"],"count":0,"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"name":"products.product_name.keyword","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.product_name"}}},{"name":"products.quantity","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.sku","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.tax_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.taxful_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.taxless_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"products.unit_discount_amount","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"sku","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"taxful_total_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"taxless_total_price","type":"number","esTypes":["half_float"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"total_quantity","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"total_unique_products","type":"number","esTypes":["integer"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"type","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"name":"user","type":"string","esTypes":["keyword"],"count":0,"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true}]', fieldFormatMap: '{"taxful_total_price":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', }, references: [], diff --git a/src/plugins/telemetry/server/config.ts b/src/plugins/telemetry/server/config.ts index 99dde0c3b3d96..ae9b70bb9f367 100644 --- a/src/plugins/telemetry/server/config.ts +++ b/src/plugins/telemetry/server/config.ts @@ -56,7 +56,7 @@ export const configSchema = schema.object({ }) ), sendUsageFrom: schema.oneOf([schema.literal('server'), schema.literal('browser')], { - defaultValue: 'browser', + defaultValue: 'server', }), }); diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js index 0b8b4be67b7a0..3e5e335a9ea39 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js @@ -61,6 +61,7 @@ class TimeseriesPanelConfigUi extends Component { axis_min: '', legend_position: 'right', show_grid: 1, + tooltip_mode: 'show_all', }; const model = { ...defaults, ...this.props.model }; const { selectedTab } = this.state; @@ -85,6 +86,22 @@ class TimeseriesPanelConfigUi extends Component { value: 'left', }, ]; + const tooltipModeOptions = [ + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.timeseries.tooltipOptions.showAll', + defaultMessage: 'Show all values', + }), + value: 'show_all', + }, + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.timeseries.tooltipOptions.showFocused', + defaultMessage: 'Show focused values', + }), + value: 'show_focused', + }, + ]; const selectedPositionOption = positionOptions.find((option) => { return model.axis_position === option.value; }); @@ -134,6 +151,10 @@ class TimeseriesPanelConfigUi extends Component { return model.legend_position === option.value; }); + const selectedTooltipMode = tooltipModeOptions.find((option) => { + return model.tooltip_mode === option.value; + }); + let view; if (selectedTab === 'data') { view = ( @@ -356,6 +377,24 @@ class TimeseriesPanelConfigUi extends Component { + + + + + + + +
diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js index b4c563f3c11fd..ddfaf3c1428d9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -250,6 +250,7 @@ export class TimeseriesVisualization extends Component { showGrid={Boolean(model.show_grid)} legend={Boolean(model.show_legend)} legendPosition={model.legend_position} + tooltipMode={model.tooltip_mode} xAxisLabel={getAxisLabelString(interval)} xAxisFormatter={this.xAxisFormatter(interval)} annotations={this.prepareAnnotations()} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 274b46bce2715..c53482b5db075 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -61,6 +61,7 @@ export const TimeSeries = ({ showGrid, legend, legendPosition, + tooltipMode, xAxisLabel, series, yAxis, @@ -131,7 +132,7 @@ export const TimeSeries = ({ baseTheme={theme} tooltip={{ snap: true, - type: TooltipType.VerticalCursor, + type: tooltipMode === 'show_focused' ? TooltipType.Follow : TooltipType.VerticalCursor, headerFormatter: tooltipFormatter, }} /> diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 2b0734ceb4d4d..c06f94efb3c49 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -69,6 +69,7 @@ export const metricsVisDefinition = { axis_scale: 'normal', show_legend: 1, show_grid: 1, + tooltip_mode: 'show_all', }, component: VisEditor, }, diff --git a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts index e8838f57ae365..bf2ea8651c5a2 100644 --- a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts +++ b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -247,6 +247,9 @@ export const visPayloadSchema = schema.object({ series: schema.arrayOf(seriesItems), show_grid: numberIntegerRequired, show_legend: numberIntegerRequired, + tooltip_mode: schema.maybe( + schema.oneOf([schema.literal('show_all'), schema.literal('show_focused')]) + ), time_field: stringOptionalNullable, time_range_mode: stringOptionalNullable, type: stringRequired, diff --git a/src/plugins/vis_type_vega/public/__mocks__/services.ts b/src/plugins/vis_type_vega/public/__mocks__/services.ts index 1bf051232e4c9..4775241a66d50 100644 --- a/src/plugins/vis_type_vega/public/__mocks__/services.ts +++ b/src/plugins/vis_type_vega/public/__mocks__/services.ts @@ -16,10 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +import { CoreStart, IUiSettingsClient, NotificationsStart, SavedObjectsStart } from 'kibana/public'; import { createGetterSetter } from '../../../kibana_utils/public'; import { DataPublicPluginStart } from '../../../data/public'; -import { IUiSettingsClient, NotificationsStart, SavedObjectsStart } from 'kibana/public'; import { dataPluginMock } from '../../../data/public/mocks'; import { coreMock } from '../../../../core/public/mocks'; @@ -34,22 +34,24 @@ setNotifications(coreMock.createStart().notifications); export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); setUISettings(coreMock.createStart().uiSettings); +export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< + CoreStart['injectedMetadata'] +>('InjectedMetadata'); +setInjectedMetadata(coreMock.createStart().injectedMetadata); + export const [getSavedObjects, setSavedObjects] = createGetterSetter( 'SavedObjects' ); setSavedObjects(coreMock.createStart().savedObjects); export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ - esShardTimeout: number; enableExternalUrls: boolean; emsTileLayerId: unknown; }>('InjectedVars'); setInjectedVars({ emsTileLayerId: {}, enableExternalUrls: true, - esShardTimeout: 10000, }); -export const getEsShardTimeout = () => getInjectedVars().esShardTimeout; export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; export const getEmsTileLayerId = () => getInjectedVars().emsTileLayerId; diff --git a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js b/src/plugins/vis_type_vega/public/data_model/es_query_parser.js index 066c9f06fc109..387301c2c7de9 100644 --- a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/es_query_parser.js @@ -17,11 +17,9 @@ * under the License. */ -import _ from 'lodash'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; - -import { getEsShardTimeout } from '../services'; +import { isPlainObject, cloneDeep } from 'lodash'; const TIMEFILTER = '%timefilter%'; const AUTOINTERVAL = '%autointerval%'; @@ -37,12 +35,11 @@ const TIMEFIELD = '%timefield%'; * This class parses ES requests specified in the data.url objects. */ export class EsQueryParser { - constructor(timeCache, searchCache, filters, onWarning) { + constructor(timeCache, searchAPI, filters, onWarning) { this._timeCache = timeCache; - this._searchCache = searchCache; + this._searchAPI = searchAPI; this._filters = filters; this._onWarning = onWarning; - this._esShardTimeout = getEsShardTimeout(); } // noinspection JSMethodCanBeStatic @@ -59,7 +56,7 @@ export class EsQueryParser { if (body === undefined) { url.body = body = {}; - } else if (!_.isPlainObject(body)) { + } else if (!isPlainObject(body)) { throw new Error( i18n.translate('visTypeVega.esQueryParser.urlBodyValueTypeErrorMessage', { defaultMessage: '{configName} must be an object', @@ -167,7 +164,7 @@ export class EsQueryParser { if (context) { // Use dashboard context - const newQuery = _.cloneDeep(this._filters); + const newQuery = cloneDeep(this._filters); if (timefield) { newQuery.bool.must.push(body.query); } @@ -179,34 +176,20 @@ export class EsQueryParser { return { dataObject, url }; } - mapRequest = (request) => { - const esRequest = request.url; - if (this._esShardTimeout) { - // remove possible timeout query param to prevent two conflicting timeout parameters - const { body = {}, timeout, ...rest } = esRequest; //eslint-disable-line no-unused-vars - body.timeout = `${this._esShardTimeout}ms`; - return { - body, - ...rest, - }; - } else { - return esRequest; - } - }; - /** * Process items generated by parseUrl() * @param {object[]} requests each object is generated by parseUrl() * @returns {Promise} */ async populateData(requests) { - const esSearches = requests.map(this.mapRequest); + const esSearches = requests.map((r) => r.url); + const data$ = this._searchAPI.search(esSearches); - const results = await this._searchCache.search(esSearches); + const results = await data$.toPromise(); - for (let i = 0; i < requests.length; i++) { - requests[i].dataObject.values = results[i]; - } + results.forEach((data) => { + requests[data.id].dataObject.values = data.rawResponse; + }); } /** @@ -222,7 +205,7 @@ export class EsQueryParser { const item = obj[pos]; if (isQuery && (item === MUST_CLAUSE || item === MUST_NOT_CLAUSE)) { const ctxTag = item === MUST_CLAUSE ? 'must' : 'must_not'; - const ctx = _.cloneDeep(this._filters); + const ctx = cloneDeep(this._filters); if (ctx && ctx.bool && ctx.bool[ctxTag]) { if (Array.isArray(ctx.bool[ctxTag])) { // replace one value with an array of values diff --git a/src/plugins/vis_type_vega/public/data_model/es_query_parser.test.js b/src/plugins/vis_type_vega/public/data_model/es_query_parser.test.js index c519da33ab1c9..fd474bef73b8c 100644 --- a/src/plugins/vis_type_vega/public/data_model/es_query_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/es_query_parser.test.js @@ -94,28 +94,36 @@ describe(`EsQueryParser time`, () => { }); describe('EsQueryParser.populateData', () => { - let searchStub; + let searchApiStub; + let data; let parser; beforeEach(() => { - searchStub = jest.fn(() => Promise.resolve([{}, {}])); - parser = new EsQueryParser({}, { search: searchStub }, undefined, undefined); + searchApiStub = { + search: jest.fn(() => ({ + toPromise: jest.fn(() => Promise.resolve(data)), + })), + }; + parser = new EsQueryParser({}, searchApiStub, undefined, undefined); }); test('should set the timeout for each request', async () => { + data = [ + { id: 0, rawResponse: {} }, + { id: 1, rawResponse: {} }, + ]; await parser.populateData([ { url: { body: {} }, dataObject: {} }, { url: { body: {} }, dataObject: {} }, ]); - expect(searchStub.mock.calls[0][0][0].body.timeout).toBe.defined; + + expect(searchApiStub.search.mock.calls[0][0][0].body).toBeDefined(); }); test('should remove possible timeout parameters on a request', async () => { - await parser.populateData([ - { url: { timeout: '500h', body: { timeout: '500h' } }, dataObject: {} }, - ]); - expect(searchStub.mock.calls[0][0][0].body.timeout).toBe.defined; - expect(searchStub.mock.calls[0][0][0].timeout).toBe(undefined); + data = [{ id: 0, rawResponse: {} }]; + await parser.populateData([{ url: { body: { timeout: '500h' } }, dataObject: {} }]); + expect(searchApiStub.search.mock.calls[0][0][0].body.timeout).toBeDefined(); }); }); diff --git a/src/plugins/vis_type_vega/public/data_model/search_api.ts b/src/plugins/vis_type_vega/public/data_model/search_api.ts new file mode 100644 index 0000000000000..c2eecf13c2d51 --- /dev/null +++ b/src/plugins/vis_type_vega/public/data_model/search_api.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CoreStart, IUiSettingsClient } from 'kibana/public'; +import { + getSearchParamsFromRequest, + SearchRequest, + DataPublicPluginStart, +} from '../../../data/public'; + +export interface SearchAPIDependencies { + uiSettings: IUiSettingsClient; + injectedMetadata: CoreStart['injectedMetadata']; + search: DataPublicPluginStart['search']; +} + +export class SearchAPI { + constructor( + private readonly dependencies: SearchAPIDependencies, + private readonly abortSignal?: AbortSignal + ) {} + + search(searchRequests: SearchRequest[]) { + const { search } = this.dependencies.search; + + return combineLatest( + searchRequests.map((request, index) => { + const params = getSearchParamsFromRequest(request, { + uiSettings: this.dependencies.uiSettings, + injectedMetadata: this.dependencies.injectedMetadata, + }); + + return search({ params }, { signal: this.abortSignal }).pipe( + map((data) => ({ + id: index, + rawResponse: data.rawResponse, + })) + ); + }) + ); + } +} diff --git a/src/plugins/vis_type_vega/public/data_model/search_cache.js b/src/plugins/vis_type_vega/public/data_model/search_cache.js deleted file mode 100644 index 41e4c67c3b2ad..0000000000000 --- a/src/plugins/vis_type_vega/public/data_model/search_cache.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import LruCache from 'lru-cache'; - -export class SearchCache { - constructor(es, cacheOpts) { - this._es = es; - this._cache = new LruCache(cacheOpts); - } - - /** - * Execute multiple searches, possibly combining the results of the cached searches - * with the new ones already in cache - * @param {object[]} requests array of search requests - */ - search(requests) { - const promises = []; - - for (const request of requests) { - const key = JSON.stringify(request); - let pending = this._cache.get(key); - if (pending === undefined) { - pending = this._es.search(request); - this._cache.set(key, pending); - } - promises.push(pending); - } - - return Promise.all(promises); - } -} diff --git a/src/plugins/vis_type_vega/public/data_model/search_cache.test.js b/src/plugins/vis_type_vega/public/data_model/search_cache.test.js deleted file mode 100644 index 92f80545ce1b5..0000000000000 --- a/src/plugins/vis_type_vega/public/data_model/search_cache.test.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { SearchCache } from './search_cache'; -jest.mock('../services'); - -describe(`SearchCache`, () => { - class FauxEs { - constructor() { - // contains all request batches, separated by 0 - this.searches = []; - } - - async search(request) { - this.searches.push(request); - return { req: request }; - } - } - - const request1 = { body: 'b1' }; - const expected1 = { req: { body: 'b1' } }; - const request2 = { body: 'b2' }; - const expected2 = { req: { body: 'b2' } }; - const request3 = { body: 'b3' }; - const expected3 = { req: { body: 'b3' } }; - - it(`sequence`, async () => { - const sc = new SearchCache(new FauxEs()); - - // empty request - let res = await sc.search([]); - expect(res).toEqual([]); - expect(sc._es.searches).toEqual([]); - - // single request - res = await sc.search([request1]); - expect(res).toEqual([expected1]); - expect(sc._es.searches).toEqual([request1]); - - // repeat the same search, use array notation - res = await sc.search([request1]); - expect(res).toEqual([expected1]); - expect(sc._es.searches).toEqual([request1]); // no new entries - - // new single search - res = await sc.search([request2]); - expect(res).toEqual([expected2]); - expect(sc._es.searches).toEqual([request1, request2]); - - // multiple search, some new, some old - res = await sc.search([request1, request3, request2]); - expect(res).toEqual([expected1, expected3, expected2]); - expect(sc._es.searches).toEqual([request1, request2, request3]); - }); -}); diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.js index f541b9f104adc..cbfe2a6ede4f2 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.js @@ -46,7 +46,7 @@ const locToDirMap = { const DEFAULT_PARSER = 'elasticsearch'; export class VegaParser { - constructor(spec, searchCache, timeCache, filters, serviceSettings) { + constructor(spec, searchAPI, timeCache, filters, serviceSettings) { this.spec = spec; this.hideWarnings = false; this.error = undefined; @@ -54,7 +54,7 @@ export class VegaParser { const onWarn = this._onWarning.bind(this); this._urlParsers = { - elasticsearch: new EsQueryParser(timeCache, searchCache, filters, onWarn), + elasticsearch: new EsQueryParser(timeCache, searchAPI, filters, onWarn), emsfile: new EmsFileParser(serviceSettings), url: new UrlParser(onWarn), }; diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js index 1bd26b8713044..a40ef31260b6f 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js @@ -78,9 +78,25 @@ describe(`VegaParser._setDefaultColors`, () => { }); describe('VegaParser._resolveEsQueries', () => { + let searchApiStub; + const data = [ + { + id: 0, + rawResponse: [42], + }, + ]; + + beforeEach(() => { + searchApiStub = { + search: jest.fn(() => ({ + toPromise: jest.fn(() => Promise.resolve(data)), + })), + }; + }); + function check(spec, expected, warnCount) { return async () => { - const vp = new VegaParser(spec, { search: async () => [[42]] }, 0, 0, { + const vp = new VegaParser(spec, searchApiStub, 0, 0, { getFileLayers: async () => [{ name: 'file1', url: 'url1' }], getUrlForRegionLayer: async (layer) => { return layer.url; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 1bce7ac92e564..b3e35dac3711f 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -28,6 +28,7 @@ import { setUISettings, setKibanaMapFactory, setMapsLegacyConfig, + setInjectedMetadata, } from './services'; import { createVegaFn } from './vega_fn'; @@ -96,5 +97,6 @@ export class VegaPlugin implements Plugin, void> { setNotifications(core.notifications); setSavedObjects(core.savedObjects); setData(data); + setInjectedMetadata(core.injectedMetadata); } } diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index f2fddb41cf72b..7d988d464b52b 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -17,8 +17,13 @@ * under the License. */ -import { SavedObjectsStart } from 'kibana/public'; -import { NotificationsStart, IUiSettingsClient } from 'src/core/public'; +import { + CoreStart, + SavedObjectsStart, + NotificationsStart, + IUiSettingsClient, +} from 'src/core/public'; + import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; import { MapsLegacyConfigType } from '../../maps_legacy/public'; @@ -34,6 +39,10 @@ export const [getKibanaMapFactory, setKibanaMapFactory] = createGetterSetter('UISettings'); +export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< + CoreStart['injectedMetadata'] +>('InjectedMetadata'); + export const [getSavedObjects, setSavedObjects] = createGetterSetter( 'SavedObjects' ); @@ -48,6 +57,5 @@ export const [getMapsLegacyConfig, setMapsLegacyConfig] = createGetterSetter getInjectedVars().esShardTimeout; export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; export const getEmsTileLayerId = () => getMapsLegacyConfig().emsTileLayerId; diff --git a/src/plugins/vis_type_vega/public/vega_fn.ts b/src/plugins/vis_type_vega/public/vega_fn.ts index 6d45e043f7cee..a9c915fcfb636 100644 --- a/src/plugins/vis_type_vega/public/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/vega_fn.ts @@ -54,8 +54,8 @@ export const createVegaFn = ( help: '', }, }, - async fn(input, args) { - const vegaRequestHandler = createVegaRequestHandler(dependencies); + async fn(input, args, context) { + const vegaRequestHandler = createVegaRequestHandler(dependencies, context.abortSignal); const response = await vegaRequestHandler({ timeRange: get(input, 'timeRange'), diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index efc02e368efa8..ac28f0b3782b2 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -19,14 +19,14 @@ import { Filter, esQuery, TimeRange, Query } from '../../data/public'; -// @ts-ignore -import { SearchCache } from './data_model/search_cache'; +import { SearchAPI } from './data_model/search_api'; + // @ts-ignore import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; -import { getData } from './services'; +import { getData, getInjectedMetadata } from './services'; interface VegaRequestHandlerParams { query: Query; @@ -35,12 +35,11 @@ interface VegaRequestHandlerParams { visParams: VisParams; } -export function createVegaRequestHandler({ - plugins: { data }, - core: { uiSettings }, - serviceSettings, -}: VegaVisualizationDependencies) { - let searchCache: SearchCache | undefined; +export function createVegaRequestHandler( + { plugins: { data }, core: { uiSettings }, serviceSettings }: VegaVisualizationDependencies, + abortSignal?: AbortSignal +) { + let searchAPI: SearchAPI; const { timefilter } = data.query.timefilter; const timeCache = new TimeCache(timefilter, 3 * 1000); @@ -50,11 +49,15 @@ export function createVegaRequestHandler({ query, visParams, }: VegaRequestHandlerParams) { - if (!searchCache) { - searchCache = new SearchCache(getData().search.__LEGACY.esClient, { - max: 10, - maxAge: 4 * 1000, - }); + if (!searchAPI) { + searchAPI = new SearchAPI( + { + uiSettings, + search: getData().search, + injectedMetadata: getInjectedMetadata(), + }, + abortSignal + ); } timeCache.setTimeRange(timeRange); @@ -63,7 +66,7 @@ export function createVegaRequestHandler({ const filtersDsl = esQuery.buildEsQuery(undefined, query, filters, esQueryConfigs); // @ts-ignore const { VegaParser } = await import('./data_model/vega_parser'); - const vp = new VegaParser(visParams.spec, searchCache, timeCache, filtersDsl, serviceSettings); + const vp = new VegaParser(visParams.spec, searchAPI, timeCache, filtersDsl, serviceSettings); return await vp.parseAsync(); }; diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 22cc873cbdb5d..53ef164685a1c 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -1264,6 +1264,7 @@ exports[`NewVisModal filter for visualization types should render as expected 1` > @@ -2772,6 +2773,7 @@ exports[`NewVisModal should render as expected 1`] = ` > diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index 969bddbd30f9a..aa7934d6b1156 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -58,6 +58,10 @@ export function AppsMenuProvider({ getService, getPageObjects }: FtrProviderCont public async closeCollapsibleNav() { const CLOSE_BUTTON = '[data-test-subj=collapsibleNav] > button'; if (await find.existsByCssSelector(CLOSE_BUTTON)) { + // Close button is only visible when focused + const button = await find.byCssSelector(CLOSE_BUTTON); + await button.focus(); + await find.clickByCssSelector(CLOSE_BUTTON); } } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index acd78cb4955e3..24b38fae96653 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "react": "^16.12.0", "react-dom": "^16.12.0" }, diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index 45d07933c4209..71a635c444b8c 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "react": "^16.12.0" }, "scripts": { diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index ca99e9b5995c1..78f0b42a6fbda 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "react": "^16.12.0" }, "scripts": { diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json index 63dde0fa96dd3..6dbc9c71f2e81 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "react": "^16.12.0" }, "scripts": { diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_visual_regression.sh index c6fefd45b005d..a32782deec65b 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_visual_regression.sh @@ -11,8 +11,7 @@ mkdir -p "$installDir" tar -xzf "$linuxBuild" -C "$installDir" --strip=1 echo " -> running visual regression tests from kibana directory" -checks-reporter-with-killswitch "X-Pack visual regression tests" \ - yarn percy exec -t 500 -- -- \ +yarn percy exec -t 500 -- -- \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$installDir" \ diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 96521ccc8f787..e406bb3e6106f 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -13,9 +13,11 @@ tar -xzf "$linuxBuild" -C "$installDir" --strip=1 echo " -> running visual regression tests from x-pack directory" cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack visual regression tests" \ - yarn percy exec -t 500 -- -- \ +yarn percy exec -t 500 -- -- \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$installDir" \ --config test/visual_regression/config.ts; + +cd "$KIBANA_DIR" +source "test/scripts/jenkins_xpack_page_load_metrics.sh" diff --git a/x-pack/package.json b/x-pack/package.json index b3dcde2194d3f..fb708ab09d841 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -190,7 +190,7 @@ "@elastic/apm-rum-react": "^1.1.1", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.8.0", - "@elastic/eui": "23.3.1", + "@elastic/eui": "24.1.0", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.1.1", diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index c3ae694fe8e14..43bdeb583c819 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -31,6 +31,7 @@ module.exports = { collectCoverageFrom: [ '**/*.{js,jsx,ts,tsx}', '!**/{__test__,__snapshots__,__examples__,integration_tests,tests}/**', + '!**/*.stories.{js,ts,tsx}', '!**/*.test.{js,ts,tsx}', '!**/dev_docs/**', '!**/e2e/**', diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 3de725dc58ea7..6a20e3c103709 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -161,6 +161,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` @@ -575,6 +576,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -754,6 +756,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -859,6 +862,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -889,6 +893,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = `
@@ -958,6 +963,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -984,6 +990,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -1089,6 +1096,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1119,6 +1127,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = `
@@ -1188,6 +1197,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1214,6 +1224,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -1319,6 +1330,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1349,6 +1361,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = `
@@ -1418,6 +1431,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1444,6 +1458,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -1549,6 +1564,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1579,6 +1595,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = `
@@ -1648,6 +1665,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 1c62d3cc03db0..30031a05304bb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -346,7 +346,7 @@ storiesOf('app/ServiceMap/Cytoscape', module).add( }, }, ]; - return ; + return ; }, { info: { propTables: false, source: false }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index cb908785d64d8..c57d702b9a546 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -121,15 +121,6 @@ export function Cytoscape({ const trackApmEvent = useUiTracker({ app: 'apm' }); - // Trigger a custom "data" event when data changes - useEffect(() => { - if (cy) { - cy.remove(cy.elements()); - cy.add(elements); - cy.trigger('data'); - } - }, [cy, elements]); - // Set up cytoscape event handlers useEffect(() => { const resetConnectedEdgeStyle = (node?: cytoscape.NodeSingular) => { @@ -223,6 +214,10 @@ export function Cytoscape({ cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('select', 'node', selectHandler); cy.on('unselect', 'node', unselectHandler); + + cy.remove(cy.elements()); + cy.add(elements); + cy.trigger('data'); } return () => { @@ -241,7 +236,7 @@ export function Cytoscape({ } clearTimeout(layoutstopDelayTimeout); }; - }, [cy, height, serviceName, trackApmEvent, width]); + }, [cy, elements, height, serviceName, trackApmEvent, width]); return ( diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx index 8f7ed54f91bd0..ebcb1627984ad 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx @@ -7,6 +7,11 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { ErrorRateAlertTrigger } from '.'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; +import { + mockApmPluginContextValue, + MockApmPluginContextWrapper, +} from '../../../context/ApmPluginContext/MockApmPluginContext'; storiesOf('app/ErrorRateAlertTrigger', module).add('example', () => { const params = { @@ -15,12 +20,16 @@ storiesOf('app/ErrorRateAlertTrigger', module).add('example', () => { }; return ( -
- undefined} - setAlertProperty={() => undefined} - /> -
+ +
+ undefined} + setAlertProperty={() => undefined} + /> +
+
); }); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx index e2429d1225442..da9adbb8dfead 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -3,18 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +// import { storiesOf } from '@storybook/react'; import { cloneDeep, merge } from 'lodash'; -import { storiesOf } from '@storybook/react'; import React from 'react'; import { TransactionDurationAlertTrigger } from '.'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { - MockApmPluginContextWrapper, mockApmPluginContextValue, + MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; -import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; -storiesOf('app/TransactionDurationAlertTrigger', module).add('example', () => { +// Disabling this because we currently don't have a way to mock `useEnvironments` +// which is used by this component. Using the fetch-mock module should work, but +// our current storybook setup has core-js-related problems when trying to import +// it. +// storiesOf('app/TransactionDurationAlertTrigger', module).add('example', +// eslint-disable-next-line no-unused-expressions +() => { const params = { threshold: 1500, aggregationType: 'avg' as const, @@ -44,4 +50,4 @@ storiesOf('app/TransactionDurationAlertTrigger', module).add('example', () => {
); -}); +}; diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx index 47b461f22ad65..3014369d94857 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx @@ -69,6 +69,7 @@ export const withUnconnectedElementsLoadedTelemetry =

( ) => function ElementsLoadedTelemetry(props: ElementsLoadedTelemetryProps) { const { telemetryElementCounts, workpad, telemetryResolvedArgs, ...other } = props; + const { error, pending } = telemetryElementCounts; const [currentWorkpadId, setWorkpadId] = useState(undefined); const [hasReported, setHasReported] = useState(false); @@ -87,27 +88,20 @@ export const withUnconnectedElementsLoadedTelemetry =

( 0 ); - if ( - workpadElementCount === 0 || - (resolvedArgsAreForWorkpad && telemetryElementCounts.pending === 0) - ) { + if (workpadElementCount === 0 || (resolvedArgsAreForWorkpad && pending === 0)) { setHasReported(true); } else { setHasReported(false); } - } else if ( - !hasReported && - telemetryElementCounts.pending === 0 && - resolvedArgsAreForWorkpad - ) { - if (telemetryElementCounts.error > 0) { + } else if (!hasReported && pending === 0 && resolvedArgsAreForWorkpad) { + if (error > 0) { trackMetric(METRIC_TYPE.LOADED, [WorkpadLoadedMetric, WorkpadLoadedWithErrorsMetric]); } else { trackMetric(METRIC_TYPE.LOADED, WorkpadLoadedMetric); } setHasReported(true); } - }); + }, [currentWorkpadId, hasReported, error, pending, telemetryResolvedArgs, workpad]); return ; }; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot index 6601f570209e9..14791cd3d8b25 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot @@ -63,6 +63,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` > @@ -88,6 +89,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` > @@ -118,6 +120,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` > @@ -148,6 +151,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` > @@ -237,6 +241,7 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` > @@ -262,6 +267,7 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` > @@ -292,6 +298,7 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` > @@ -322,6 +329,7 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` > diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot index aff630b21c770..1b8f1480759f6 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot @@ -422,6 +422,7 @@ Array [ > @@ -447,6 +448,7 @@ Array [ > @@ -477,6 +479,7 @@ Array [ > @@ -507,6 +510,7 @@ Array [ > @@ -585,6 +589,7 @@ Array [ > @@ -610,6 +615,7 @@ Array [ > @@ -640,6 +646,7 @@ Array [ > @@ -670,6 +677,7 @@ Array [ > diff --git a/x-pack/plugins/canvas/public/components/router/index.ts b/x-pack/plugins/canvas/public/components/router/index.ts index 5e014870f5158..fa857c6f0cd3c 100644 --- a/x-pack/plugins/canvas/public/components/router/index.ts +++ b/x-pack/plugins/canvas/public/components/router/index.ts @@ -11,7 +11,6 @@ import { enableAutoplay, setRefreshInterval, setAutoplayInterval, - // @ts-ignore untyped local } from '../../state/actions/workpad'; // @ts-ignore untyped local import { Router as Component } from './router'; diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_controls.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_controls.stories.storyshot index 6f12f68356467..408b0679c415f 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_controls.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_controls.stories.storyshot @@ -16,6 +16,7 @@ exports[`Storyshots components/SavedElementsModal/ElementControls has two button > @@ -42,6 +43,7 @@ exports[`Storyshots components/SavedElementsModal/ElementControls has two button > diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_grid.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_grid.stories.storyshot index be0fb0573c394..1c506819df1fb 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_grid.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/element_grid.stories.storyshot @@ -66,6 +66,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > @@ -92,6 +93,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > @@ -170,6 +172,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > @@ -196,6 +199,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > @@ -274,6 +278,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > @@ -300,6 +305,7 @@ exports[`Storyshots components/SavedElementsModal/ElementGrid default 1`] = ` > diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/saved_elements_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/saved_elements_modal.stories.storyshot index 03093b41300b8..04b2184f27462 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/saved_elements_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/__snapshots__/saved_elements_modal.stories.storyshot @@ -362,6 +362,7 @@ Array [ > @@ -388,6 +389,7 @@ Array [ > @@ -466,6 +468,7 @@ Array [ > @@ -492,6 +495,7 @@ Array [ > @@ -570,6 +574,7 @@ Array [ > @@ -596,6 +601,7 @@ Array [ > @@ -851,6 +857,7 @@ Array [ > @@ -877,6 +884,7 @@ Array [ > diff --git a/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot index 4d5b9570ee20f..16263aa7ea384 100644 --- a/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot @@ -55,6 +55,7 @@ exports[`Storyshots components/Sidebar/SidebarHeader with layer controls 1`] = ` > @@ -80,6 +81,7 @@ exports[`Storyshots components/Sidebar/SidebarHeader with layer controls 1`] = ` > @@ -105,6 +107,7 @@ exports[`Storyshots components/Sidebar/SidebarHeader with layer controls 1`] = ` > @@ -130,6 +133,7 @@ exports[`Storyshots components/Sidebar/SidebarHeader with layer controls 1`] = ` > diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts index c6dddab3b5dd1..abd40731078ec 100644 --- a/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts @@ -5,7 +5,6 @@ */ import { connect } from 'react-redux'; -// @ts-ignore import { addColor, removeColor } from '../../state/actions/workpad'; import { getWorkpadColors } from '../../state/selectors/workpad'; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.js b/x-pack/plugins/canvas/public/components/workpad_config/index.ts similarity index 63% rename from x-pack/plugins/canvas/public/components/workpad_config/index.js rename to x-pack/plugins/canvas/public/components/workpad_config/index.ts index 913cf7093e726..e417821fd4f67 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_config/index.ts @@ -7,28 +7,29 @@ import { connect } from 'react-redux'; import { get } from 'lodash'; -import { sizeWorkpad, setName, setWorkpadCSS } from '../../state/actions/workpad'; +import { sizeWorkpad as setSize, setName, setWorkpadCSS } from '../../state/actions/workpad'; import { getWorkpad } from '../../state/selectors/workpad'; import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; import { WorkpadConfig as Component } from './workpad_config'; +import { State } from '../../../types'; -const mapStateToProps = (state) => { +const mapStateToProps = (state: State) => { const workpad = getWorkpad(state); return { - name: get(workpad, 'name'), + name: get(workpad, 'name'), size: { - width: get(workpad, 'width'), - height: get(workpad, 'height'), + width: get(workpad, 'width'), + height: get(workpad, 'height'), }, - css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), + css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), }; }; const mapDispatchToProps = { - setSize: (size) => sizeWorkpad(size), - setName: (name) => setName(name), - setWorkpadCSS: (css) => setWorkpadCSS(css), + setSize, + setName, + setWorkpadCSS, }; export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js deleted file mode 100644 index 45758c9965653..0000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.js +++ /dev/null @@ -1,169 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFieldText, - EuiFieldNumber, - EuiBadge, - EuiButtonIcon, - EuiFormRow, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiToolTip, - EuiTextArea, - EuiAccordion, - EuiText, - EuiButton, -} from '@elastic/eui'; -import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadConfig: strings } = ComponentStrings; - -export class WorkpadConfig extends PureComponent { - static propTypes = { - size: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - css: PropTypes.string, - setSize: PropTypes.func.isRequired, - setName: PropTypes.func.isRequired, - setWorkpadCSS: PropTypes.func.isRequired, - }; - - state = { - css: this.props.css, - }; - - render() { - const { size, name, setSize, setName, setWorkpadCSS } = this.props; - const { css } = this.state; - const rotate = () => setSize({ width: size.height, height: size.width }); - - const badges = [ - { - name: '1080p', - size: { height: 1080, width: 1920 }, - }, - { - name: '720p', - size: { height: 720, width: 1280 }, - }, - { - name: 'A4', - size: { height: 842, width: 590 }, - }, - { - name: strings.getUSLetterButtonLabel(), - size: { height: 792, width: 612 }, - }, - ]; - - return ( -

-
- -

{strings.getTitle()}

-
-
- - - - - setName(e.target.value)} /> - - - - - - - - setSize({ width: Number(e.target.value), height: size.height })} - value={size.width} - /> - - - - - - - - - - - - setSize({ height: Number(e.target.value), width: size.width })} - value={size.height} - /> - - - - - - -
- {badges.map((badge, i) => ( - setSize(badge.size)} - aria-label={strings.getPageSizeBadgeAriaLabel(badge.name)} - onClickAriaLabel={strings.getPageSizeBadgeOnClickAriaLabel(badge.name)} - > - {badge.name} - - ))} -
- - -
- - - {strings.getGlobalCSSLabel()} - - - } - > -
- this.setState({ css: e.target.value })} - rows={10} - /> - - setWorkpadCSS(css || DEFAULT_WORKPAD_CSS)}> - {strings.getApplyStylesheetButtonLabel()} - - -
-
-
-
- ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx new file mode 100644 index 0000000000000..7b7a1e08b2c5d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFieldText, + EuiFieldNumber, + EuiBadge, + EuiButtonIcon, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiToolTip, + EuiTextArea, + EuiAccordion, + EuiText, + EuiButton, +} from '@elastic/eui'; +import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { ComponentStrings } from '../../../i18n'; + +const { WorkpadConfig: strings } = ComponentStrings; + +interface Props { + size: { + height: number; + width: number; + }; + name: string; + css?: string; + setSize: ({ height, width }: { height: number; width: number }) => void; + setName: (name: string) => void; + setWorkpadCSS: (css: string) => void; +} + +export const WorkpadConfig: FunctionComponent = (props) => { + const [css, setCSS] = useState(props.css); + const { size, name, setSize, setName, setWorkpadCSS } = props; + const rotate = () => setSize({ width: size.height, height: size.width }); + + const badges = [ + { + name: '1080p', + size: { height: 1080, width: 1920 }, + }, + { + name: '720p', + size: { height: 720, width: 1280 }, + }, + { + name: 'A4', + size: { height: 842, width: 590 }, + }, + { + name: strings.getUSLetterButtonLabel(), + size: { height: 792, width: 612 }, + }, + ]; + + return ( +
+
+ +

{strings.getTitle()}

+
+
+ + + + + setName(e.target.value)} /> + + + + + + + + setSize({ width: Number(e.target.value), height: size.height })} + value={size.width} + /> + + + + + + + + + + + + setSize({ height: Number(e.target.value), width: size.width })} + value={size.height} + /> + + + + + + +
+ {badges.map((badge, i) => ( + setSize(badge.size)} + aria-label={strings.getPageSizeBadgeAriaLabel(badge.name)} + onClickAriaLabel={strings.getPageSizeBadgeOnClickAriaLabel(badge.name)} + > + {badge.name} + + ))} +
+ + +
+ + + {strings.getGlobalCSSLabel()} + + + } + > +
+ setCSS(e.target.value)} + rows={10} + /> + + setWorkpadCSS(css || DEFAULT_WORKPAD_CSS)}> + {strings.getApplyStylesheetButtonLabel()} + + +
+
+
+
+ ); +}; + +WorkpadConfig.propTypes = { + size: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + css: PropTypes.string, + setSize: PropTypes.func.isRequired, + setName: PropTypes.func.isRequired, + setWorkpadCSS: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts index e561607cb101e..0765973915f77 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -14,13 +14,11 @@ import { State, CanvasWorkpadBoundingBox } from '../../../../types'; import { fetchAllRenderables } from '../../../state/actions/elements'; // @ts-ignore Untyped local import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient'; -// @ts-ignore Untyped local import { setWriteable, setRefreshInterval, enableAutoplay, setAutoplayInterval, - // @ts-ignore Untyped local } from '../../../state/actions/workpad'; import { getZoomScale, canUserWrite } from '../../../state/selectors/app'; import { @@ -75,7 +73,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ }, doRefresh: () => dispatch(fetchAllRenderables()), setRefreshInterval: (interval: number) => dispatch(setRefreshInterval(interval)), - enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(autoplay)), + enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(!!autoplay)), setAutoplayInterval: (interval: number) => dispatch(setAutoplayInterval(interval)), }); diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot index 14466cab1a698..f8583d7cd0dc0 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot @@ -169,6 +169,7 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no series 1`] = ` > diff --git a/x-pack/plugins/canvas/public/lib/create_thunk.ts b/x-pack/plugins/canvas/public/lib/create_thunk.ts new file mode 100644 index 0000000000000..cbcaeeccc8b93 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/create_thunk.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, Action } from 'redux'; +// @ts-ignore untyped dependency +import { createThunk as createThunkFn } from 'redux-thunks/cjs'; +import { State } from '../../types'; + +type CreateThunk = ( + type: string, + fn: ( + params: { type: string; dispatch: Dispatch; getState: () => State }, + ...args: Arguments + ) => void +) => (...args: Arguments) => Action; + +// This declaration exists because redux-thunks is not typed, and has a dependency on +// Canvas State. Therefore, creating a wrapper that strongly-types the function-- and creates +// a single point of replacement, should the need arise-- is a nice workaround. +export const createThunk = createThunkFn as CreateThunk; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 47fbc782f90d3..e89e62917da39 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -5,10 +5,10 @@ */ import { createAction } from 'redux-actions'; -import { createThunk } from 'redux-thunks/cjs'; import immutable from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; +import { createThunk } from '../../lib/create_thunk'; import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; diff --git a/x-pack/plugins/canvas/public/state/actions/embeddable.ts b/x-pack/plugins/canvas/public/state/actions/embeddable.ts index e2cf588ec20a9..a153cb7f4354d 100644 --- a/x-pack/plugins/canvas/public/state/actions/embeddable.ts +++ b/x-pack/plugins/canvas/public/state/actions/embeddable.ts @@ -6,8 +6,7 @@ import { Dispatch } from 'redux'; import { createAction } from 'redux-actions'; -// @ts-ignore Untyped -import { createThunk } from 'redux-thunks'; +import { createThunk } from '../../lib/create_thunk'; // @ts-ignore Untyped Local import { fetchRenderable } from './elements'; import { State } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.js b/x-pack/plugins/canvas/public/state/actions/workpad.ts similarity index 54% rename from x-pack/plugins/canvas/public/state/actions/workpad.js rename to x-pack/plugins/canvas/public/state/actions/workpad.ts index 167c156dce998..47df38838f890 100644 --- a/x-pack/plugins/canvas/public/state/actions/workpad.js +++ b/x-pack/plugins/canvas/public/state/actions/workpad.ts @@ -5,26 +5,28 @@ */ import { createAction } from 'redux-actions'; -import { createThunk } from 'redux-thunks/cjs'; import { without, includes } from 'lodash'; +import { createThunk } from '../../lib/create_thunk'; import { getWorkpadColors } from '../selectors/workpad'; +// @ts-ignore import { fetchAllRenderables } from './elements'; +import { CanvasWorkpad } from '../../../types'; -export const sizeWorkpad = createAction('sizeWorkpad'); -export const setName = createAction('setName'); -export const setWriteable = createAction('setWriteable'); -export const setColors = createAction('setColors'); -export const setRefreshInterval = createAction('setRefreshInterval'); -export const setWorkpadCSS = createAction('setWorkpadCSS'); -export const enableAutoplay = createAction('enableAutoplay'); -export const setAutoplayInterval = createAction('setAutoplayInterval'); -export const resetWorkpad = createAction('resetWorkpad'); +export const sizeWorkpad = createAction<{ height: number; width: number }>('sizeWorkpad'); +export const setName = createAction('setName'); +export const setWriteable = createAction('setWriteable'); +export const setColors = createAction('setColors'); +export const setRefreshInterval = createAction('setRefreshInterval'); +export const setWorkpadCSS = createAction('setWorkpadCSS'); +export const enableAutoplay = createAction('enableAutoplay'); +export const setAutoplayInterval = createAction('setAutoplayInterval'); +export const resetWorkpad = createAction('resetWorkpad'); export const initializeWorkpad = createThunk('initializeWorkpad', ({ dispatch }) => { dispatch(fetchAllRenderables()); }); -export const addColor = createThunk('addColor', ({ dispatch, getState }, color) => { +export const addColor = createThunk('addColor', ({ dispatch, getState }, color: string) => { const colors = getWorkpadColors(getState()).slice(0); if (!includes(colors, color)) { colors.push(color); @@ -32,16 +34,20 @@ export const addColor = createThunk('addColor', ({ dispatch, getState }, color) dispatch(setColors(colors)); }); -export const removeColor = createThunk('removeColor', ({ dispatch, getState }, color) => { +export const removeColor = createThunk('removeColor', ({ dispatch, getState }, color: string) => { dispatch(setColors(without(getWorkpadColors(getState()), color))); }); export const setWorkpad = createThunk( 'setWorkpad', - ({ dispatch, type }, workpad, { loadPages = true } = {}) => { + ( + { dispatch, type }, + workpad: CanvasWorkpad, + { loadPages = true }: { loadPages?: boolean } = {} + ) => { dispatch(createAction(type)(workpad)); // set the workpad object in state if (loadPages) { dispatch(initializeWorkpad()); - } // load all the elements on the workpad + } } ); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index da461609f0b83..56d76da522ac2 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -12,7 +12,7 @@ type HttpResponse = Record | any[]; // Register helpers to mock HTTP Requests const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { const setLoadTemplatesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/index-templates`, [ + server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(response), @@ -27,8 +27,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setLoadDataStreamsResponse = (response: HttpResponse = []) => { + server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setDeleteTemplateResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete-index-templates`, [ + server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [ 200, { 'Content-Type': 'application/json' }, JSON.stringify(response), @@ -39,7 +47,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { const status = error ? error.status || 400 : 200; const body = error ? error.body : response; - server.respondWith('GET', `${API_BASE_PATH}/index-templates/:id`, [ + server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [ status, { 'Content-Type': 'application/json' }, JSON.stringify(body), @@ -50,7 +58,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { const status = error ? error.body.status || 400 : 200; const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - server.respondWith('POST', `${API_BASE_PATH}/index-templates`, [ + server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [ status, { 'Content-Type': 'application/json' }, body, @@ -61,7 +69,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { const status = error ? error.status || 400 : 200; const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - server.respondWith('PUT', `${API_BASE_PATH}/index-templates/:name`, [ + server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [ status, { 'Content-Type': 'application/json' }, body, @@ -71,6 +79,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { return { setLoadTemplatesResponse, setLoadIndicesResponse, + setLoadDataStreamsResponse, setDeleteTemplateResponse, setLoadTemplateResponse, setCreateTemplateResponse, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts index 8e7755a65af3c..f581083e28cc6 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts @@ -10,44 +10,4 @@ export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../.. export { setupEnvironment, WithAppDependencies, services } from './setup_environment'; -export type TestSubjects = - | 'aliasesTab' - | 'appTitle' - | 'cell' - | 'closeDetailsButton' - | 'createTemplateButton' - | 'createLegacyTemplateButton' - | 'deleteSystemTemplateCallOut' - | 'deleteTemplateButton' - | 'deleteTemplatesConfirmation' - | 'documentationLink' - | 'emptyPrompt' - | 'manageTemplateButton' - | 'mappingsTab' - | 'noAliasesCallout' - | 'noMappingsCallout' - | 'noSettingsCallout' - | 'indicesList' - | 'indicesTab' - | 'indexTableIncludeHiddenIndicesToggle' - | 'indexTableIndexNameLink' - | 'reloadButton' - | 'reloadIndicesButton' - | 'row' - | 'sectionError' - | 'sectionLoading' - | 'settingsTab' - | 'summaryTab' - | 'summaryTitle' - | 'systemTemplatesSwitch' - | 'templateDetails' - | 'templateDetails.manageTemplateButton' - | 'templateDetails.sectionLoading' - | 'templateDetails.tab' - | 'templateDetails.title' - | 'templateList' - | 'templateTable' - | 'templatesTab' - | 'legacyTemplateTable' - | 'viewButton' - | 'filterList.filterItem'; +export { TestSubjects } from './test_subjects'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts new file mode 100644 index 0000000000000..4e297118b0fdd --- /dev/null +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type TestSubjects = + | 'aliasesTab' + | 'appTitle' + | 'cell' + | 'closeDetailsButton' + | 'createLegacyTemplateButton' + | 'createTemplateButton' + | 'dataStreamsEmptyPromptTemplateLink' + | 'dataStreamTable' + | 'dataStreamTable' + | 'deleteSystemTemplateCallOut' + | 'deleteTemplateButton' + | 'deleteTemplatesConfirmation' + | 'documentationLink' + | 'emptyPrompt' + | 'filterList.filterItem' + | 'indexTable' + | 'indexTableIncludeHiddenIndicesToggle' + | 'indexTableIndexNameLink' + | 'indicesList' + | 'indicesTab' + | 'legacyTemplateTable' + | 'manageTemplateButton' + | 'mappingsTab' + | 'noAliasesCallout' + | 'noMappingsCallout' + | 'noSettingsCallout' + | 'reloadButton' + | 'reloadIndicesButton' + | 'row' + | 'sectionError' + | 'sectionLoading' + | 'settingsTab' + | 'summaryTab' + | 'summaryTitle' + | 'systemTemplatesSwitch' + | 'templateDetails' + | 'templateDetails.manageTemplateButton' + | 'templateDetails.sectionLoading' + | 'templateDetails.tab' + | 'templateDetails.title' + | 'templateList' + | 'templatesTab' + | 'templateTable' + | 'viewButton'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts new file mode 100644 index 0000000000000..ef6aca44a1754 --- /dev/null +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { + registerTestBed, + TestBed, + TestBedConfig, + findTestSubject, +} from '../../../../../test_utils'; +import { DataStream } from '../../../common'; +import { IndexManagementHome } from '../../../public/application/sections/home'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { indexManagementStore } from '../../../public/application/store'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { WithAppDependencies, services, TestSubjects } from '../helpers'; + +const testBedConfig: TestBedConfig = { + store: () => indexManagementStore(services as any), + memoryRouter: { + initialEntries: [`/indices`], + componentRoutePath: `/:section(indices|data_streams|templates)`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); + +export interface DataStreamsTabTestBed extends TestBed { + actions: { + goToDataStreamsList: () => void; + clickEmptyPromptIndexTemplateLink: () => void; + clickReloadButton: () => void; + clickIndicesAt: (index: number) => void; + }; +} + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + /** + * User Actions + */ + + const goToDataStreamsList = () => { + testBed.find('data_streamsTab').simulate('click'); + }; + + const clickEmptyPromptIndexTemplateLink = async () => { + const { find, component, router } = testBed; + + const templateLink = find('dataStreamsEmptyPromptTemplateLink'); + + await act(async () => { + router.navigateTo(templateLink.props().href!); + }); + + component.update(); + }; + + const clickReloadButton = () => { + const { find } = testBed; + find('reloadButton').simulate('click'); + }; + + const clickIndicesAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('dataStreamTable'); + const indicesLink = findTestSubject(rows[index].reactWrapper, 'indicesLink'); + + await act(async () => { + router.navigateTo(indicesLink.props().href!); + }); + + component.update(); + }; + + return { + ...testBed, + actions: { + goToDataStreamsList, + clickEmptyPromptIndexTemplateLink, + clickReloadButton, + clickIndicesAt, + }, + }; +}; + +export const createDataStreamPayload = (name: string): DataStream => ({ + name, + timeStampField: '@timestamp', + indices: [ + { + name: 'indexName', + uuid: 'indexId', + }, + ], + generation: 1, +}); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts new file mode 100644 index 0000000000000..efe2e2d0c74ae --- /dev/null +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { setupEnvironment } from '../helpers'; + +import { DataStreamsTabTestBed, setup, createDataStreamPayload } from './data_streams_tab.helpers'; + +describe('Data Streams tab', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: DataStreamsTabTestBed; + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([ + { + health: '', + status: '', + primary: '', + replica: '', + documents: '', + documents_deleted: '', + size: '', + primary_size: '', + name: 'data-stream-index', + data_stream: 'dataStream1', + }, + { + health: 'green', + status: 'open', + primary: 1, + replica: 1, + documents: 10000, + documents_deleted: 100, + size: '156kb', + primary_size: '156kb', + name: 'non-data-stream-index', + }, + ]); + + await act(async () => { + testBed = await setup(); + }); + }); + + describe('when there are no data streams', () => { + beforeEach(async () => { + const { actions, component } = testBed; + + httpRequestsMockHelpers.setLoadDataStreamsResponse([]); + httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); + + await act(async () => { + actions.goToDataStreamsList(); + }); + + component.update(); + }); + + test('displays an empty prompt', async () => { + const { exists } = testBed; + + expect(exists('sectionLoading')).toBe(false); + expect(exists('emptyPrompt')).toBe(true); + }); + + test('goes to index templates tab when "Get started" link is clicked', async () => { + const { actions, exists } = testBed; + + await act(async () => { + actions.clickEmptyPromptIndexTemplateLink(); + }); + + expect(exists('templateList')).toBe(true); + }); + }); + + describe('when there are data streams', () => { + beforeEach(async () => { + const { actions, component } = testBed; + + httpRequestsMockHelpers.setLoadDataStreamsResponse([ + createDataStreamPayload('dataStream1'), + createDataStreamPayload('dataStream2'), + ]); + + await act(async () => { + actions.goToDataStreamsList(); + }); + + component.update(); + }); + + test('lists them in the table', async () => { + const { table } = testBed; + + const { tableCellsValues } = table.getMetaData('dataStreamTable'); + + expect(tableCellsValues).toEqual([ + ['dataStream1', '1', '@timestamp', '1'], + ['dataStream2', '1', '@timestamp', '1'], + ]); + }); + + test('has a button to reload the data streams', async () => { + const { exists, actions } = testBed; + const totalRequests = server.requests.length; + + expect(exists('reloadButton')).toBe(true); + + await act(async () => { + actions.clickReloadButton(); + }); + + expect(server.requests.length).toBe(totalRequests + 1); + expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + }); + + test('clicking the indices count navigates to the backing indices', async () => { + const { table, actions } = testBed; + + await actions.clickIndicesAt(0); + + expect(table.getMetaData('indexTable').tableCellsValues).toEqual([ + ['', '', '', '', '', '', '', 'dataStream1'], + ]); + }); + }); +}); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 8f6a8dddeb195..7c79c7e61174e 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -188,7 +188,7 @@ describe('Index Templates tab', () => { expect(server.requests.length).toBe(totalRequests + 1); expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/index-templates` + `${API_BASE_PATH}/index_templates` ); }); @@ -318,7 +318,7 @@ describe('Index Templates tab', () => { const latestRequest = server.requests[server.requests.length - 1]; expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete-index-templates`); + expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ templates: [{ name: legacyTemplates[0].name, isLegacy }], }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index e995932dfa00d..f00348aacbf08 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -6,8 +6,13 @@ import { act } from 'react-dom/test-utils'; -import { registerTestBed, TestBed, TestBedConfig } from '../../../../../test_utils'; -import { IndexList } from '../../../public/application/sections/home/index_list'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { + registerTestBed, + TestBed, + TestBedConfig, + findTestSubject, +} from '../../../../../test_utils'; +import { IndexManagementHome } from '../../../public/application/sections/home'; // eslint-disable-line @kbn/eslint/no-restricted-paths import { indexManagementStore } from '../../../public/application/store'; // eslint-disable-line @kbn/eslint/no-restricted-paths import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -15,18 +20,19 @@ const testBedConfig: TestBedConfig = { store: () => indexManagementStore(services as any), memoryRouter: { initialEntries: [`/indices?includeHiddenIndices=true`], - componentRoutePath: `/:section(indices|templates)`, + componentRoutePath: `/:section(indices|data_streams)`, }, doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(IndexList), testBedConfig); +const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); export interface IndicesTestBed extends TestBed { actions: { selectIndexDetailsTab: (tab: 'settings' | 'mappings' | 'stats' | 'edit_settings') => void; getIncludeHiddenIndicesToggleStatus: () => boolean; clickIncludeHiddenIndicesToggle: () => void; + clickDataStreamAt: (index: number) => void; }; } @@ -59,12 +65,25 @@ export const setup = async (): Promise => { component.update(); }; + const clickDataStreamAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('indexTable'); + const dataStreamLink = findTestSubject(rows[index].reactWrapper, 'dataStreamLink'); + + await act(async () => { + router.navigateTo(dataStreamLink.props().href!); + }); + + component.update(); + }; + return { ...testBed, actions: { selectIndexDetailsTab, getIncludeHiddenIndicesToggleStatus, clickIncludeHiddenIndicesToggle, + clickDataStreamAt, }, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index 11c25ffbb590f..c2d955bb4dfce 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -9,6 +9,7 @@ import { act } from 'react-dom/test-utils'; import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment, nextTick } from '../helpers'; import { IndicesTestBed, setup } from './indices_tab.helpers'; +import { createDataStreamPayload } from './data_streams_tab.helpers'; /** * The below import is required to avoid a console error warn from the "brace" package @@ -52,6 +53,49 @@ describe('', () => { }); }); + describe('data stream column', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([ + { + health: '', + status: '', + primary: '', + replica: '', + documents: '', + documents_deleted: '', + size: '', + primary_size: '', + name: 'data-stream-index', + data_stream: 'dataStream1', + }, + ]); + + httpRequestsMockHelpers.setLoadDataStreamsResponse([ + createDataStreamPayload('dataStream1'), + createDataStreamPayload('dataStream2'), + ]); + + testBed = await setup(); + + await act(async () => { + const { component } = testBed; + + await nextTick(); + component.update(); + }); + }); + + test('navigates to the data stream in the Data Streams tab', async () => { + const { table, actions } = testBed; + + await actions.clickDataStreamAt(0); + + expect(table.getMetaData('dataStreamTable').tableCellsValues).toEqual([ + ['dataStream1', '1', '@timestamp', '1'], + ]); + }); + }); + describe('index detail panel with % character in index name', () => { const indexName = 'test%'; beforeEach(async () => { diff --git a/x-pack/plugins/index_management/common/index.ts b/x-pack/plugins/index_management/common/index.ts index 3792e322ae40b..4ad428744deab 100644 --- a/x-pack/plugins/index_management/common/index.ts +++ b/x-pack/plugins/index_management/common/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { PLUGIN, API_BASE_PATH, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from './constants'; +export { PLUGIN, API_BASE_PATH, CREATE_LEGACY_TEMPLATE_BY_DEFAULT, BASE_PATH } from './constants'; export { getTemplateParameter } from './lib'; diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts new file mode 100644 index 0000000000000..eaa7f24017a2f --- /dev/null +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { deserializeComponentTemplate } from './component_template_serialization'; + +describe('deserializeComponentTemplate', () => { + test('deserializes a component template', () => { + expect( + deserializeComponentTemplate( + { + name: 'my_component_template', + component_template: { + version: 1, + _meta: { + serialization: { + id: 10, + class: 'MyComponentTemplate', + }, + description: 'set number of shards to one', + }, + template: { + settings: { + number_of_shards: 1, + }, + mappings: { + _source: { + enabled: false, + }, + properties: { + host_name: { + type: 'keyword', + }, + created_at: { + type: 'date', + format: 'EEE MMM dd HH:mm:ss Z yyyy', + }, + }, + }, + }, + }, + }, + [ + { + name: 'my_index_template', + index_template: { + index_patterns: ['foo'], + template: { + settings: { + number_of_replicas: 2, + }, + }, + composed_of: ['my_component_template'], + }, + }, + ] + ) + ).toEqual({ + name: 'my_component_template', + version: 1, + _meta: { + serialization: { + id: 10, + class: 'MyComponentTemplate', + }, + description: 'set number of shards to one', + }, + template: { + settings: { + number_of_shards: 1, + }, + mappings: { + _source: { + enabled: false, + }, + properties: { + host_name: { + type: 'keyword', + }, + created_at: { + type: 'date', + format: 'EEE MMM dd HH:mm:ss Z yyyy', + }, + }, + }, + }, + _kbnMeta: { + usedBy: ['my_index_template'], + }, + }); + }); +}); diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts new file mode 100644 index 0000000000000..0db81bf81d300 --- /dev/null +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + TemplateFromEs, + ComponentTemplateFromEs, + ComponentTemplateDeserialized, + ComponentTemplateListItem, +} from '../types'; + +const hasEntries = (data: object = {}) => Object.entries(data).length > 0; + +/** + * Normalize a list of component templates to a map where each key + * is a component template name, and the value is an array of index templates name using it + * + * @example + * + { + "comp-1": [ + "template-1", + "template-2" + ], + "comp2": [ + "template-1", + "template-2" + ] + } + * + * @param indexTemplatesEs List of component templates + */ + +const getIndexTemplatesToUsedBy = (indexTemplatesEs: TemplateFromEs[]) => { + return indexTemplatesEs.reduce((acc, item) => { + if (item.index_template.composed_of) { + item.index_template.composed_of.forEach((component) => { + acc[component] = acc[component] ? [...acc[component], item.name] : [item.name]; + }); + } + return acc; + }, {} as { [key: string]: string[] }); +}; + +export function deserializeComponentTemplate( + componentTemplateEs: ComponentTemplateFromEs, + indexTemplatesEs: TemplateFromEs[] +) { + const { name, component_template: componentTemplate } = componentTemplateEs; + const { template, _meta, version } = componentTemplate; + + const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs); + + const deserializedComponentTemplate: ComponentTemplateDeserialized = { + name, + template, + version, + _meta, + _kbnMeta: { + usedBy: indexTemplatesToUsedBy[name] || [], + }, + }; + + return deserializedComponentTemplate; +} + +export function deserializeComponenTemplateList( + componentTemplateEs: ComponentTemplateFromEs, + indexTemplatesEs: TemplateFromEs[] +) { + const { name, component_template: componentTemplate } = componentTemplateEs; + const { template } = componentTemplate; + + const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs); + + const componentTemplateListItem: ComponentTemplateListItem = { + name, + usedBy: indexTemplatesToUsedBy[name] || [], + hasSettings: hasEntries(template.settings), + hasMappings: hasEntries(template.mappings), + hasAliases: hasEntries(template.aliases), + }; + + return componentTemplateListItem; +} diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts new file mode 100644 index 0000000000000..9d267210a6b31 --- /dev/null +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataStream, DataStreamFromEs } from '../types'; + +export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]): DataStream[] { + return dataStreamsFromEs.map(({ name, timestamp_field, indices, generation }) => ({ + name, + timeStampField: timestamp_field, + indices: indices.map( + ({ index_name, index_uuid }: { index_name: string; index_uuid: string }) => ({ + name: index_name, + uuid: index_uuid, + }) + ), + generation, + })); +} diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index 16eb544c56a08..fce4d8ccc2502 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -3,6 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +export { deserializeDataStreamList } from './data_stream_serialization'; + export { deserializeLegacyTemplateList, deserializeTemplateList, @@ -11,3 +14,8 @@ export { } from './template_serialization'; export { getTemplateParameter } from './utils'; + +export { + deserializeComponentTemplate, + deserializeComponenTemplateList, +} from './component_template_serialization'; diff --git a/x-pack/plugins/index_management/common/types/component_templates.ts b/x-pack/plugins/index_management/common/types/component_templates.ts new file mode 100644 index 0000000000000..bc7ebdc2753dd --- /dev/null +++ b/x-pack/plugins/index_management/common/types/component_templates.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexSettings } from './indices'; +import { Aliases } from './aliases'; +import { Mappings } from './mappings'; + +export interface ComponentTemplateSerialized { + template: { + settings?: IndexSettings; + aliases?: Aliases; + mappings?: Mappings; + }; + version?: number; + _meta?: { [key: string]: any }; +} + +export interface ComponentTemplateDeserialized extends ComponentTemplateSerialized { + name: string; + _kbnMeta: { + usedBy: string[]; + }; +} + +export interface ComponentTemplateFromEs { + name: string; + component_template: ComponentTemplateSerialized; +} + +export interface ComponentTemplateListItem { + name: string; + usedBy: string[]; + hasMappings: boolean; + hasAliases: boolean; + hasSettings: boolean; +} diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts new file mode 100644 index 0000000000000..5b743296d868b --- /dev/null +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface DataStreamFromEs { + name: string; + timestamp_field: string; + indices: DataStreamIndexFromEs[]; + generation: number; +} + +export interface DataStreamIndexFromEs { + index_name: string; + index_uuid: string; +} + +export interface DataStream { + name: string; + timeStampField: string; + indices: DataStreamIndex[]; + generation: number; +} + +export interface DataStreamIndex { + name: string; + uuid: string; +} diff --git a/x-pack/plugins/index_management/common/types/index.ts b/x-pack/plugins/index_management/common/types/index.ts index b467f020978a5..c4ba60573d430 100644 --- a/x-pack/plugins/index_management/common/types/index.ts +++ b/x-pack/plugins/index_management/common/types/index.ts @@ -11,3 +11,7 @@ export * from './indices'; export * from './mappings'; export * from './templates'; + +export { DataStreamFromEs, DataStream, DataStreamIndex } from './data_streams'; + +export * from './component_templates'; diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts index f113aa44d058f..006a2d9dea8f2 100644 --- a/x-pack/plugins/index_management/common/types/templates.ts +++ b/x-pack/plugins/index_management/common/types/templates.ts @@ -49,6 +49,11 @@ export interface TemplateDeserialized { }; } +export interface TemplateFromEs { + name: string; + index_template: TemplateSerialized; +} + /** * Interface for the template list in our UI table * we don't include the mappings, settings and aliases diff --git a/x-pack/plugins/index_management/public/application/app.tsx b/x-pack/plugins/index_management/public/application/app.tsx index 10bbe3ced64da..92197bee30c88 100644 --- a/x-pack/plugins/index_management/public/application/app.tsx +++ b/x-pack/plugins/index_management/public/application/app.tsx @@ -5,10 +5,12 @@ */ import React, { useEffect } from 'react'; + import { Router, Switch, Route, Redirect } from 'react-router-dom'; import { ScopedHistory } from 'kibana/public'; + import { UIM_APP_LOAD } from '../../common/constants'; -import { IndexManagementHome } from './sections/home'; +import { IndexManagementHome, homeSections } from './sections/home'; import { TemplateCreate } from './sections/template_create'; import { TemplateClone } from './sections/template_clone'; import { TemplateEdit } from './sections/template_edit'; @@ -29,10 +31,10 @@ export const App = ({ history }: { history: ScopedHistory }) => { // Export this so we can test it with a different router. export const AppWithoutRouter = () => ( - - - - + + + + ); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts new file mode 100644 index 0000000000000..830cc0ee6a980 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import { ComponentTemplateListTestBed } from './helpers/component_template_list.helpers'; +import { API_BASE_PATH } from '../../../../../../common/constants'; +import { ComponentTemplateListItem } from '../../types'; + +const { setup } = pageHelpers.componentTemplateList; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +describe('', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: ComponentTemplateListTestBed; + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + + describe('With component templates', () => { + const componentTemplate1: ComponentTemplateListItem = { + name: 'test_component_template_1', + hasMappings: true, + hasAliases: true, + hasSettings: true, + usedBy: [], + }; + + const componentTemplate2: ComponentTemplateListItem = { + name: 'test_component_template_2', + hasMappings: true, + hasAliases: true, + hasSettings: true, + usedBy: ['test_index_template_1'], + }; + + const componentTemplates = [componentTemplate1, componentTemplate2]; + + httpRequestsMockHelpers.setLoadComponentTemplatesResponse(componentTemplates); + + test('should render the list view', async () => { + const { table } = testBed; + + // Verify table content + const { tableCellsValues } = table.getMetaData('componentTemplatesTable'); + tableCellsValues.forEach((row, i) => { + const { name, usedBy } = componentTemplates[i]; + const usedByText = usedBy.length === 0 ? 'Not in use' : usedBy.length.toString(); + + expect(row).toEqual(['', name, usedByText, '', '', '', '']); + }); + }); + + test('should reload the component templates data', async () => { + const { component, actions } = testBed; + const totalRequests = server.requests.length; + + await act(async () => { + actions.clickReloadButton(); + }); + + component.update(); + + expect(server.requests.length).toBe(totalRequests + 1); + expect(server.requests[server.requests.length - 1].url).toBe( + `${API_BASE_PATH}/component_templates` + ); + }); + + test('should delete a component template', async () => { + const { actions, component } = testBed; + const { name: componentTemplateName } = componentTemplate1; + + await act(async () => { + actions.clickDeleteActionAt(0); + }); + + // We need to read the document "body" as the modal is added there and not inside + // the component DOM tree. + const modal = document.body.querySelector( + '[data-test-subj="deleteComponentTemplatesConfirmation"]' + ); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + expect(modal).not.toBe(null); + expect(modal!.textContent).toContain('Delete component template'); + + httpRequestsMockHelpers.setDeleteComponentTemplateResponse({ + itemsDeleted: [componentTemplateName], + errors: [], + }); + + await act(async () => { + confirmButton!.click(); + }); + + component.update(); + + const deleteRequest = server.requests[server.requests.length - 2]; + + expect(deleteRequest.method).toBe('DELETE'); + expect(deleteRequest.url).toBe( + `${API_BASE_PATH}/component_templates/${componentTemplateName}` + ); + expect(deleteRequest.status).toEqual(200); + }); + }); + + describe('No component templates', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); + + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + + test('should display an empty prompt', async () => { + const { exists, find } = testBed; + + expect(exists('sectionLoading')).toBe(false); + expect(exists('emptyList')).toBe(true); + expect(find('emptyList.title').text()).toEqual('Start by creating a component template'); + }); + }); + + describe('Error handling', () => { + beforeEach(async () => { + const error = { + status: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error }); + + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + + test('should render an error message if error fetching component templates', async () => { + const { exists, find } = testBed; + + expect(exists('componentTemplatesLoadError')).toBe(true); + expect(find('componentTemplatesLoadError').text()).toContain( + 'Unable to load component templates. Try again.' + ); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts new file mode 100644 index 0000000000000..8fb4dcff0bcea --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { BASE_PATH } from '../../../../../../../common'; +import { + registerTestBed, + TestBed, + TestBedConfig, + findTestSubject, + nextTick, +} from '../../../../../../../../../test_utils'; +import { WithAppDependencies } from './setup_environment'; +import { ComponentTemplateList } from '../../../component_template_list'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}component_templates`], + componentRoutePath: `${BASE_PATH}component_templates`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig); + +export type ComponentTemplateListTestBed = TestBed & { + actions: ReturnType; +}; + +const createActions = (testBed: TestBed) => { + const { find } = testBed; + + /** + * User Actions + */ + const clickReloadButton = () => { + find('reloadButton').simulate('click'); + }; + + const clickComponentTemplateAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('componentTemplatesTable'); + const componentTemplateLink = findTestSubject( + rows[index].reactWrapper, + 'componentTemplateDetailsLink' + ); + + await act(async () => { + const { href } = componentTemplateLink.props(); + router.navigateTo(href!); + await nextTick(); + component.update(); + }); + }; + + const clickDeleteActionAt = (index: number) => { + const { table } = testBed; + + const { rows } = table.getMetaData('componentTemplatesTable'); + const deleteButton = findTestSubject(rows[index].reactWrapper, 'deleteComponentTemplateButton'); + + deleteButton.simulate('click'); + }; + + return { + clickReloadButton, + clickComponentTemplateAt, + clickDeleteActionAt, + }; +}; + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type ComponentTemplateTestSubjects = + | 'componentTemplatesTable' + | 'componentTemplateDetails' + | 'componentTemplateDetails.title' + | 'deleteComponentTemplatesConfirmation' + | 'emptyList' + | 'emptyList.title' + | 'sectionLoading' + | 'componentTemplatesLoadError' + | 'deleteComponentTemplateButton' + | 'reloadButton'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts new file mode 100644 index 0000000000000..8473041ee0af3 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon, { SinonFakeServer } from 'sinon'; +import { API_BASE_PATH } from '../../../../../../../common'; + +// Register helpers to mock HTTP Requests +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const setLoadComponentTemplatesResponse = (response?: any[], error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setDeleteComponentTemplateResponse = (response?: object) => { + server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + + return { + setLoadComponentTemplatesResponse, + setDeleteComponentTemplateResponse, + }; +}; + +export const init = () => { + const server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Define default response for unhandled requests. + // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, + // and we can mock them all with a 200 instead of mocking each one individually. + server.respondWith([200, {}, 'DefaultMockedResponse']); + + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + + return { + server, + httpRequestsMockHelpers, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts new file mode 100644 index 0000000000000..c1d75b3c2dd9b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setup as componentTemplatesListSetup } from './component_template_list.helpers'; + +export { nextTick, getRandomString, findTestSubject } from '../../../../../../../../../test_utils'; + +export { setupEnvironment } from './setup_environment'; + +export const pageHelpers = { + componentTemplateList: { setup: componentTemplatesListSetup }, +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx new file mode 100644 index 0000000000000..c0aeb70166b5b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import React from 'react'; +import axios from 'axios'; +import axiosXhrAdapter from 'axios/lib/adapters/xhr'; + +import { HttpSetup } from 'kibana/public'; +import { BASE_PATH, API_BASE_PATH } from '../../../../../../../common/constants'; +import { + notificationServiceMock, + docLinksServiceMock, +} from '../../../../../../../../../../src/core/public/mocks'; + +import { init as initHttpRequests } from './http_requests'; +import { ComponentTemplatesProvider } from '../../../component_templates_context'; + +const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); + +const appDependencies = { + httpClient: (mockHttpClient as unknown) as HttpSetup, + apiBasePath: API_BASE_PATH, + appBasePath: BASE_PATH, + trackMetric: () => {}, + docLinks: docLinksServiceMock.createStartContract(), + toasts: notificationServiceMock.createSetupContract().toasts, +}; + +export const setupEnvironment = () => { + const { server, httpRequestsMockHelpers } = initHttpRequests(); + + return { + server, + httpRequestsMockHelpers, + }; +}; + +export const WithAppDependencies = (Comp: any) => (props: any) => ( + + + +); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx new file mode 100644 index 0000000000000..41fa608ef538b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { SectionLoading } from '../shared_imports'; +import { useComponentTemplatesContext } from '../component_templates_context'; +import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; + +import { EmptyPrompt } from './empty_prompt'; +import { ComponentTable } from './table'; +import { LoadError } from './error'; +import { ComponentTemplatesDeleteModal } from './delete_modal'; + +export const ComponentTemplateList: React.FunctionComponent = () => { + const { api, trackMetric } = useComponentTemplatesContext(); + + const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates(); + + const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState([]); + + // Track component loaded + useEffect(() => { + trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD); + }, [trackMetric]); + + if (data && data.length === 0) { + return ; + } + + let content: React.ReactNode; + + if (isLoading) { + content = ( + + + + ); + } else if (data?.length) { + content = ( + + ); + } else if (error) { + content = ; + } + + return ( +
+ {content} + {componentTemplatesToDelete?.length > 0 ? ( + { + if (deleteResponse?.hasDeletedComponentTemplates) { + // refetch the component templates + sendRequest(); + } + setComponentTemplatesToDelete([]); + }} + componentTemplatesToDelete={componentTemplatesToDelete} + /> + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx new file mode 100644 index 0000000000000..bf621065842b5 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useComponentTemplatesContext } from '../component_templates_context'; + +export const ComponentTemplatesDeleteModal = ({ + componentTemplatesToDelete, + callback, +}: { + componentTemplatesToDelete: string[]; + callback: (data?: { hasDeletedComponentTemplates: boolean }) => void; +}) => { + const { toasts, api } = useComponentTemplatesContext(); + const numComponentTemplatesToDelete = componentTemplatesToDelete.length; + + const handleDeleteComponentTemplates = () => { + api + .deleteComponentTemplates(componentTemplatesToDelete) + .then(({ data: { itemsDeleted, errors }, error }) => { + const hasDeletedComponentTemplates = itemsDeleted && itemsDeleted.length; + + if (hasDeletedComponentTemplates) { + const successMessage = + itemsDeleted.length === 1 + ? i18n.translate( + 'xpack.idxMgmt.home.componentTemplates.deleteModal.successDeleteSingleNotificationMessageText', + { + defaultMessage: "Deleted component template '{componentTemplateName}'", + values: { componentTemplateName: componentTemplatesToDelete[0] }, + } + ) + : i18n.translate( + 'xpack.idxMgmt.home.componentTemplates.deleteModal.successDeleteMultipleNotificationMessageText', + { + defaultMessage: + 'Deleted {numSuccesses, plural, one {# component template} other {# component templates}}', + values: { numSuccesses: itemsDeleted.length }, + } + ); + + callback({ hasDeletedComponentTemplates }); + toasts.addSuccess(successMessage); + } + + if (error || errors?.length) { + const hasMultipleErrors = + errors?.length > 1 || (error && componentTemplatesToDelete.length > 1); + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.idxMgmt.home.componentTemplates.deleteModal.multipleErrorsNotificationMessageText', + { + defaultMessage: 'Error deleting {count} component templates', + values: { + count: errors?.length || componentTemplatesToDelete.length, + }, + } + ) + : i18n.translate( + 'xpack.idxMgmt.home.componentTemplates.deleteModal.errorNotificationMessageText', + { + defaultMessage: "Error deleting component template '{name}'", + values: { name: (errors && errors[0].name) || componentTemplatesToDelete[0] }, + } + ); + toasts.addDanger(errorMessage); + } + }); + }; + + const handleOnCancel = () => { + callback(); + }; + + return ( + + + } + onCancel={handleOnCancel} + onConfirm={handleDeleteComponentTemplates} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

+ +

+ +
    + {componentTemplatesToDelete.map((name) => ( +
  • {name}
  • + ))} +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx new file mode 100644 index 0000000000000..edd9f77cbf635 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/empty_prompt.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; + +import { useComponentTemplatesContext } from '../component_templates_context'; + +export const EmptyPrompt: FunctionComponent = () => { + const { documentation } = useComponentTemplatesContext(); + + return ( + + {i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptTitle', { + defaultMessage: 'Start by creating a component template', + })} + + } + body={ +

+ +
+ + {i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptDocumentionLink', { + defaultMessage: 'Learn more', + })} + +

+ } + /> + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx new file mode 100644 index 0000000000000..aa37b9ce5767c --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/error.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink, EuiCallOut } from '@elastic/eui'; + +export interface Props { + onReloadClick: () => void; +} + +export const LoadError: FunctionComponent = ({ onReloadClick }) => { + return ( + + + + ), + }} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/index.ts similarity index 79% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/index.ts rename to x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/index.ts index 3a25359373aa6..84ee48d14bb8c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CreateAnalyticsFlyout } from './create_analytics_flyout'; +export { ComponentTemplateList } from './component_template_list'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx new file mode 100644 index 0000000000000..2d9557e64e6e7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiInMemoryTable, + EuiButton, + EuiInMemoryTableProps, + EuiTextColor, + EuiIcon, +} from '@elastic/eui'; + +import { ComponentTemplateListItem } from '../types'; + +export interface Props { + componentTemplates: ComponentTemplateListItem[]; + onReloadClick: () => void; + onDeleteClick: (componentTemplateName: string[]) => void; +} + +export const ComponentTable: FunctionComponent = ({ + componentTemplates, + onReloadClick, + onDeleteClick, +}) => { + const [selection, setSelection] = useState([]); + + const tableProps: EuiInMemoryTableProps = { + itemId: 'name', + isSelectable: true, + 'data-test-subj': 'componentTemplatesTable', + sorting: { sort: { field: 'name', direction: 'asc' } }, + selection: { + onSelectionChange: setSelection, + selectable: ({ usedBy }) => usedBy.length === 0, + selectableMessage: (selectable) => + selectable + ? i18n.translate('xpack.idxMgmt.componentTemplatesList.table.selectionLabel', { + defaultMessage: 'Select this component template', + }) + : i18n.translate('xpack.idxMgmt.componentTemplatesList.table.disabledSelectionLabel', { + defaultMessage: 'Component template is in use and cannot be deleted', + }), + }, + rowProps: () => ({ + 'data-test-subj': 'componentTemplateTableRow', + }), + search: { + toolsLeft: + selection.length > 0 ? ( + onDeleteClick(selection.map(({ name }) => name))} + color="danger" + > + + + ) : undefined, + toolsRight: [ + + {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.reloadButtonLabel', { + defaultMessage: 'Reload', + })} + , + ], + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_toggle_group', + field: 'usedBy.length', + items: [ + { + value: 1, + name: i18n.translate( + 'xpack.idxMgmt.componentTemplatesList.table.inUseFilterOptionLabel', + { + defaultMessage: 'In use', + } + ), + operator: 'gte', + }, + { + value: 0, + name: i18n.translate( + 'xpack.idxMgmt.componentTemplatesList.table.notInUseFilterOptionLabel', + { + defaultMessage: 'Not in use', + } + ), + operator: 'eq', + }, + ], + }, + ], + }, + pagination: { + initialPageSize: 10, + pageSizeOptions: [10, 20, 50], + }, + columns: [ + { + field: 'name', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.nameColumnTitle', { + defaultMessage: 'Name', + }), + sortable: true, + }, + { + field: 'usedBy', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isInUseColumnTitle', { + defaultMessage: 'Index templates', + }), + sortable: true, + render: (usedBy: string[]) => { + if (usedBy.length) { + return usedBy.length; + } + + return ( + + + + + + ); + }, + }, + { + field: 'hasMappings', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.mappingsColumnTitle', { + defaultMessage: 'Mappings', + }), + truncateText: true, + sortable: true, + render: (hasMappings: boolean) => (hasMappings ? : null), + }, + { + field: 'hasSettings', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.settingsColumnTitle', { + defaultMessage: 'Settings', + }), + truncateText: true, + sortable: true, + render: (hasSettings: boolean) => (hasSettings ? : null), + }, + { + field: 'hasAliases', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.aliasesColumnTitle', { + defaultMessage: 'Aliases', + }), + truncateText: true, + sortable: true, + render: (hasAliases: boolean) => (hasAliases ? : null), + }, + { + name: ( + + ), + actions: [ + { + 'data-test-subj': 'deleteComponentTemplateButton', + isPrimary: true, + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.deleteActionLabel', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.idxMgmt.componentTemplatesList.table.deleteActionDescription', + { defaultMessage: 'Delete this component template' } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: ({ name }) => onDeleteClick([name]), + enabled: ({ usedBy }) => usedBy.length === 0, + }, + ], + }, + ], + items: componentTemplates ?? [], + }; + + return ; +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx new file mode 100644 index 0000000000000..6f5f5bdebd6d0 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; +import { HttpSetup, DocLinksSetup, NotificationsSetup } from 'src/core/public'; + +import { getApi, getUseRequest, getSendRequest, getDocumentation } from './lib'; + +const ComponentTemplatesContext = createContext(undefined); + +interface Props { + httpClient: HttpSetup; + apiBasePath: string; + appBasePath: string; + trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void; + docLinks: DocLinksSetup; + toasts: NotificationsSetup['toasts']; +} + +interface Context { + api: ReturnType; + documentation: ReturnType; + trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void; + toasts: NotificationsSetup['toasts']; + appBasePath: string; +} + +export const ComponentTemplatesProvider = ({ + children, + value, +}: { + value: Props; + children: React.ReactNode; +}) => { + const { httpClient, apiBasePath, trackMetric, docLinks, toasts, appBasePath } = value; + + const useRequest = getUseRequest(httpClient); + const sendRequest = getSendRequest(httpClient); + + const api = getApi(useRequest, sendRequest, apiBasePath, trackMetric); + const documentation = getDocumentation(docLinks); + + return ( + + {children} + + ); +}; + +export const useComponentTemplatesContext = () => { + const ctx = useContext(ComponentTemplatesContext); + if (!ctx) { + throw new Error( + '"useComponentTemplatesContext" can only be called inside of ComponentTemplatesProvider!' + ); + } + return ctx; +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/constants.ts b/x-pack/plugins/index_management/public/application/components/component_templates/constants.ts new file mode 100644 index 0000000000000..3e763119fa9fb --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/constants.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; + * you may not use this file except in compliance with the Elastic License. + */ + +// ui metric constants +export const UIM_COMPONENT_TEMPLATE_LIST_LOAD = 'component_template_list_load'; +export const UIM_COMPONENT_TEMPLATE_DELETE = 'component_template_delete'; +export const UIM_COMPONENT_TEMPLATE_DELETE_MANY = 'component_template_delete_many'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/index.ts new file mode 100644 index 0000000000000..e0219ec71787f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ComponentTemplatesProvider } from './component_templates_context'; + +export { ComponentTemplateList } from './component_template_list'; + +export * from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts new file mode 100644 index 0000000000000..351e83c6c0cb5 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ComponentTemplateListItem } from '../types'; +import { UseRequestHook, SendRequestHook } from './request'; +import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, UIM_COMPONENT_TEMPLATE_DELETE } from '../constants'; + +export const getApi = ( + useRequest: UseRequestHook, + sendRequest: SendRequestHook, + apiBasePath: string, + trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void +) => { + function useLoadComponentTemplates() { + return useRequest({ + path: `${apiBasePath}/component_templates`, + method: 'get', + }); + } + + function deleteComponentTemplates(names: string[]) { + const result = sendRequest({ + path: `${apiBasePath}/component_templates/${names + .map((name) => encodeURIComponent(name)) + .join(',')}`, + method: 'delete', + }); + + trackMetric( + 'count', + names.length > 1 ? UIM_COMPONENT_TEMPLATE_DELETE_MANY : UIM_COMPONENT_TEMPLATE_DELETE + ); + + return result; + } + + return { + useLoadComponentTemplates, + deleteComponentTemplates, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts new file mode 100644 index 0000000000000..dc27dadf0b807 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocLinksSetup } from 'src/core/public'; + +export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksSetup) => { + const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; + + return { + componentTemplates: `${esDocsBase}/indices-component-template.html`, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/index.ts new file mode 100644 index 0000000000000..9a91312f83294 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './api'; + +export * from './request'; + +export * from './documentation'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts new file mode 100644 index 0000000000000..97ffa4d875ecb --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; + +import { + UseRequestConfig, + UseRequestResponse, + SendRequestConfig, + SendRequestResponse, + sendRequest as _sendRequest, + useRequest as _useRequest, +} from '../shared_imports'; + +export type UseRequestHook = (config: UseRequestConfig) => UseRequestResponse; +export type SendRequestHook = (config: SendRequestConfig) => Promise; + +export const getUseRequest = (httpClient: HttpSetup): UseRequestHook => ( + config: UseRequestConfig +) => { + return _useRequest(httpClient, config); +}; + +export const getSendRequest = (httpClient: HttpSetup): SendRequestHook => ( + config: SendRequestConfig +) => { + return _sendRequest(httpClient, config); +}; diff --git a/x-pack/plugins/infra/public/routers/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts similarity index 54% rename from x-pack/plugins/infra/public/routers/index.ts rename to x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts index 71ab2613d8dc1..863b00b353c49 100644 --- a/x-pack/plugins/infra/public/routers/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts @@ -3,13 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { History } from 'history'; -export * from './logs_router'; -export * from './metrics_router'; - -interface RouterProps { - history: History; -} - -export type AppRouter = React.FC; +export { + UseRequestConfig, + UseRequestResponse, + SendRequestConfig, + SendRequestResponse, + sendRequest, + useRequest, + SectionLoading, +} from '../../../../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/types.ts b/x-pack/plugins/index_management/public/application/components/component_templates/types.ts new file mode 100644 index 0000000000000..0aab3b6b0a94a --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Ideally, we shouldn't depend on anything in index management that is +// outside of the components_templates directory +// We could consider creating shared types or duplicating the types here if +// the component_templates app were to move outside of index management +import { + ComponentTemplateSerialized, + ComponentTemplateDeserialized, + ComponentTemplateListItem, +} from '../../../../common'; + +export { ComponentTemplateSerialized, ComponentTemplateDeserialized, ComponentTemplateListItem }; diff --git a/x-pack/plugins/index_management/public/application/components/index.ts b/x-pack/plugins/index_management/public/application/components/index.ts index e6d836c0d0501..7ec25ed5583b7 100644 --- a/x-pack/plugins/index_management/public/application/components/index.ts +++ b/x-pack/plugins/index_management/public/application/components/index.ts @@ -11,3 +11,4 @@ export { PageErrorForbidden } from './page_error'; export { TemplateDeleteModal } from './template_delete_modal'; export { TemplateForm } from './template_form'; export * from './mappings_editor'; +export * from './component_templates'; diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 8da556cc81fcc..5d1096c9ee24e 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -10,9 +10,12 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { CoreStart } from '../../../../../src/core/public'; +import { API_BASE_PATH, BASE_PATH } from '../../common'; + import { AppContextProvider, AppDependencies } from './app_context'; import { App } from './app'; import { indexManagementStore } from './store'; +import { ComponentTemplatesProvider } from './components'; export const renderApp = ( elem: HTMLElement | null, @@ -22,15 +25,26 @@ export const renderApp = ( return () => undefined; } - const { i18n } = core; + const { i18n, docLinks, notifications } = core; const { Context: I18nContext } = i18n; const { services, history } = dependencies; + const componentTemplateProviderValues = { + httpClient: services.httpService.httpClient, + apiBasePath: API_BASE_PATH, + appBasePath: BASE_PATH, + trackMetric: services.uiMetricService.trackMetric.bind(services.uiMetricService), + docLinks, + toasts: notifications.toasts, + }; + render( - + + + , diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx new file mode 100644 index 0000000000000..a6c8b83a05f98 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { SectionLoading, SectionError, Error } from '../../../../components'; +import { useLoadDataStream } from '../../../../services/api'; + +interface Props { + dataStreamName: string; + onClose: () => void; +} + +/** + * NOTE: This currently isn't in use by data_stream_list.tsx because it doesn't contain any + * information that doesn't already exist in the table. We'll use it once we add additional + * info, e.g. storage size, docs count. + */ +export const DataStreamDetailPanel: React.FunctionComponent = ({ + dataStreamName, + onClose, +}) => { + const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName); + + let content; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + + } + error={error as Error} + data-test-subj="sectionError" + /> + ); + } else if (dataStream) { + content = {JSON.stringify(dataStream)}; + } + + return ( + + + +

+ {dataStreamName} +

+
+
+ + {content} + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/index.ts similarity index 74% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/index.ts rename to x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/index.ts index c8e7a958f6d42..3f45267c032ed 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CreateAnalyticsFlyoutWrapper } from './create_analytics_flyout_wrapper'; +export { DataStreamDetailPanel } from './data_stream_detail_panel'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx new file mode 100644 index 0000000000000..951c4a0d7f3c3 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiTitle, EuiText, EuiSpacer, EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; + +import { reactRouterNavigate } from '../../../../shared_imports'; +import { SectionError, SectionLoading, Error } from '../../../components'; +import { useLoadDataStreams } from '../../../services/api'; +import { DataStreamTable } from './data_stream_table'; + +interface MatchParams { + dataStreamName?: string; +} + +export const DataStreamList: React.FunctionComponent> = ({ + match: { + params: { dataStreamName }, + }, + history, +}) => { + const { error, isLoading, data: dataStreams, sendRequest: reload } = useLoadDataStreams(); + + let content; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + + } + error={error as Error} + /> + ); + } else if (Array.isArray(dataStreams) && dataStreams.length === 0) { + content = ( + + + + } + body={ +

+ + {i18n.translate('xpack.idxMgmt.dataStreamList.emptyPrompt.getStartedLink', { + defaultMessage: 'composable index template', + })} + + ), + }} + /> +

+ } + data-test-subj="emptyPrompt" + /> + ); + } else if (Array.isArray(dataStreams) && dataStreams.length > 0) { + content = ( + <> + {/* TODO: Add a switch for toggling on data streams created by Ingest Manager */} + + + + + + + + + + + {/* TODO: Implement this once we have something to put in here, e.g. storage size, docs count */} + {/* dataStreamName && ( + { + history.push('/data_streams'); + }} + /> + )*/} + + ); + } + + return
{content}
; +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx new file mode 100644 index 0000000000000..54b215e561b46 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiButton, EuiLink } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; + +import { DataStream } from '../../../../../../common/types'; +import { reactRouterNavigate } from '../../../../../shared_imports'; +import { encodePathForReactRouter } from '../../../../services/routing'; + +interface Props { + dataStreams?: DataStream[]; + reload: () => {}; + history: ScopedHistory; + filters?: string; +} + +export const DataStreamTable: React.FunctionComponent = ({ + dataStreams, + reload, + history, + filters, +}) => { + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.nameColumnTitle', { + defaultMessage: 'Name', + }), + truncateText: true, + sortable: true, + // TODO: Render as a link to open the detail panel + }, + { + field: 'indices', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.indicesColumnTitle', { + defaultMessage: 'Indices', + }), + truncateText: true, + sortable: true, + render: (indices: DataStream['indices'], dataStream) => ( + + {indices.length} + + ), + }, + { + field: 'timeStampField', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.timeStampFieldColumnTitle', { + defaultMessage: 'Timestamp field', + }), + truncateText: true, + sortable: true, + }, + { + field: 'generation', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.generationFieldColumnTitle', { + defaultMessage: 'Generation', + }), + truncateText: true, + sortable: true, + }, + ]; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50], + }; + + const sorting = { + sort: { + field: 'name', + direction: 'asc', + }, + } as const; + + const searchConfig = { + query: filters, + box: { + incremental: true, + }, + toolsLeft: undefined /* TODO: Actions menu */, + toolsRight: [ + + + , + ], + }; + + return ( + <> + ({ + 'data-test-subj': 'row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="dataStreamTable" + message={ + + } + /> + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/index.ts similarity index 79% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/index.ts rename to x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/index.ts index 20b96a3668e4b..3922ca5c1d50c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CreateAnalyticsForm } from './create_analytics_form'; +export { DataStreamTable } from './data_stream_table'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/index.ts new file mode 100644 index 0000000000000..e2f588cc2a0fb --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DataStreamList } from './data_stream_list'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/home.tsx b/x-pack/plugins/index_management/public/application/sections/home/home.tsx index 9d4331d742a25..51deaf42cc72c 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/home.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/home.tsx @@ -19,11 +19,25 @@ import { EuiTitle, } from '@elastic/eui'; import { documentationService } from '../../services/documentation'; +import { DataStreamList } from './data_stream_list'; import { IndexList } from './index_list'; import { TemplateList } from './template_list'; +import { ComponentTemplateList } from '../../components/component_templates'; import { breadcrumbService } from '../../services/breadcrumbs'; -type Section = 'indices' | 'templates'; +export enum Section { + Indices = 'indices', + DataStreams = 'data_streams', + IndexTemplates = 'templates', + ComponentTemplates = 'component_templates', +} + +export const homeSections = [ + Section.Indices, + Section.DataStreams, + Section.IndexTemplates, + Section.ComponentTemplates, +]; interface MatchParams { section: Section; @@ -37,11 +51,20 @@ export const IndexManagementHome: React.FunctionComponent { const tabs = [ { - id: 'indices' as Section, + id: Section.Indices, name: , }, { - id: 'templates' as Section, + id: Section.DataStreams, + name: ( + + ), + }, + { + id: Section.IndexTemplates, name: ( ), }, + { + id: Section.ComponentTemplates, + name: ( + + ), + }, ]; const onSectionChange = (newSection: Section) => { @@ -106,13 +138,19 @@ export const IndexManagementHome: React.FunctionComponent - - + + + + diff --git a/x-pack/plugins/index_management/public/application/sections/home/index.ts b/x-pack/plugins/index_management/public/application/sections/home/index.ts index 3a29ef4e58555..b53910748aedb 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { IndexManagementHome } from './home'; +export { IndexManagementHome, Section, homeSections } from './home'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/detail_panel.container.d.ts b/x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/detail_panel.container.d.ts new file mode 100644 index 0000000000000..6f37b4dc486a7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/detail_panel.container.d.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export declare function DetailPanel(props: any): any; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/index.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/index.ts similarity index 100% rename from x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/index.js rename to x-pack/plugins/index_management/public/application/sections/home/index_list/detail_panel/index.ts diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx similarity index 71% rename from x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.js rename to x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx index df5ca7f837d10..f81221238c536 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_list.tsx @@ -5,14 +5,16 @@ */ import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; + import { DetailPanel } from './detail_panel'; import { IndexTable } from './index_table'; -export function IndexList() { +export const IndexList: React.FunctionComponent = ({ history }) => { return (
- +
); -} +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index.ts similarity index 100% rename from x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index.js rename to x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index.ts diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.d.ts b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.d.ts new file mode 100644 index 0000000000000..35ddfc4813617 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.d.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export declare function IndexTable(props: any): any; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index f33d486520a29..c3acff087146a 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -37,8 +37,10 @@ import { } from '@elastic/eui'; import { UIM_SHOW_DETAILS_CLICK } from '../../../../../../common/constants'; +import { reactRouterNavigate } from '../../../../../shared_imports'; import { REFRESH_RATE_INDEX_LIST } from '../../../../constants'; import { healthToColor } from '../../../../services'; +import { encodePathForReactRouter } from '../../../../services/routing'; import { AppContextConsumer } from '../../../../app_context'; import { renderBadges } from '../../../../lib/render_badges'; import { NoMatch, PageErrorForbidden } from '../../../../components'; @@ -117,6 +119,7 @@ export class IndexTable extends Component { } } } + componentWillUnmount() { clearInterval(this.interval); } @@ -146,11 +149,14 @@ export class IndexTable extends Component { const newIsSortAscending = sortField === column ? !isSortAscending : true; sortChanged(column, newIsSortAscending); }; + renderFilterError() { const { filterError } = this.state; + if (!filterError) { return; } + return ( <> @@ -169,6 +175,7 @@ export class IndexTable extends Component { ); } + onFilterChanged = ({ query, error }) => { if (error) { this.setState({ filterError: error }); @@ -177,6 +184,7 @@ export class IndexTable extends Component { this.setState({ filterError: null }); } }; + getFilters = (extensionsService) => { const { allIndices } = this.props; return extensionsService.filters.reduce((accum, filterExtension) => { @@ -184,6 +192,7 @@ export class IndexTable extends Component { return [...accum, ...filtersToAdd]; }, []); }; + toggleAll = () => { const allSelected = this.areAllItemsSelected(); if (allSelected) { @@ -243,7 +252,8 @@ export class IndexTable extends Component { } buildRowCell(fieldName, value, index, appServices) { - const { openDetailPanel, filterChanged } = this.props; + const { openDetailPanel, filterChanged, history } = this.props; + if (fieldName === 'health') { return {value}; } else if (fieldName === 'name') { @@ -261,7 +271,19 @@ export class IndexTable extends Component { {renderBadges(index, filterChanged, appServices.extensionsService)} ); + } else if (fieldName === 'data_stream') { + return ( + + {value} + + ); } + return value; } @@ -480,12 +502,14 @@ export class IndexTable extends Component { + {(indicesLoading && allIndices.length === 0) || indicesError ? null : ( {extensionsService.toggles.map((toggle) => { return this.renderToggleControl(toggle); })} + + + {this.renderBanners(extensionsService)} + {indicesError && this.renderError()} + {atLeastOneItemSelected ? ( @@ -523,6 +551,7 @@ export class IndexTable extends Component { /> ) : null} + {(indicesLoading && allIndices.length === 0) || indicesError ? null : ( @@ -572,11 +601,14 @@ export class IndexTable extends Component { )} + {this.renderFilterError()} + + {indices.length > 0 ? (
- + + {this.buildHeader()} + {this.buildRows(services)}
) : ( emptyState )} + + {indices.length > 0 ? this.renderPager() : null} ); diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx index ec2956973d4f6..807229fb36267 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx @@ -38,7 +38,7 @@ import { Error, } from '../../../../../components'; import { useLoadIndexTemplate } from '../../../../../services/api'; -import { decodePath } from '../../../../../services/routing'; +import { decodePathFromReactRouter } from '../../../../../services/routing'; import { SendRequestResponse } from '../../../../../../shared_imports'; import { useServices } from '../../../../../app_context'; import { TabSummary, TabMappings, TabSettings, TabAliases } from '../../template_details/tabs'; @@ -107,7 +107,7 @@ export const LegacyTemplateDetails: React.FunctionComponent = ({ reload, }) => { const { uiMetricService } = useServices(); - const decodedTemplateName = decodePath(templateName); + const decodedTemplateName = decodePathFromReactRouter(templateName); const { error, data: templateDetails, isLoading } = useLoadIndexTemplate( decodedTemplateName, isLegacy diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx index 92fedd5d68f00..edce05018ce39 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx @@ -9,12 +9,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiInMemoryTable, EuiIcon, EuiButton, EuiLink, EuiBasicTableColumn } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; -import { reactRouterNavigate } from '../../../../../../../../../../src/plugins/kibana_react/public'; +import { SendRequestResponse, reactRouterNavigate } from '../../../../../../shared_imports'; import { TemplateListItem } from '../../../../../../../common'; import { UIM_TEMPLATE_SHOW_DETAILS_CLICK } from '../../../../../../../common/constants'; import { TemplateDeleteModal } from '../../../../../components'; +import { encodePathForReactRouter } from '../../../../../services/routing'; import { useServices } from '../../../../../app_context'; -import { SendRequestResponse } from '../../../../../../shared_imports'; interface Props { templates: TemplateListItem[]; @@ -52,7 +52,7 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ {...reactRouterNavigate( history, { - pathname: `/templates/${encodeURIComponent(encodeURIComponent(name))}`, + pathname: `/templates/${encodePathForReactRouter(name)}`, search: `legacy=${Boolean(item._kbnMeta.isLegacy)}`, }, () => uiMetricService.trackMetric('click', UIM_TEMPLATE_SHOW_DETAILS_CLICK) diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index 8bdd230f89952..82835c56a3877 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -11,7 +11,7 @@ import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../common'; import { TemplateForm, SectionLoading, SectionError, Error } from '../../components'; import { breadcrumbService } from '../../services/breadcrumbs'; -import { decodePath, getTemplateDetailsLink } from '../../services/routing'; +import { decodePathFromReactRouter, getTemplateDetailsLink } from '../../services/routing'; import { saveTemplate, useLoadIndexTemplate } from '../../services/api'; import { getIsLegacyFromQueryParams } from '../../lib/index_templates'; @@ -26,7 +26,7 @@ export const TemplateClone: React.FunctionComponent { - const decodedTemplateName = decodePath(name); + const decodedTemplateName = decodePathFromReactRouter(name); const isLegacy = getIsLegacyFromQueryParams(location); const [isSaving, setIsSaving] = useState(false); diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index d3e539989bc96..7cacb5ee97a60 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -11,7 +11,7 @@ import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@e import { TemplateDeserialized } from '../../../../common'; import { breadcrumbService } from '../../services/breadcrumbs'; import { useLoadIndexTemplate, updateTemplate } from '../../services/api'; -import { decodePath, getTemplateDetailsLink } from '../../services/routing'; +import { decodePathFromReactRouter, getTemplateDetailsLink } from '../../services/routing'; import { SectionLoading, SectionError, TemplateForm, Error } from '../../components'; import { getIsLegacyFromQueryParams } from '../../lib/index_templates'; @@ -26,7 +26,7 @@ export const TemplateEdit: React.FunctionComponent { - const decodedTemplateName = decodePath(name); + const decodedTemplateName = decodePathFromReactRouter(name); const isLegacy = getIsLegacyFromQueryParams(location); const [isSaving, setIsSaving] = useState(false); diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index 3961942b83ea3..5ad84395d24c2 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -31,14 +31,12 @@ import { UIM_TEMPLATE_UPDATE, UIM_TEMPLATE_CLONE, } from '../../../common/constants'; - +import { TemplateDeserialized, TemplateListItem, DataStream } from '../../../common'; +import { IndexMgmtMetricsType } from '../../types'; import { TAB_SETTINGS, TAB_MAPPING, TAB_STATS } from '../constants'; - import { useRequest, sendRequest } from './use_request'; import { httpService } from './http'; import { UiMetricService } from './ui_metric'; -import { TemplateDeserialized, TemplateListItem } from '../../../common'; -import { IndexMgmtMetricsType } from '../../types'; // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context @@ -48,6 +46,21 @@ export const setUiMetricService = (_uiMetricService: UiMetricService({ + path: `${API_BASE_PATH}/data_streams`, + method: 'get', + }); +} + +// TODO: Implement this API endpoint once we have content to surface in the detail panel. +export function useLoadDataStream(name: string) { + return useRequest({ + path: `${API_BASE_PATH}/data_stream/${encodeURIComponent(name)}`, + method: 'get', + }); +} + export async function loadIndices() { const response = await httpService.httpClient.get(`${API_BASE_PATH}/indices`); return response.data ? response.data : response; @@ -211,14 +224,14 @@ export async function loadIndexData(type: string, indexName: string) { export function useLoadIndexTemplates() { return useRequest<{ templates: TemplateListItem[]; legacyTemplates: TemplateListItem[] }>({ - path: `${API_BASE_PATH}/index-templates`, + path: `${API_BASE_PATH}/index_templates`, method: 'get', }); } export async function deleteTemplates(templates: Array<{ name: string; isLegacy?: boolean }>) { const result = sendRequest({ - path: `${API_BASE_PATH}/delete-index-templates`, + path: `${API_BASE_PATH}/delete_index_templates`, method: 'post', body: { templates }, }); @@ -232,7 +245,7 @@ export async function deleteTemplates(templates: Array<{ name: string; isLegacy? export function useLoadIndexTemplate(name: TemplateDeserialized['name'], isLegacy?: boolean) { return useRequest({ - path: `${API_BASE_PATH}/index-templates/${encodeURIComponent(name)}`, + path: `${API_BASE_PATH}/index_templates/${encodeURIComponent(name)}`, method: 'get', query: { legacy: isLegacy, @@ -242,7 +255,7 @@ export function useLoadIndexTemplate(name: TemplateDeserialized['name'], isLegac export async function saveTemplate(template: TemplateDeserialized, isClone?: boolean) { const result = await sendRequest({ - path: `${API_BASE_PATH}/index-templates`, + path: `${API_BASE_PATH}/index_templates`, method: 'post', body: JSON.stringify(template), }); @@ -257,7 +270,7 @@ export async function saveTemplate(template: TemplateDeserialized, isClone?: boo export async function updateTemplate(template: TemplateDeserialized) { const { name } = template; const result = await sendRequest({ - path: `${API_BASE_PATH}/index-templates/${encodeURIComponent(name)}`, + path: `${API_BASE_PATH}/index_templates/${encodeURIComponent(name)}`, method: 'put', body: JSON.stringify(template), }); diff --git a/x-pack/plugins/index_management/public/application/services/routing.ts b/x-pack/plugins/index_management/public/application/services/routing.ts index a999c58f5bb42..2a895196189d0 100644 --- a/x-pack/plugins/index_management/public/application/services/routing.ts +++ b/x-pack/plugins/index_management/public/application/services/routing.ts @@ -6,10 +6,8 @@ export const getTemplateListLink = () => `/templates`; -// Need to add some additonal encoding/decoding logic to work with React Router -// For background, see: https://github.com/ReactTraining/history/issues/505 export const getTemplateDetailsLink = (name: string, isLegacy?: boolean, withHash = false) => { - const baseUrl = `/templates/${encodeURIComponent(encodeURIComponent(name))}`; + const baseUrl = `/templates/${encodePathForReactRouter(name)}`; let url = withHash ? `#${baseUrl}` : baseUrl; if (isLegacy) { url = `${url}?legacy=${isLegacy}`; @@ -18,18 +16,14 @@ export const getTemplateDetailsLink = (name: string, isLegacy?: boolean, withHas }; export const getTemplateEditLink = (name: string, isLegacy?: boolean) => { - return encodeURI( - `/edit_template/${encodeURIComponent(encodeURIComponent(name))}?legacy=${isLegacy === true}` - ); + return encodeURI(`/edit_template/${encodePathForReactRouter(name)}?legacy=${isLegacy === true}`); }; export const getTemplateCloneLink = (name: string, isLegacy?: boolean) => { - return encodeURI( - `/clone_template/${encodeURIComponent(encodeURIComponent(name))}?legacy=${isLegacy === true}` - ); + return encodeURI(`/clone_template/${encodePathForReactRouter(name)}?legacy=${isLegacy === true}`); }; -export const decodePath = (pathname: string): string => { +export const decodePathFromReactRouter = (pathname: string): string => { let decodedPath; try { decodedPath = decodeURI(pathname); @@ -39,3 +33,8 @@ export const decodePath = (pathname: string): string => { } return decodeURIComponent(decodedPath); }; + +// Need to add some additonal encoding/decoding logic to work with React Router +// For background, see: https://github.com/ReactTraining/history/issues/505 +export const encodePathForReactRouter = (pathname: string): string => + encodeURIComponent(encodeURIComponent(pathname)); diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 8942367261511..afd5a5cf650e1 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -31,3 +31,5 @@ export { export { getFormRow, Field } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { isJSON } from '../../../../src/plugins/es_ui_shared/static/validators/string'; + +export { reactRouterNavigate } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index 65bd5411a249b..6b1bf47512b21 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -10,6 +10,47 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) Client.prototype.dataManagement = components.clientAction.namespaceFactory(); const dataManagement = Client.prototype.dataManagement.prototype; + // Data streams + dataManagement.getDataStreams = ca({ + urls: [ + { + fmt: '/_data_stream', + }, + ], + method: 'GET', + }); + + // We don't allow the user to create a data stream in the UI or API. We're just adding this here + // to enable the API integration tests. + dataManagement.createDataStream = ca({ + urls: [ + { + fmt: '/_data_stream/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'PUT', + }); + + dataManagement.deleteDataStream = ca({ + urls: [ + { + fmt: '/_data_stream/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'DELETE', + }); + + // Component templates dataManagement.getComponentTemplates = ca({ urls: [ { @@ -60,4 +101,43 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'DELETE', }); + + // Composable index templates + dataManagement.getComposableIndexTemplates = ca({ + urls: [ + { + fmt: '/_index_template', + }, + ], + method: 'GET', + }); + + dataManagement.saveComposableIndexTemplate = ca({ + urls: [ + { + fmt: '/_index_template/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + needBody: true, + method: 'PUT', + }); + + dataManagement.deleteComposableIndexTemplate = ca({ + urls: [ + { + fmt: '/_index_template/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'DELETE', + }); }; diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts index 87aa64421624e..f6f8e7d63d370 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts @@ -5,6 +5,11 @@ */ import { schema } from '@kbn/config-schema'; +import { + deserializeComponentTemplate, + deserializeComponenTemplateList, +} from '../../../../common/lib'; +import { ComponentTemplateFromEs } from '../../../../common'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; @@ -20,9 +25,25 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou const { callAsCurrentUser } = ctx.dataManagement!.client; try { - const response = await callAsCurrentUser('dataManagement.getComponentTemplates'); + const { + component_templates: componentTemplates, + }: { component_templates: ComponentTemplateFromEs[] } = await callAsCurrentUser( + 'dataManagement.getComponentTemplates' + ); + + const { index_templates: indexTemplates } = await callAsCurrentUser( + 'dataManagement.getComposableIndexTemplates' + ); + + const body = componentTemplates.map((componentTemplate) => { + const deserializedComponentTemplateListItem = deserializeComponenTemplateList( + componentTemplate, + indexTemplates + ); + return deserializedComponentTemplateListItem; + }); - return res.ok({ body: response.component_templates }); + return res.ok({ body }); } catch (error) { if (isEsError(error)) { return res.customError({ @@ -56,11 +77,12 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou } ); + const { index_templates: indexTemplates } = await callAsCurrentUser( + 'dataManagement.getComposableIndexTemplates' + ); + return res.ok({ - body: { - ...componentTemplates[0], - name, - }, + body: deserializeComponentTemplate(componentTemplates[0], indexTemplates), }); } catch (error) { if (isEsError(error)) { diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts new file mode 100644 index 0000000000000..56c514e30f242 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDependencies } from '../../../types'; + +import { registerGetAllRoute } from './register_get_route'; + +export function registerDataStreamRoutes(dependencies: RouteDependencies) { + registerGetAllRoute(dependencies); +} diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts new file mode 100644 index 0000000000000..9128556130bf4 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { deserializeDataStreamList } from '../../../../common/lib'; +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../index'; + +export function registerGetAllRoute({ router, license, lib: { isEsError } }: RouteDependencies) { + router.get( + { path: addBasePath('/data_streams'), validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.dataManagement!.client; + + try { + const dataStreams = await callAsCurrentUser('dataManagement.getDataStreams'); + const body = deserializeDataStreamList(dataStreams); + + return res.ok({ body }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +} diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts index 26e74847e3e05..e0d92b3800785 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts @@ -16,7 +16,7 @@ const bodySchema = templateSchema; export function registerCreateRoute({ router, license, lib }: RouteDependencies) { router.post( - { path: addBasePath('/index-templates'), validate: { body: bodySchema } }, + { path: addBasePath('/index_templates'), validate: { body: bodySchema } }, license.guardApiRoute(async (ctx, req, res) => { const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; const template = req.body as TemplateDeserialized; diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_delete_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_delete_route.ts index b5cc00ad6d8cc..1527af12a92a4 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_delete_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_delete_route.ts @@ -24,7 +24,7 @@ const bodySchema = schema.object({ export function registerDeleteRoute({ router, license }: RouteDependencies) { router.post( { - path: addBasePath('/delete-index-templates'), + path: addBasePath('/delete_index_templates'), validate: { body: bodySchema }, }, license.guardApiRoute(async (ctx, req, res) => { diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts index 12ec005258a62..ae5f7802a8408 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts @@ -16,7 +16,7 @@ import { addBasePath } from '../index'; export function registerGetAllRoute({ router, license }: RouteDependencies) { router.get( - { path: addBasePath('/index-templates'), validate: false }, + { path: addBasePath('/index_templates'), validate: false }, license.guardApiRoute(async (ctx, req, res) => { const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; const managedTemplatePrefix = await getManagedTemplatePrefix(callAsCurrentUser); @@ -55,7 +55,7 @@ const querySchema = schema.object({ export function registerGetOneRoute({ router, license, lib }: RouteDependencies) { router.get( { - path: addBasePath('/index-templates/{name}'), + path: addBasePath('/index_templates/{name}'), validate: { params: paramsSchema, query: querySchema }, }, license.guardApiRoute(async (ctx, req, res) => { diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts index 5b2a0d8722e46..7e9c3174d0591 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts @@ -19,7 +19,7 @@ const paramsSchema = schema.object({ export function registerUpdateRoute({ router, license, lib }: RouteDependencies) { router.put( { - path: addBasePath('/index-templates/{name}'), + path: addBasePath('/index_templates/{name}'), validate: { body: bodySchema, params: paramsSchema }, }, license.guardApiRoute(async (ctx, req, res) => { diff --git a/x-pack/plugins/index_management/server/routes/index.ts b/x-pack/plugins/index_management/server/routes/index.ts index 1e5aaf8087624..202e6919f7b13 100644 --- a/x-pack/plugins/index_management/server/routes/index.ts +++ b/x-pack/plugins/index_management/server/routes/index.ts @@ -6,6 +6,7 @@ import { RouteDependencies } from '../types'; +import { registerDataStreamRoutes } from './api/data_streams'; import { registerIndicesRoutes } from './api/indices'; import { registerTemplateRoutes } from './api/templates'; import { registerMappingRoute } from './api/mapping'; @@ -15,6 +16,7 @@ import { registerComponentTemplateRoutes } from './api/component_templates'; export class ApiRoutes { setup(dependencies: RouteDependencies) { + registerDataStreamRoutes(dependencies); registerIndicesRoutes(dependencies); registerTemplateRoutes(dependencies); registerSettingsRoutes(dependencies); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 7a71bb68bc54f..d5d61733e8717 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -384,3 +384,7 @@ export const Expressions: React.FC = (props) => { ); }; + +// required for dynamic import +// eslint-disable-next-line import/no-default-export +export default Expressions; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx index 2d9524ca158c8..da342f0a45420 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx @@ -5,7 +5,6 @@ */ import { i18n } from '@kbn/i18n'; -import { isNumber } from 'lodash'; import { MetricExpressionParams, Comparator, @@ -106,3 +105,5 @@ export function validateMetricThreshold({ return validationResult; } + +const isNumber = (value: unknown): value is number => typeof value === 'number'; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts index a40cb1eaec50c..6a999a86c99d1 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import React from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; -import { Expressions } from './components/expression'; import { validateMetricThreshold } from './components/validation'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../server/lib/alerting/metric_threshold/types'; @@ -18,7 +18,7 @@ export function createMetricThresholdAlertType(): AlertTypeModel { defaultMessage: 'Metric threshold', }), iconClass: 'bell', - alertParamsExpression: Expressions, + alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.infra.metrics.alerting.threshold.defaultActionMessage', diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx new file mode 100644 index 0000000000000..facb0f1539a10 --- /dev/null +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { CoreStart } from 'kibana/public'; +import { ApolloClient } from 'apollo-client'; +import { + useUiSetting$, + KibanaContextProvider, +} from '../../../../../src/plugins/kibana_react/public'; +import { TriggersActionsProvider } from '../utils/triggers_actions_context'; +import { ClientPluginDeps } from '../types'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; +import { ApolloClientContext } from '../utils/apollo_context'; +import { EuiThemeProvider } from '../../../observability/public'; +import { NavigationWarningPromptProvider } from '../utils/navigation_warning_prompt'; + +export const CommonInfraProviders: React.FC<{ + apolloClient: ApolloClient<{}>; + triggersActionsUI: TriggersAndActionsUIPublicPluginStart; +}> = ({ apolloClient, children, triggersActionsUI }) => { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + + + {children} + + + + ); +}; + +export const CoreProviders: React.FC<{ + core: CoreStart; + plugins: ClientPluginDeps; +}> = ({ children, core, plugins }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/infra/public/apps/common_styles.ts b/x-pack/plugins/infra/public/apps/common_styles.ts new file mode 100644 index 0000000000000..546b83a69035c --- /dev/null +++ b/x-pack/plugins/infra/public/apps/common_styles.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CONTAINER_CLASSNAME = 'infra-container-element'; + +export const prepareMountElement = (element: HTMLElement) => { + // Ensure the element we're handed from application mounting is assigned a class + // for our index.scss styles to apply to. + element.classList.add(CONTAINER_CLASSNAME); +}; diff --git a/x-pack/plugins/infra/public/apps/legacy_app.tsx b/x-pack/plugins/infra/public/apps/legacy_app.tsx new file mode 100644 index 0000000000000..195f252c6b60f --- /dev/null +++ b/x-pack/plugins/infra/public/apps/legacy_app.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiErrorBoundary } from '@elastic/eui'; +import { createBrowserHistory, History } from 'history'; +import { AppMountParameters } from 'kibana/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, RouteProps, Router, Switch } from 'react-router-dom'; +import url from 'url'; + +// This exists purely to facilitate legacy app/infra URL redirects. +// It will be removed in 8.0.0. +export async function renderApp({ element }: AppMountParameters) { + const history = createBrowserHistory(); + + ReactDOM.render(, element); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +} + +const LegacyApp: React.FunctionComponent<{ history: History }> = ({ history }) => { + return ( + + + + { + if (!location) { + return null; + } + + let nextPath = ''; + let nextBasePath = ''; + let nextSearch; + + if ( + location.hash.indexOf('#infrastructure') > -1 || + location.hash.indexOf('#/infrastructure') > -1 + ) { + nextPath = location.hash.replace( + new RegExp( + '#infrastructure/|#/infrastructure/|#/infrastructure|#infrastructure', + 'g' + ), + '' + ); + nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); + } else if ( + location.hash.indexOf('#logs') > -1 || + location.hash.indexOf('#/logs') > -1 + ) { + nextPath = location.hash.replace( + new RegExp('#logs/|#/logs/|#/logs|#logs', 'g'), + '' + ); + nextBasePath = location.pathname.replace('app/infra', 'app/logs'); + } else { + // This covers /app/infra and /app/infra/home (both of which used to render + // the metrics inventory page) + nextPath = 'inventory'; + nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); + nextSearch = undefined; + } + + // app/infra#infrastructure/metrics/:type/:node was changed to app/metrics/detail/:type/:node, this + // accounts for that edge case + nextPath = nextPath.replace('metrics/', 'detail/'); + + // Query parameters (location.search) will arrive as part of location.hash and not location.search + const nextPathParts = nextPath.split('?'); + nextPath = nextPathParts[0]; + nextSearch = nextPathParts[1] ? nextPathParts[1] : undefined; + + let nextUrl = url.format({ + pathname: `${nextBasePath}/${nextPath}`, + hash: undefined, + search: nextSearch, + }); + + nextUrl = nextUrl.replace('//', '/'); + + window.location.href = nextUrl; + + return null; + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/infra/public/apps/logs_app.tsx b/x-pack/plugins/infra/public/apps/logs_app.tsx new file mode 100644 index 0000000000000..e0251522bb24c --- /dev/null +++ b/x-pack/plugins/infra/public/apps/logs_app.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApolloClient } from 'apollo-client'; +import { History } from 'history'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, Router, Switch } from 'react-router-dom'; +import { AppMountParameters } from '../../../../../src/core/public'; +import '../index.scss'; +import { NotFoundPage } from '../pages/404'; +import { LinkToLogsPage } from '../pages/link_to/link_to_logs'; +import { LogsPage } from '../pages/logs'; +import { ClientPluginDeps } from '../types'; +import { createApolloClient } from '../utils/apollo_client'; +import { CommonInfraProviders, CoreProviders } from './common_providers'; +import { prepareMountElement } from './common_styles'; + +export const renderApp = ( + core: CoreStart, + plugins: ClientPluginDeps, + { element, history }: AppMountParameters +) => { + const apolloClient = createApolloClient(core.http.fetch); + + prepareMountElement(element); + + ReactDOM.render( + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +const LogsApp: React.FC<{ + apolloClient: ApolloClient<{}>; + core: CoreStart; + history: History; + plugins: ClientPluginDeps; +}> = ({ apolloClient, core, history, plugins }) => { + const uiCapabilities = core.application.capabilities; + + return ( + + + + + + {uiCapabilities?.logs?.show && } + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/apps/metrics_app.tsx b/x-pack/plugins/infra/public/apps/metrics_app.tsx new file mode 100644 index 0000000000000..8713abe0510a6 --- /dev/null +++ b/x-pack/plugins/infra/public/apps/metrics_app.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApolloClient } from 'apollo-client'; +import { History } from 'history'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, Router, Switch } from 'react-router-dom'; +import { AppMountParameters } from '../../../../../src/core/public'; +import '../index.scss'; +import { NotFoundPage } from '../pages/404'; +import { LinkToMetricsPage } from '../pages/link_to/link_to_metrics'; +import { InfrastructurePage } from '../pages/metrics'; +import { MetricDetail } from '../pages/metrics/metric_detail'; +import { ClientPluginDeps } from '../types'; +import { createApolloClient } from '../utils/apollo_client'; +import { RedirectWithQueryParams } from '../utils/redirect_with_query_params'; +import { CommonInfraProviders, CoreProviders } from './common_providers'; +import { prepareMountElement } from './common_styles'; + +export const renderApp = ( + core: CoreStart, + plugins: ClientPluginDeps, + { element, history }: AppMountParameters +) => { + const apolloClient = createApolloClient(core.http.fetch); + + prepareMountElement(element); + + ReactDOM.render( + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +const MetricsApp: React.FC<{ + apolloClient: ApolloClient<{}>; + core: CoreStart; + history: History; + plugins: ClientPluginDeps; +}> = ({ apolloClient, core, history, plugins }) => { + const uiCapabilities = core.application.capabilities; + + return ( + + + + + + {uiCapabilities?.infrastructure?.show && ( + + )} + {uiCapabilities?.infrastructure?.show && ( + + )} + {uiCapabilities?.infrastructure?.show && ( + + )} + {uiCapabilities?.infrastructure?.show && ( + + )} + {uiCapabilities?.infrastructure?.show && ( + + )} + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/apps/start_app.tsx b/x-pack/plugins/infra/public/apps/start_app.tsx deleted file mode 100644 index 4c213700b62e6..0000000000000 --- a/x-pack/plugins/infra/public/apps/start_app.tsx +++ /dev/null @@ -1,80 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { ApolloProvider } from 'react-apollo'; -import { CoreStart, AppMountParameters } from 'kibana/public'; - -// TODO use theme provided from parentApp when kibana supports it -import { EuiErrorBoundary } from '@elastic/eui'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { EuiThemeProvider } from '../../../observability/public/typings/eui_styled_components'; -import { InfraFrontendLibs } from '../lib/lib'; -import { ApolloClientContext } from '../utils/apollo_context'; -import { HistoryContext } from '../utils/history_context'; -import { - useUiSetting$, - KibanaContextProvider, -} from '../../../../../src/plugins/kibana_react/public'; -import { AppRouter } from '../routers'; -import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; -import { TriggersActionsProvider } from '../utils/triggers_actions_context'; -import '../index.scss'; -import { NavigationWarningPromptProvider } from '../utils/navigation_warning_prompt'; - -export const CONTAINER_CLASSNAME = 'infra-container-element'; - -export async function startApp( - libs: InfraFrontendLibs, - core: CoreStart, - plugins: object, - params: AppMountParameters, - Router: AppRouter, - triggersActionsUI: TriggersAndActionsUIPublicPluginSetup -) { - const { element, history } = params; - - const InfraPluginRoot: React.FunctionComponent = () => { - const [darkMode] = useUiSetting$('theme:darkMode'); - - return ( - - - - - - - - - - - - - - - - - - ); - }; - - const App: React.FunctionComponent = () => ( - - - - ); - - // Ensure the element we're handed from application mounting is assigned a class - // for our index.scss styles to apply to. - element.className += ` ${CONTAINER_CLASSNAME}`; - - ReactDOM.render(, element); - - return () => { - ReactDOM.unmountComponentAtNode(element); - }; -} diff --git a/x-pack/plugins/infra/public/apps/start_legacy_app.tsx b/x-pack/plugins/infra/public/apps/start_legacy_app.tsx deleted file mode 100644 index 6e5960ceb2081..0000000000000 --- a/x-pack/plugins/infra/public/apps/start_legacy_app.tsx +++ /dev/null @@ -1,100 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createBrowserHistory } from 'history'; -import React from 'react'; -import url from 'url'; -import ReactDOM from 'react-dom'; -import { AppMountParameters } from 'kibana/public'; -import { Route, Router, Switch, RouteProps } from 'react-router-dom'; -// TODO use theme provided from parentApp when kibana supports it -import { EuiErrorBoundary } from '@elastic/eui'; - -// This exists purely to facilitate legacy app/infra URL redirects. -// It will be removed in 8.0.0. -export async function startLegacyApp(params: AppMountParameters) { - const { element } = params; - const history = createBrowserHistory(); - - const App: React.FunctionComponent = () => { - return ( - - - - { - if (!location) { - return null; - } - - let nextPath = ''; - let nextBasePath = ''; - let nextSearch; - - if ( - location.hash.indexOf('#infrastructure') > -1 || - location.hash.indexOf('#/infrastructure') > -1 - ) { - nextPath = location.hash.replace( - new RegExp( - '#infrastructure/|#/infrastructure/|#/infrastructure|#infrastructure', - 'g' - ), - '' - ); - nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); - } else if ( - location.hash.indexOf('#logs') > -1 || - location.hash.indexOf('#/logs') > -1 - ) { - nextPath = location.hash.replace( - new RegExp('#logs/|#/logs/|#/logs|#logs', 'g'), - '' - ); - nextBasePath = location.pathname.replace('app/infra', 'app/logs'); - } else { - // This covers /app/infra and /app/infra/home (both of which used to render - // the metrics inventory page) - nextPath = 'inventory'; - nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); - nextSearch = undefined; - } - - // app/inra#infrastructure/metrics/:type/:node was changed to app/metrics/detail/:type/:node, this - // accounts for that edge case - nextPath = nextPath.replace('metrics/', 'detail/'); - - // Query parameters (location.search) will arrive as part of location.hash and not location.search - const nextPathParts = nextPath.split('?'); - nextPath = nextPathParts[0]; - nextSearch = nextPathParts[1] ? nextPathParts[1] : undefined; - - let nextUrl = url.format({ - pathname: `${nextBasePath}/${nextPath}`, - hash: undefined, - search: nextSearch, - }); - - nextUrl = nextUrl.replace('//', '/'); - - window.location.href = nextUrl; - - return null; - }} - /> - - - - ); - }; - - ReactDOM.render(, element); - - return () => { - ReactDOM.unmountComponentAtNode(element); - }; -} diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx index 074464fb55414..ce14897991e60 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx +++ b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx @@ -336,6 +336,10 @@ export const Expressions: React.FC = (props) => { ); }; +// required for dynamic import +// eslint-disable-next-line import/no-default-export +export default Expressions; + interface ExpressionRowProps { nodeType: InventoryItemType; expressionId: number; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts index 9ede2d2a47727..0cb564ec2194e 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import React from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; -import { Expressions } from './expression'; import { validateMetricThreshold } from './validation'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; @@ -18,7 +18,7 @@ export function getInventoryMetricAlertType(): AlertTypeModel { defaultMessage: 'Inventory', }), iconClass: 'bell', - alertParamsExpression: Expressions, + alertParamsExpression: React.lazy(() => import('./expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.infra.metrics.alerting.inventory.threshold.defaultActionMessage', diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx index 441adeec988c7..47ecd3c527fad 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx +++ b/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx @@ -6,8 +6,6 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { isNumber } from 'lodash'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths import { MetricExpressionParams } from '../../../../server/lib/alerting/metric_threshold/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; @@ -95,3 +93,5 @@ export function validateMetricThreshold({ return validationResult; } + +const isNumber = (value: unknown): value is number => typeof value === 'number'; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index 609f99805fe9c..a3a48d477425b 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -236,3 +236,7 @@ export const Editor: React.FC = (props) => { ); }; + +// required for dynamic import +// eslint-disable-next-line import/no-default-export +export default Editor; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts index 9bba8bd804f80..4c7811f0d9666 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/components/alerting/logs/log_threshold_alert_type.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import React from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/types'; -import { ExpressionEditor } from './expression_editor'; import { validateExpression } from './validation'; export function getAlertType(): AlertTypeModel { @@ -17,7 +17,7 @@ export function getAlertType(): AlertTypeModel { defaultMessage: 'Log threshold', }), iconClass: 'bell', - alertParamsExpression: ExpressionEditor, + alertParamsExpression: React.lazy(() => import('./expression_editor/editor')), validate: validateExpression, defaultActionMessage: i18n.translate( 'xpack.infra.logs.alerting.threshold.defaultActionMessage', diff --git a/x-pack/plugins/infra/public/compose_libs.ts b/x-pack/plugins/infra/public/compose_libs.ts deleted file mode 100644 index f2060983e95eb..0000000000000 --- a/x-pack/plugins/infra/public/compose_libs.ts +++ /dev/null @@ -1,99 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; -import ApolloClient from 'apollo-client'; -import { ApolloLink } from 'apollo-link'; -import { createHttpLink } from 'apollo-link-http'; -import { withClientState } from 'apollo-link-state'; -import { CoreStart, HttpFetchOptions } from 'src/core/public'; -import { InfraFrontendLibs } from './lib/lib'; -import introspectionQueryResultData from './graphql/introspection.json'; -import { InfraKibanaObservableApiAdapter } from './lib/adapters/observable_api/kibana_observable_api'; - -export function composeLibs(core: CoreStart) { - const cache = new InMemoryCache({ - addTypename: false, - fragmentMatcher: new IntrospectionFragmentMatcher({ - introspectionQueryResultData, - }), - }); - - const observableApi = new InfraKibanaObservableApiAdapter({ - basePath: core.http.basePath.get(), - }); - - const wrappedFetch = (path: string, options: HttpFetchOptions) => { - return new Promise(async (resolve, reject) => { - // core.http.fetch isn't 100% compatible with the Fetch API and will - // throw Errors on 401s. This top level try / catch handles those scenarios. - try { - core.http - .fetch(path, { - ...options, - // Set headers to undefined due to this bug: https://github.com/apollographql/apollo-link/issues/249, - // Apollo will try to set a "content-type" header which will conflict with the "Content-Type" header that - // core.http.fetch correctly sets. - headers: undefined, - asResponse: true, - }) - .then((res) => { - if (!res.response) { - return reject(); - } - // core.http.fetch will parse the Response and set a body before handing it back. As such .text() / .json() - // will have already been called on the Response instance. However, Apollo will also want to call - // .text() / .json() on the instance, as it expects the raw Response instance, rather than core's wrapper. - // .text() / .json() can only be called once, and an Error will be thrown if those methods are accessed again. - // This hacks around that by setting up a new .text() method that will restringify the JSON response we already have. - // This does result in an extra stringify / parse cycle, which isn't ideal, but as we only have a few endpoints left using - // GraphQL this shouldn't create excessive overhead. - // Ref: https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-http/src/httpLink.ts#L134 - // and - // https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-http-common/src/index.ts#L125 - return resolve({ - ...res.response, - text: () => { - return new Promise(async (resolveText, rejectText) => { - if (res.body) { - return resolveText(JSON.stringify(res.body)); - } else { - return rejectText(); - } - }); - }, - }); - }); - } catch (error) { - reject(error); - } - }); - }; - - const HttpLink = createHttpLink({ - fetch: wrappedFetch, - uri: `/api/infra/graphql`, - }); - - const graphQLOptions = { - cache, - link: ApolloLink.from([ - withClientState({ - cache, - resolvers: {}, - }), - HttpLink, - ]), - }; - - const apolloClient = new ApolloClient(graphQLOptions); - - const libs: InfraFrontendLibs = { - apolloClient, - observableApi, - }; - return libs; -} diff --git a/x-pack/plugins/infra/public/containers/with_state_from_location.tsx b/x-pack/plugins/infra/public/containers/with_state_from_location.tsx deleted file mode 100644 index 2a9676046d451..0000000000000 --- a/x-pack/plugins/infra/public/containers/with_state_from_location.tsx +++ /dev/null @@ -1,131 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { parse, stringify } from 'query-string'; -import { Location } from 'history'; -import { omit } from 'lodash'; -import React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -// eslint-disable-next-line @typescript-eslint/camelcase -import { decode_object, encode_object } from 'rison-node'; -import { Omit } from '../lib/lib'; - -interface AnyObject { - [key: string]: any; -} - -interface WithStateFromLocationOptions { - mapLocationToState: (location: Location) => StateInLocation; - mapStateToLocation: (state: StateInLocation, location: Location) => Location; -} - -type InjectedPropsFromLocation = Partial & { - pushStateInLocation?: (state: StateInLocation) => void; - replaceStateInLocation?: (state: StateInLocation) => void; -}; - -export const withStateFromLocation = ({ - mapLocationToState, - mapStateToLocation, -}: WithStateFromLocationOptions) => < - WrappedComponentProps extends InjectedPropsFromLocation ->( - WrappedComponent: React.ComponentType -) => { - const wrappedName = WrappedComponent.displayName || WrappedComponent.name; - - return withRouter( - class WithStateFromLocation extends React.PureComponent< - RouteComponentProps<{}> & - Omit> - > { - public static displayName = `WithStateFromLocation(${wrappedName})`; - - public render() { - const { location } = this.props; - const otherProps = omit(this.props, ['location', 'history', 'match', 'staticContext']); - - const stateFromLocation = mapLocationToState(location); - - return ( - // @ts-ignore - - ); - } - - private pushStateInLocation = (state: StateInLocation) => { - const { history, location } = this.props; - - const newLocation = mapStateToLocation(state, this.props.location); - - if (newLocation !== location) { - history.push(newLocation); - } - }; - - private replaceStateInLocation = (state: StateInLocation) => { - const { history, location } = this.props; - - const newLocation = mapStateToLocation(state, this.props.location); - - if (newLocation !== location) { - history.replace(newLocation); - } - }; - } - ); -}; - -const decodeRisonAppState = (queryValues: { _a?: string }): AnyObject => { - try { - return queryValues && queryValues._a ? decode_object(queryValues._a) : {}; - } catch (error) { - if (error instanceof Error && error.message.startsWith('rison decoder error')) { - return {}; - } - throw error; - } -}; - -const encodeRisonAppState = (state: AnyObject) => ({ - _a: encode_object(state), -}); - -export const mapRisonAppLocationToState = ( - mapState: (risonAppState: AnyObject) => State = (state: AnyObject) => state as State -) => (location: Location): State => { - const queryValues = parse(location.search.substring(1), { sort: false }); - const decodedState = decodeRisonAppState(queryValues); - return mapState(decodedState); -}; - -export const mapStateToRisonAppLocation = ( - mapState: (state: State) => AnyObject = (state: State) => state -) => (state: State, location: Location): Location => { - const previousQueryValues = parse(location.search.substring(1), { sort: false }); - const previousState = decodeRisonAppState(previousQueryValues); - - const encodedState = encodeRisonAppState({ - ...previousState, - ...mapState(state), - }); - const newQueryValues = stringify( - { - ...previousQueryValues, - ...encodedState, - }, - { sort: false } - ); - return { - ...location, - search: `?${newQueryValues}`, - }; -}; diff --git a/x-pack/plugins/infra/public/graphql/log_entries.gql_query.ts b/x-pack/plugins/infra/public/graphql/log_entries.gql_query.ts deleted file mode 100644 index 41ff3c293a713..0000000000000 --- a/x-pack/plugins/infra/public/graphql/log_entries.gql_query.ts +++ /dev/null @@ -1,44 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -import { sharedFragments } from '../../common/graphql/shared'; - -export const logEntriesQuery = gql` - query LogEntries( - $sourceId: ID = "default" - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: $sourceId) { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx index f9cfaf71036f6..d93cc44c45623 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { encode } from 'rison-node'; -import { createMemoryHistory } from 'history'; import { renderHook } from '@testing-library/react-hooks'; +import { createMemoryHistory } from 'history'; import React from 'react'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { HistoryContext } from '../utils/history_context'; +import { Router } from 'react-router-dom'; +import { encode } from 'rison-node'; import { coreMock } from 'src/core/public/mocks'; -import { useLinkProps, LinkDescriptor } from './use_link_props'; import { ScopedHistory } from '../../../../../src/core/public'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { LinkDescriptor, useLinkProps } from './use_link_props'; const PREFIX = '/test-basepath/s/test-space/app/'; @@ -30,9 +30,9 @@ const scopedHistory = new ScopedHistory(history, `${PREFIX}${INTERNAL_APP}`); const ProviderWrapper: React.FC = ({ children }) => { return ( - + {children}; - + ); }; diff --git a/x-pack/plugins/infra/public/index.ts b/x-pack/plugins/infra/public/index.ts index 1dfdf827f203b..8f2d37fa1daa9 100644 --- a/x-pack/plugins/infra/public/index.ts +++ b/x-pack/plugins/infra/public/index.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext, PluginInitializer } from 'kibana/public'; -import { Plugin, ClientSetup, ClientStart, ClientPluginsSetup, ClientPluginsStart } from './plugin'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; +import { ClientSetup, ClientStart, Plugin } from './plugin'; +import { ClientPluginsSetup, ClientPluginsStart } from './types'; export const plugin: PluginInitializer< ClientSetup, diff --git a/x-pack/plugins/infra/public/lib/adapters/observable_api/kibana_observable_api.ts b/x-pack/plugins/infra/public/lib/adapters/observable_api/kibana_observable_api.ts deleted file mode 100644 index 9ae21d96886f3..0000000000000 --- a/x-pack/plugins/infra/public/lib/adapters/observable_api/kibana_observable_api.ts +++ /dev/null @@ -1,45 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ajax } from 'rxjs/ajax'; -import { map } from 'rxjs/operators'; - -import { - InfraObservableApi, - InfraObservableApiPostParams, - InfraObservableApiResponse, -} from '../../lib'; - -export class InfraKibanaObservableApiAdapter implements InfraObservableApi { - private basePath: string; - private defaultHeaders: { - [headerName: string]: boolean | string; - }; - - constructor({ basePath }: { basePath: string }) { - this.basePath = basePath; - this.defaultHeaders = { - 'kbn-xsrf': true, - }; - } - - public post = ({ - url, - body, - }: InfraObservableApiPostParams): InfraObservableApiResponse => - ajax({ - body: body ? JSON.stringify(body) : undefined, - headers: { - ...this.defaultHeaders, - 'Content-Type': 'application/json', - }, - method: 'POST', - responseType: 'json', - timeout: 30000, - url: `${this.basePath}/api/${url}`, - withCredentials: true, - }).pipe(map(({ response, status }) => ({ response, status }))); -} diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index d1ca62b747a24..93f7ef644f795 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -4,102 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IModule, IScope } from 'angular'; -import { NormalizedCacheObject } from 'apollo-cache-inmemory'; -import ApolloClient from 'apollo-client'; -import { AxiosRequestConfig } from 'axios'; -import React from 'react'; -import { Observable } from 'rxjs'; -import * as rt from 'io-ts'; import { i18n } from '@kbn/i18n'; -import { SourceQuery } from '../graphql/types'; +import * as rt from 'io-ts'; import { - SnapshotMetricInput, - SnapshotGroupBy, InfraTimerangeInput, + SnapshotGroupBy, + SnapshotMetricInput, SnapshotNodeMetric, SnapshotNodePath, } from '../../common/http_api/snapshot_api'; +import { SourceQuery } from '../graphql/types'; import { WaffleSortOption } from '../pages/metrics/inventory_view/hooks/use_waffle_options'; -export interface InfraFrontendLibs { - apolloClient: InfraApolloClient; - observableApi: InfraObservableApi; -} - -export type InfraTimezoneProvider = () => string; - -export type InfraApolloClient = ApolloClient; - -export interface InfraFrameworkAdapter { - // Insstance vars - appState?: object; - kbnVersion?: string; - timezone?: string; - - // Methods - setUISettings(key: string, value: any): void; - render(component: React.ReactElement): void; - renderBreadcrumbs(component: React.ReactElement): void; -} - -export type InfraFramworkAdapterConstructable = new ( - uiModule: IModule, - timezoneProvider: InfraTimezoneProvider -) => InfraFrameworkAdapter; - -// TODO: replace AxiosRequestConfig with something more defined -export type InfraRequestConfig = AxiosRequestConfig; - -export interface InfraApiAdapter { - get(url: string, config?: InfraRequestConfig | undefined): Promise; - post(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise; - delete(url: string, config?: InfraRequestConfig | undefined): Promise; - put(url: string, data?: any, config?: InfraRequestConfig | undefined): Promise; -} - -export interface InfraObservableApiPostParams { - url: string; - body?: RequestBody; -} - -export type InfraObservableApiResponse = Observable<{ - status: number; - response: BodyType; -}>; - -export interface InfraObservableApi { - post( - params: InfraObservableApiPostParams - ): InfraObservableApiResponse; -} - -export interface InfraUiKibanaAdapterScope extends IScope { - breadcrumbs: any[]; - topNavMenu: any[]; -} - -export interface InfraKibanaUIConfig { - get(key: string): any; - set(key: string, value: any): Promise; -} - -export interface InfraKibanaAdapterServiceRefs { - config: InfraKibanaUIConfig; - rootScope: IScope; -} - -export type InfraBufferedKibanaServiceCall = (serviceRefs: ServiceRefs) => void; - -export interface InfraField { - name: string; - type: string; - searchable: boolean; - aggregatable: boolean; -} - -export type InfraWaffleData = InfraWaffleMapGroup[]; - export interface InfraWaffleMapNode { pathId: string; id: string; @@ -221,8 +137,6 @@ export interface InfraOptions { wafflemap: InfraWaffleMapOptions; } -export type Omit = Pick>; - export interface InfraWaffleMapBounds { min: number; max: number; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.test.tsx index 1394fc48107ef..e62b29974674a 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.test.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.test.tsx @@ -35,7 +35,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -47,7 +47,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -59,7 +59,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -73,7 +73,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -89,7 +89,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); @@ -103,7 +103,7 @@ describe('RedirectToNodeLogs component', () => { expect(component).toMatchInlineSnapshot(` `); }); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index d9aaa2da7bbc8..10320ebbe7609 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -71,7 +71,7 @@ export const RedirectToNodeLogs = ({ replaceSourceIdInQueryString(sourceId) )(''); - return ; + return ; }; export const getNodeLogsUrl = ({ diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 2974939a83215..14c53557ba2c7 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -96,6 +96,7 @@ export const LogsPageContent: React.FunctionComponent = () => { + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/table_view.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/table_view.tsx index 3b68ad314f7df..764eeb154d346 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/table_view.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/table_view.tsx @@ -15,7 +15,7 @@ import { fieldToName } from '../lib/field_to_display_name'; import { NodeContextMenu } from './waffle/node_context_menu'; import { InventoryItemType } from '../../../../../common/inventory_models/types'; import { SnapshotNode, SnapshotNodePath } from '../../../../../common/http_api/snapshot_api'; -import { CONTAINER_CLASSNAME } from '../../../../apps/start_app'; +import { CONTAINER_CLASSNAME } from '../../../../apps/common_styles'; interface Props { nodes: SnapshotNode[]; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_time.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_time.ts index 91cf405dcc759..9a1fbee421294 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_time.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_time.ts @@ -26,7 +26,9 @@ export const useWaffleTime = () => { const [state, setState] = useState(urlState); - useEffect(() => setUrlState(state), [setUrlState, state]); + useEffect(() => { + setUrlState(state); + }, [setUrlState, state]); const { currentTime, isAutoReloading } = urlState; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/metrics_time.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/metrics_time.test.tsx index 17fcc05406470..d2076ad6df502 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/metrics_time.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/metrics_time.test.tsx @@ -4,14 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; import { mountHook } from 'test_utils/enzyme_helpers'; - +import { ScopedHistory } from '../../../../../../../../src/core/public'; import { useMetricsTime } from './use_metrics_time'; describe('useMetricsTime hook', () => { describe('timeRange state', () => { it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useMetricsTime().timeRange); + const { getLastHookValue } = mountHook( + () => useMetricsTime().timeRange, + createProviderWrapper() + ); const hookValue = getLastHookValue(); expect(hookValue).toHaveProperty('from'); expect(hookValue).toHaveProperty('to'); @@ -19,7 +25,7 @@ describe('useMetricsTime hook', () => { }); it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useMetricsTime()); + const { act, getLastHookValue } = mountHook(() => useMetricsTime(), createProviderWrapper()); const timeRange = { from: 'now-15m', @@ -37,12 +43,15 @@ describe('useMetricsTime hook', () => { describe('AutoReloading state', () => { it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useMetricsTime().isAutoReloading); + const { getLastHookValue } = mountHook( + () => useMetricsTime().isAutoReloading, + createProviderWrapper() + ); expect(getLastHookValue()).toBe(false); }); it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useMetricsTime()); + const { act, getLastHookValue } = mountHook(() => useMetricsTime(), createProviderWrapper()); act(({ setAutoReload }) => { setAutoReload(true); @@ -52,3 +61,17 @@ describe('useMetricsTime hook', () => { }); }); }); + +const createProviderWrapper = () => { + const INITIAL_URL = '/test-basepath/s/test-space/app/metrics'; + const history = createMemoryHistory(); + + history.push(INITIAL_URL); + const scopedHistory = new ScopedHistory(history, INITIAL_URL); + + const ProviderWrapper: React.FC = ({ children }) => { + return {children}; + }; + + return ProviderWrapper; +}; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index deae78e22c6a1..b3765db43335a 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -4,54 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { merge } from 'lodash'; import { - Plugin as PluginClass, + AppMountParameters, CoreSetup, CoreStart, + Plugin as PluginClass, PluginInitializerContext, - AppMountParameters, } from 'kibana/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; +import { createMetricThresholdAlertType } from './alerting/metric_threshold'; +import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type'; +import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; import { registerStartSingleton } from './legacy_singletons'; import { registerFeatures } from './register_feature'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; -import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; - -import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; -import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; -import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type'; -import { createMetricThresholdAlertType } from './alerting/metric_threshold'; +import { ClientPluginsSetup, ClientPluginsStart } from './types'; export type ClientSetup = void; export type ClientStart = void; -export interface ClientPluginsSetup { - home: HomePublicPluginSetup; - data: DataPublicPluginSetup; - usageCollection: UsageCollectionSetup; - dataEnhanced: DataEnhancedSetup; - triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; -} - -export interface ClientPluginsStart { - data: DataPublicPluginStart; - dataEnhanced: DataEnhancedStart; -} - -export type InfraPlugins = ClientPluginsSetup & ClientPluginsStart; - -const getMergedPlugins = (setup: ClientPluginsSetup, start: ClientPluginsStart): InfraPlugins => { - return merge({}, setup, start); -}; - export class Plugin implements PluginClass { - constructor(context: PluginInitializerContext) {} + constructor(_context: PluginInitializerContext) {} - setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { + setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getInventoryMetricAlertType()); @@ -69,16 +44,18 @@ export class Plugin category: DEFAULT_APP_CATEGORIES.observability, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); - const plugins = getMergedPlugins(pluginsSetup, pluginsStart as ClientPluginsStart); - const { startApp, composeLibs, LogsRouter } = await this.downloadAssets(); + const { renderApp } = await import('./apps/logs_app'); - return startApp( - composeLibs(coreStart), + return renderApp( coreStart, - plugins, - params, - LogsRouter, - pluginsSetup.triggers_actions_ui + { + data: pluginsStart.data, + dataEnhanced: pluginsSetup.dataEnhanced, + home: pluginsSetup.home, + triggers_actions_ui: pluginsStart.triggers_actions_ui, + usageCollection: pluginsSetup.usageCollection, + }, + params ); }, }); @@ -94,16 +71,18 @@ export class Plugin category: DEFAULT_APP_CATEGORIES.observability, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); - const plugins = getMergedPlugins(pluginsSetup, pluginsStart as ClientPluginsStart); - const { startApp, composeLibs, MetricsRouter } = await this.downloadAssets(); + const { renderApp } = await import('./apps/metrics_app'); - return startApp( - composeLibs(coreStart), + return renderApp( coreStart, - plugins, - params, - MetricsRouter, - pluginsSetup.triggers_actions_ui + { + data: pluginsStart.data, + dataEnhanced: pluginsSetup.dataEnhanced, + home: pluginsSetup.home, + triggers_actions_ui: pluginsStart.triggers_actions_ui, + usageCollection: pluginsSetup.usageCollection, + }, + params ); }, }); @@ -116,28 +95,14 @@ export class Plugin title: 'infra', navLinkStatus: 3, mount: async (params: AppMountParameters) => { - const { startLegacyApp } = await import('./apps/start_legacy_app'); - return startLegacyApp(params); + const { renderApp } = await import('./apps/legacy_app'); + + return renderApp(params); }, }); } - start(core: CoreStart, plugins: ClientPluginsStart) { + start(core: CoreStart, _plugins: ClientPluginsStart) { registerStartSingleton(core); } - - private async downloadAssets() { - const [{ startApp }, { composeLibs }, { LogsRouter, MetricsRouter }] = await Promise.all([ - import('./apps/start_app'), - import('./compose_libs'), - import('./routers'), - ]); - - return { - startApp, - composeLibs, - LogsRouter, - MetricsRouter, - }; - } } diff --git a/x-pack/plugins/infra/public/routers/logs_router.tsx b/x-pack/plugins/infra/public/routers/logs_router.tsx deleted file mode 100644 index 8258f087b5872..0000000000000 --- a/x-pack/plugins/infra/public/routers/logs_router.tsx +++ /dev/null @@ -1,31 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { Route, Router, Switch } from 'react-router-dom'; - -import { NotFoundPage } from '../pages/404'; -import { LinkToLogsPage } from '../pages/link_to'; -import { LogsPage } from '../pages/logs'; -import { RedirectWithQueryParams } from '../utils/redirect_with_query_params'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { AppRouter } from './index'; - -export const LogsRouter: AppRouter = ({ history }) => { - const uiCapabilities = useKibana().services.application?.capabilities; - return ( - - - - {uiCapabilities?.logs?.show && ( - - )} - {uiCapabilities?.logs?.show && } - - - - ); -}; diff --git a/x-pack/plugins/infra/public/routers/metrics_router.tsx b/x-pack/plugins/infra/public/routers/metrics_router.tsx deleted file mode 100644 index 0e427150a46cc..0000000000000 --- a/x-pack/plugins/infra/public/routers/metrics_router.tsx +++ /dev/null @@ -1,41 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { Route, Router, Switch } from 'react-router-dom'; - -import { NotFoundPage } from '../pages/404'; -import { InfrastructurePage } from '../pages/metrics'; -import { MetricDetail } from '../pages/metrics/metric_detail'; -import { RedirectWithQueryParams } from '../utils/redirect_with_query_params'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { AppRouter } from './index'; -import { LinkToMetricsPage } from '../pages/link_to'; - -export const MetricsRouter: AppRouter = ({ history }) => { - const uiCapabilities = useKibana().services.application?.capabilities; - return ( - - - - {uiCapabilities?.infrastructure?.show && ( - - )} - {uiCapabilities?.infrastructure?.show && ( - - )} - {uiCapabilities?.infrastructure?.show && ( - - )} - {uiCapabilities?.infrastructure?.show && ( - - )} - {uiCapabilities?.infrastructure?.show && } - - - - ); -}; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts new file mode 100644 index 0000000000000..8181da3301c92 --- /dev/null +++ b/x-pack/plugins/infra/public/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import { DataEnhancedSetup } from '../../data_enhanced/public'; + +export interface ClientPluginsSetup { + dataEnhanced: DataEnhancedSetup; + home: HomePublicPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + usageCollection: UsageCollectionSetup; +} + +export interface ClientPluginsStart { + data: DataPublicPluginStart; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +} + +export type ClientPluginDeps = ClientPluginsSetup & ClientPluginsStart; diff --git a/x-pack/plugins/infra/public/utils/apollo_client.ts b/x-pack/plugins/infra/public/utils/apollo_client.ts new file mode 100644 index 0000000000000..3c69ef4c98fac --- /dev/null +++ b/x-pack/plugins/infra/public/utils/apollo_client.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import ApolloClient from 'apollo-client'; +import { ApolloLink } from 'apollo-link'; +import { createHttpLink } from 'apollo-link-http'; +import { withClientState } from 'apollo-link-state'; +import { HttpFetchOptions, HttpHandler } from 'src/core/public'; +import introspectionQueryResultData from '../graphql/introspection.json'; + +export const createApolloClient = (fetch: HttpHandler) => { + const cache = new InMemoryCache({ + addTypename: false, + fragmentMatcher: new IntrospectionFragmentMatcher({ + introspectionQueryResultData, + }), + }); + + const wrappedFetch = (path: string, options: HttpFetchOptions) => { + return new Promise(async (resolve, reject) => { + // core.http.fetch isn't 100% compatible with the Fetch API and will + // throw Errors on 401s. This top level try / catch handles those scenarios. + try { + fetch(path, { + ...options, + // Set headers to undefined due to this bug: https://github.com/apollographql/apollo-link/issues/249, + // Apollo will try to set a "content-type" header which will conflict with the "Content-Type" header that + // core.http.fetch correctly sets. + headers: undefined, + asResponse: true, + }).then((res) => { + if (!res.response) { + return reject(); + } + // core.http.fetch will parse the Response and set a body before handing it back. As such .text() / .json() + // will have already been called on the Response instance. However, Apollo will also want to call + // .text() / .json() on the instance, as it expects the raw Response instance, rather than core's wrapper. + // .text() / .json() can only be called once, and an Error will be thrown if those methods are accessed again. + // This hacks around that by setting up a new .text() method that will restringify the JSON response we already have. + // This does result in an extra stringify / parse cycle, which isn't ideal, but as we only have a few endpoints left using + // GraphQL this shouldn't create excessive overhead. + // Ref: https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-http/src/httpLink.ts#L134 + // and + // https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-http-common/src/index.ts#L125 + return resolve({ + ...res.response, + text: () => { + return new Promise(async (resolveText, rejectText) => { + if (res.body) { + return resolveText(JSON.stringify(res.body)); + } else { + return rejectText(); + } + }); + }, + }); + }); + } catch (error) { + reject(error); + } + }); + }; + + const HttpLink = createHttpLink({ + fetch: wrappedFetch, + uri: `/api/infra/graphql`, + }); + + const graphQLOptions = { + cache, + link: ApolloLink.from([ + withClientState({ + cache, + resolvers: {}, + }), + HttpLink, + ]), + }; + + return new ApolloClient(graphQLOptions); +}; diff --git a/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx b/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx index 1cff3663280fd..6b51714893a6d 100644 --- a/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx +++ b/x-pack/plugins/infra/public/utils/triggers_actions_context.tsx @@ -5,10 +5,10 @@ */ import * as React from 'react'; -import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; interface ContextProps { - triggersActionsUI: TriggersAndActionsUIPublicPluginSetup | null; + triggersActionsUI: TriggersAndActionsUIPublicPluginStart | null; } export const TriggerActionsContext = React.createContext({ @@ -16,7 +16,7 @@ export const TriggerActionsContext = React.createContext({ }); interface Props { - triggersActionsUI: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUI: TriggersAndActionsUIPublicPluginStart; } export const TriggersActionsProvider: React.FC = (props) => { diff --git a/x-pack/plugins/infra/public/utils/use_url_state.ts b/x-pack/plugins/infra/public/utils/use_url_state.ts index 7a63b48fa9a1a..ab0ca1311194f 100644 --- a/x-pack/plugins/infra/public/utils/use_url_state.ts +++ b/x-pack/plugins/infra/public/utils/use_url_state.ts @@ -8,10 +8,9 @@ import { parse, stringify } from 'query-string'; import { Location } from 'history'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { decode, encode, RisonValue } from 'rison-node'; +import { useHistory } from 'react-router-dom'; import { url } from '../../../../../src/plugins/kibana_utils/public'; -import { useHistory } from './history_context'; - export const useUrlState = ({ defaultState, decodeUrlState, diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json new file mode 100644 index 0000000000000..f0ed3ed9a0364 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -0,0 +1,4397 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Ingest Manager", + "version": "0.2", + "contact": { + "name": "Ingest Team" + }, + "license": { + "name": "Elastic" + } + }, + "servers": [ + { + "url": "http://localhost:5601/api/ingest_manager", + "description": "local" + } + ], + "paths": { + "/agent_configs": { + "get": { + "summary": "Agent Config - List", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "total": { + "type": "number" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "items", + "total", + "page", + "perPage", + "success" + ] + }, + "examples": { + "success": { + "value": { + "items": [ + { + "id": "82da1fc0-8fbf-11ea-b2ce-01c4a6127154", + "name": "Default config", + "namespace": "default", + "description": "Default agent configuration created by Kibana", + "status": "active", + "datasources": [ + "8a5679b0-8fbf-11ea-b2ce-01c4a6127154" + ], + "is_default": true, + "monitoring_enabled": [ + "logs", + "metrics" + ], + "revision": 2, + "updated_on": "2020-05-06T17:32:21.905Z", + "updated_by": "system", + "agents": 0 + } + ], + "total": 1, + "page": 1, + "perPage": 50, + "success": true + } + } + } + } + } + } + }, + "operationId": "agent-config-list", + "parameters": [ + { + "$ref": "#/components/parameters/pageSizeParam" + }, + { + "$ref": "#/components/parameters/pageIndexParam" + }, + { + "$ref": "#/components/parameters/kueryParam" + } + ], + "description": "" + }, + "post": { + "summary": "Agent Config - Create", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/AgentConfig" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "operationId": "post-agent_configs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewAgentConfig" + } + } + } + }, + "security": [], + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/agent_configs/{agentConfigId}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentConfigId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Agent Config - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/AgentConfig" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "item", + "success" + ] + }, + "examples": { + "success": { + "value": { + "item": { + "id": "82da1fc0-8fbf-11ea-b2ce-01c4a6127154", + "name": "Default config", + "namespace": "default", + "description": "Default agent configuration created by Kibana", + "status": "active", + "datasources": [ + { + "id": "8a5679b0-8fbf-11ea-b2ce-01c4a6127154", + "name": "system-1", + "namespace": "default", + "package": { + "name": "system", + "title": "System", + "version": "0.0.3" + }, + "enabled": true, + "config_id": "82da1fc0-8fbf-11ea-b2ce-01c4a6127154", + "output_id": "08adc51c-69f3-4294-80e2-24527c6ff73d", + "inputs": [ + { + "type": "logs", + "enabled": true, + "streams": [ + { + "id": "logs-system.auth", + "enabled": true, + "dataset": "system.auth", + "vars": { + "paths": { + "value": [ + "/var/log/auth.log*", + "/var/log/secure*" + ], + "type": "text" + } + }, + "agent_stream": { + "paths": [ + "/var/log/auth.log*", + "/var/log/secure*" + ], + "exclude_files": [ + ".gz$" + ], + "multiline": { + "pattern": "^\\s", + "match": "after" + }, + "processors": [ + { + "add_locale": null + }, + { + "add_fields": { + "target": "", + "fields": { + "ecs.version": "1.5.0" + } + } + } + ] + } + }, + { + "id": "logs-system.syslog", + "enabled": true, + "dataset": "system.syslog", + "vars": { + "paths": { + "value": [ + "/var/log/messages*", + "/var/log/syslog*" + ], + "type": "text" + } + }, + "agent_stream": { + "paths": [ + "/var/log/messages*", + "/var/log/syslog*" + ], + "exclude_files": [ + ".gz$" + ], + "multiline": { + "pattern": "^\\s", + "match": "after" + }, + "processors": [ + { + "add_locale": null + }, + { + "add_fields": { + "target": "", + "fields": { + "ecs.version": "1.5.0" + } + } + } + ] + } + } + ] + }, + { + "type": "system/metrics", + "enabled": true, + "streams": [ + { + "id": "system/metrics-system.core", + "enabled": true, + "dataset": "system.core", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "core" + ], + "core.metrics": "percentages" + } + }, + { + "id": "system/metrics-system.cpu", + "enabled": true, + "dataset": "system.cpu", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "cpu" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.diskio", + "enabled": true, + "dataset": "system.diskio", + "agent_stream": { + "metricsets": [ + "diskio" + ] + } + }, + { + "id": "system/metrics-system.entropy", + "enabled": true, + "dataset": "system.entropy", + "agent_stream": { + "metricsets": [ + "entropy" + ] + } + }, + { + "id": "system/metrics-system.filesystem", + "enabled": true, + "dataset": "system.filesystem", + "vars": { + "period": { + "value": "1m", + "type": "text" + }, + "processors": { + "value": "- drop_event.when.regexp:\n system.filesystem.mount_point: ^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)\n", + "type": "yaml" + } + }, + "agent_stream": { + "metricsets": [ + "filesystem" + ], + "period": "1m", + "processors": [ + { + "drop_event.when.regexp": { + "system.filesystem.mount_point": "^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)" + } + } + ] + } + }, + { + "id": "system/metrics-system.fsstat", + "enabled": true, + "dataset": "system.fsstat", + "vars": { + "period": { + "value": "1m", + "type": "text" + }, + "processors": { + "value": "- drop_event.when.regexp:\n system.filesystem.mount_point: ^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)\n", + "type": "yaml" + } + }, + "agent_stream": { + "metricsets": [ + "fsstat" + ], + "period": "1m", + "processors": [ + { + "drop_event.when.regexp": { + "system.filesystem.mount_point": "^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)" + } + } + ] + } + }, + { + "id": "system/metrics-system.load", + "enabled": true, + "dataset": "system.load", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "load" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.memory", + "enabled": true, + "dataset": "system.memory", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "memory" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.network", + "enabled": true, + "dataset": "system.network", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "network" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.network_summary", + "enabled": true, + "dataset": "system.network_summary", + "agent_stream": { + "metricsets": [ + "network_summary" + ] + } + }, + { + "id": "system/metrics-system.process", + "enabled": true, + "dataset": "system.process", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "process" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.process_summary", + "enabled": true, + "dataset": "system.process_summary", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "process_summary" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.raid", + "enabled": true, + "dataset": "system.raid", + "agent_stream": { + "metricsets": [ + "raid" + ] + } + }, + { + "id": "system/metrics-system.service", + "enabled": true, + "dataset": "system.service", + "agent_stream": { + "metricsets": [ + "service" + ] + } + }, + { + "id": "system/metrics-system.socket", + "enabled": true, + "dataset": "system.socket", + "agent_stream": { + "metricsets": [ + "socket" + ] + } + }, + { + "id": "system/metrics-system.socket_summary", + "enabled": true, + "dataset": "system.socket_summary", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "process.include_top_n.by_cpu": { + "value": 5, + "type": "integer" + }, + "process.include_top_n.by_memory": { + "value": 5, + "type": "integer" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "socket_summary" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + } + }, + { + "id": "system/metrics-system.uptime", + "enabled": true, + "dataset": "system.uptime", + "vars": { + "core.metrics": { + "value": [ + "percentages" + ], + "type": "text" + }, + "cpu.metrics": { + "value": [ + "percentages", + "normalized_percentages" + ], + "type": "text" + }, + "period": { + "value": "10s", + "type": "text" + }, + "processes": { + "value": [ + ".*" + ], + "type": "text" + } + }, + "agent_stream": { + "metricsets": [ + "uptime" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "processes": ".*" + } + }, + { + "id": "system/metrics-system.users", + "enabled": true, + "dataset": "system.users", + "agent_stream": { + "metricsets": [ + "users" + ] + } + } + ] + } + ], + "revision": 1 + } + ], + "is_default": true, + "monitoring_enabled": [ + "logs", + "metrics" + ], + "revision": 2, + "updated_on": "2020-05-06T17:32:21.905Z", + "updated_by": "system" + }, + "success": true + } + } + } + } + } + } + }, + "operationId": "agent-config-info", + "description": "Get one agent config", + "parameters": [] + }, + "put": { + "summary": "Agent Config - Update", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/AgentConfig" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "item", + "success" + ] + }, + "examples": { + "example-1": { + "value": { + "item": { + "id": "0b7130d0-5a37-11ea-ac2c-25e9ab4ecb2a", + "name": "UPDATED name", + "description": "UPDATED description", + "namespace": "UPDATED namespace", + "updated_on": "Fri Feb 28 2020 16:22:31 GMT-0500 (Eastern Standard Time)", + "updated_by": "elastic", + "datasources": [] + }, + "success": true + } + } + } + } + } + } + }, + "operationId": "put-agent_configs-agentConfigId", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewAgentConfig" + }, + "examples": { + "example-1": { + "value": { + "name": "UPDATED name", + "description": "UPDATED description", + "namespace": "UPDATED namespace" + } + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/agent_configs/delete": { + "post": { + "summary": "Agent Config - Delete", + "operationId": "post-agent_config-delete", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "id", + "success" + ] + } + }, + "examples": { + "success": { + "value": [ + { + "id": "df7d2540-5a47-11ea-80da-89b5a66da347", + "success": true + } + ] + }, + "fail": { + "value": [ + { + "id": "df7d2540-5a47-11ea-80da-89b5a66da347", + "success": false + } + ] + } + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agentConfigIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "examples": { + "example-1": { + "value": { + "agentConfigIds": [ + "df7d2540-5a47-11ea-80da-89b5a66da347" + ] + } + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + }, + "parameters": [] + }, + "/datasources": { + "get": { + "summary": "Datasources - List", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Datasource" + } + }, + "total": { + "type": "number" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "items", + "success" + ] + }, + "examples": { + "example-1": { + "value": { + "items": [ + { + "id": "5d273cf0-5a44-11ea-80da-89b5a66da347", + "use_output": "default", + "inputs": [ + { + "type": "docker/metrics", + "streams": [ + { + "metricset": "status", + "dataset": "docker.status" + } + ] + }, + { + "type": "logs", + "streams": [ + { + "paths": [ + "/var/log/hello1.log", + "/var/log/hello2.log" + ] + } + ] + } + ] + }, + { + "id": "66490980-5a44-11ea-80da-89b5a66da347", + "namespace": "testing", + "use_output": "default", + "inputs": [ + { + "type": "apache/metrics", + "streams": [ + { + "enabled": true, + "metricset": "info" + } + ] + } + ] + }, + { + "id": "df1ccae0-5a49-11ea-94a6-81affd263f47", + "enabled": true, + "title": "This is a nice title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "type": "logs", + "streams": [ + { + "enabled": true, + "dataset": "nginx.acccess", + "paths": [ + "/var/log/nginx/access.log" + ] + }, + { + "enabled": true, + "dataset": "nginx.error", + "paths": [ + "/var/log/nginx/error.log" + ] + } + ] + }, + { + "type": "nginx/metrics", + "streams": [ + { + "id": "id string", + "enabled": true, + "dataset": "nginx.stub_status", + "metricset": "stub_status" + } + ] + } + ] + }, + { + "id": "f96a09d0-5a49-11ea-94a6-81affd263f47", + "enabled": true, + "title": "This is a nice title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "type": "logs", + "streams": [ + { + "enabled": true, + "dataset": "nginx.acccess", + "paths": [ + "/var/log/nginx/access.log" + ] + }, + { + "enabled": true, + "dataset": "nginx.error", + "paths": [ + "/var/log/nginx/error.log" + ] + } + ] + }, + { + "type": "nginx/metrics", + "streams": [ + { + "id": "id string", + "enabled": true, + "dataset": "nginx.stub_status", + "metricset": "stub_status" + } + ] + } + ] + }, + { + "id": "9ca403a0-5a66-11ea-9468-c911a41ab4f5", + "enabled": true, + "title": "This is a nice title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "type": "logs", + "streams": [ + { + "enabled": true, + "dataset": "nginx.acccess", + "paths": [ + "/var/log/nginx/access.log" + ] + }, + { + "enabled": true, + "dataset": "nginx.error", + "paths": [ + "/var/log/nginx/error.log" + ] + } + ] + }, + { + "type": "nginx/metrics", + "streams": [ + { + "id": "id string", + "enabled": true, + "dataset": "nginx.stub_status", + "metricset": "stub_status" + } + ] + } + ] + }, + { + "id": "27925980-5a44-11ea-80da-89b5a66da347", + "enabled": true, + "title": "UPDATED title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "streams": [ + { + "paths": [ + "/var/log/nginx/access.log" + ], + "dataset": "nginx.acccess", + "enabled": true + }, + { + "paths": [ + "/var/log/nginx/error.log" + ], + "dataset": "nginx.error", + "enabled": true + } + ], + "type": "logs" + }, + { + "streams": [ + { + "metricset": "stub_status", + "id": "id string", + "dataset": "nginx.stub_status", + "enabled": true + } + ], + "type": "nginx/metrics" + } + ] + } + ], + "total": 6, + "page": 1, + "perPage": 20, + "success": true + } + } + } + } + } + } + }, + "operationId": "get-datasources", + "security": [], + "parameters": [] + }, + "parameters": [], + "post": { + "summary": "Datasources - Create", + "operationId": "post-datasources", + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewDatasource" + }, + "examples": { + "example-1": { + "value": { + "enabled": true, + "title": "This is a nice title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "type": "logs", + "streams": [ + { + "enabled": true, + "dataset": "nginx.acccess", + "paths": [ + "/var/log/nginx/access.log" + ] + }, + { + "enabled": true, + "dataset": "nginx.error", + "paths": [ + "/var/log/nginx/error.log" + ] + } + ] + }, + { + "type": "nginx/metrics", + "streams": [ + { + "id": "id string", + "enabled": true, + "dataset": "nginx.stub_status", + "metricset": "stub_status" + } + ] + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/datasources/{datasourceId}": { + "get": { + "summary": "Datasources - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/Datasource" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "item", + "success" + ] + } + } + } + } + }, + "operationId": "get-datasources-datasourceId" + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "datasourceId", + "in": "path", + "required": true + } + ], + "put": { + "summary": "Datasources - Update", + "operationId": "put-datasources-datasourceId", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/Datasource" + }, + "sucess": { + "type": "boolean" + } + }, + "required": [ + "item", + "sucess" + ] + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/fleet/setup": { + "get": { + "summary": "Fleet setup - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isInitialized": { + "type": "boolean" + } + }, + "required": [ + "isInitialized" + ] + }, + "examples": { + "success": { + "value": { + "isInitialized": true + } + }, + "failure": { + "value": { + "isInitialized": false + } + } + } + } + } + } + }, + "operationId": "get-fleet-setup", + "security": [ + { + "basicAuth": [] + } + ] + }, + "post": { + "summary": "Fleet setup - Create", + "operationId": "post-fleet-setup", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isInitialized": { + "type": "boolean" + } + }, + "required": [ + "isInitialized" + ] + }, + "examples": { + "success": { + "value": { + "isInitialized": true + } + } + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "admin_username": { + "type": "string" + }, + "admin_password": { + "type": "string" + } + }, + "required": [ + "admin_username", + "admin_password" + ] + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/epm/packages/{pkgkey}": { + "get": { + "summary": "EPM - Packages - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "allOf": [ + { + "properties": { + "response": { + "$ref": "#/components/schemas/PackageInfo" + }, + "success": { + "type": "boolean" + } + } + }, + { + "properties": { + "status": { + "type": "string", + "enum": [ + "installed", + "not_installed" + ] + }, + "savedObject": { + "type": "string" + } + }, + "required": [ + "status", + "savedObject" + ] + } + ] + }, + "examples": { + "example-1": { + "value": { + "response": { + "name": "coredns", + "title": "CoreDNS", + "version": "1.0.1", + "readme": "/package/coredns-1.0.1/docs/README.md", + "description": "CoreDNS logs and metrics integration.\nThe CoreDNS integrations allows to gather logs and metrics from the CoreDNS DNS server to get better insights.\n", + "type": "integration", + "categories": [ + "logs", + "metrics" + ], + "requirement": { + "kibana": { + "versions": ">6.7.0" + } + }, + "icons": [ + { + "src": "/package/coredns-1.0.1/img/icon.png", + "size": "1800x1800" + }, + { + "src": "/package/coredns-1.0.1/img/icon.svg", + "size": "255x144", + "type": "image/svg+xml" + } + ], + "assets": { + "kibana": { + "dashboard": [ + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "dashboard", + "file": "53aa1f70-443e-11e9-8548-ab7fbe04f038.json", + "path": "coredns-1.0.1/kibana/dashboard/53aa1f70-443e-11e9-8548-ab7fbe04f038.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "dashboard", + "file": "Metricbeat-CoreDNS-Dashboard-ecs.json", + "path": "coredns-1.0.1/kibana/dashboard/Metricbeat-CoreDNS-Dashboard-ecs.json" + } + ], + "visualization": [ + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "277fc650-67a9-11e9-a534-715561d0bf42.json", + "path": "coredns-1.0.1/kibana/visualization/277fc650-67a9-11e9-a534-715561d0bf42.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "27da53f0-53d5-11e9-b466-9be470bbd327-ecs.json", + "path": "coredns-1.0.1/kibana/visualization/27da53f0-53d5-11e9-b466-9be470bbd327-ecs.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "36e08510-53c4-11e9-b466-9be470bbd327-ecs.json", + "path": "coredns-1.0.1/kibana/visualization/36e08510-53c4-11e9-b466-9be470bbd327-ecs.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "3ad75810-4429-11e9-8548-ab7fbe04f038.json", + "path": "coredns-1.0.1/kibana/visualization/3ad75810-4429-11e9-8548-ab7fbe04f038.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "4804eaa0-7315-11e9-b0d0-414c3011ddbb.json", + "path": "coredns-1.0.1/kibana/visualization/4804eaa0-7315-11e9-b0d0-414c3011ddbb.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "57c74300-7308-11e9-b0d0-414c3011ddbb.json", + "path": "coredns-1.0.1/kibana/visualization/57c74300-7308-11e9-b0d0-414c3011ddbb.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "75743f70-443c-11e9-8548-ab7fbe04f038.json", + "path": "coredns-1.0.1/kibana/visualization/75743f70-443c-11e9-8548-ab7fbe04f038.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "86177430-728d-11e9-b0d0-414c3011ddbb.json", + "path": "coredns-1.0.1/kibana/visualization/86177430-728d-11e9-b0d0-414c3011ddbb.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "9dc640e0-4432-11e9-8548-ab7fbe04f038.json", + "path": "coredns-1.0.1/kibana/visualization/9dc640e0-4432-11e9-8548-ab7fbe04f038.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "a19df590-53c4-11e9-b466-9be470bbd327-ecs.json", + "path": "coredns-1.0.1/kibana/visualization/a19df590-53c4-11e9-b466-9be470bbd327-ecs.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "a58345f0-7298-11e9-b0d0-414c3011ddbb.json", + "path": "coredns-1.0.1/kibana/visualization/a58345f0-7298-11e9-b0d0-414c3011ddbb.json" + }, + { + "pkgkey": "coredns-1.0.1", + "service": "kibana", + "type": "visualization", + "file": "cfde7fb0-443d-11e9-8548-ab7fbe04f038.json", + "path": "coredns-1.0.1/kibana/visualization/cfde7fb0-443d-11e9-8548-ab7fbe04f038.json" + } + ] + } + }, + "format_version": "1.0.0", + "datasets": [ + { + "title": "CoreDNS logs", + "name": "log", + "release": "ga", + "type": "logs", + "ingest_pipeline": "pipeline-entry", + "vars": [ + { + "default": [ + "/var/log/coredns.log" + ], + "name": "paths", + "type": "textarea" + }, + { + "default": [ + "coredns" + ], + "name": "tags", + "type": "text" + } + ], + "package": "coredns" + }, + { + "title": "CoreDNS stats metrics", + "name": "stats", + "release": "ga", + "type": "metrics", + "vars": [ + { + "default": [ + "http://localhost:9153" + ], + "description": "CoreDNS hosts", + "name": "hosts", + "required": true + }, + { + "default": "10s", + "description": "Collection period. Valid values: 10s, 5m, 2h", + "name": "period" + }, + { + "name": "username", + "type": "text" + }, + { + "name": "password", + "type": "password" + } + ], + "package": "coredns" + } + ], + "download": "/epr/coredns/coredns-1.0.1.tar.gz", + "path": "/package/coredns-1.0.1", + "status": "installed", + "savedObject": { + "id": "coredns-1.0.1", + "type": "epm-package", + "updated_at": "2020-02-27T16:25:43.652Z", + "version": "WzU2LDFd", + "attributes": { + "installed": [ + { + "id": "53aa1f70-443e-11e9-8548-ab7fbe04f038", + "type": "dashboard" + }, + { + "id": "Metricbeat-CoreDNS-Dashboard-ecs", + "type": "dashboard" + }, + { + "id": "75743f70-443c-11e9-8548-ab7fbe04f038", + "type": "visualization" + }, + { + "id": "36e08510-53c4-11e9-b466-9be470bbd327-ecs", + "type": "visualization" + }, + { + "id": "277fc650-67a9-11e9-a534-715561d0bf42", + "type": "visualization" + }, + { + "id": "cfde7fb0-443d-11e9-8548-ab7fbe04f038", + "type": "visualization" + }, + { + "id": "a19df590-53c4-11e9-b466-9be470bbd327-ecs", + "type": "visualization" + }, + { + "id": "a58345f0-7298-11e9-b0d0-414c3011ddbb", + "type": "visualization" + }, + { + "id": "9dc640e0-4432-11e9-8548-ab7fbe04f038", + "type": "visualization" + }, + { + "id": "3ad75810-4429-11e9-8548-ab7fbe04f038", + "type": "visualization" + }, + { + "id": "57c74300-7308-11e9-b0d0-414c3011ddbb", + "type": "visualization" + }, + { + "id": "27da53f0-53d5-11e9-b466-9be470bbd327-ecs", + "type": "visualization" + }, + { + "id": "86177430-728d-11e9-b0d0-414c3011ddbb", + "type": "visualization" + }, + { + "id": "4804eaa0-7315-11e9-b0d0-414c3011ddbb", + "type": "visualization" + }, + { + "id": "logs-log-1.0.1-pipeline-plaintext", + "type": "ingest-pipeline" + }, + { + "id": "logs-log-1.0.1-pipeline-json", + "type": "ingest-pipeline" + }, + { + "id": "logs-log-1.0.1", + "type": "ingest-pipeline" + }, + { + "id": "logs-log", + "type": "index-template" + }, + { + "id": "metrics-stats", + "type": "index-template" + } + ] + }, + "references": [] + } + }, + "success": true + } + } + } + } + } + } + }, + "operationId": "get-epm-package-pkgkey", + "security": [ + { + "basicAuth": [] + } + ] + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "pkgkey", + "in": "path", + "required": true + } + ], + "post": { + "summary": "EPM - Packages - Install", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "response", + "success" + ] + } + } + } + } + }, + "operationId": "post-epm-install-pkgkey", + "description": "", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + }, + "delete": { + "summary": "EPM - Packages - Delete", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "response", + "success" + ] + } + } + } + } + }, + "operationId": "post-epm-delete-pkgkey", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/epm/packages": { + "get": { + "summary": "EPM - Packages - List", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "examples": { + "success": { + "value": { + "response": [ + { + "description": "aws Integration", + "download": "/epr/aws/aws-0.0.3.tar.gz", + "icons": [ + { + "src": "/package/aws/0.0.3/img/logo_aws.svg", + "title": "logo aws", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "name": "aws", + "path": "/package/aws/0.0.3", + "title": "aws", + "type": "integration", + "version": "0.0.3", + "status": "not_installed" + }, + { + "description": "This is the Elastic Endpoint package.", + "download": "/epr/endpoint/endpoint-0.1.0.tar.gz", + "icons": [ + { + "src": "/package/endpoint/0.1.0/img/logo-endpoint-64-color.svg", + "size": "16x16", + "type": "image/svg+xml" + } + ], + "name": "endpoint", + "path": "/package/endpoint/0.1.0", + "title": "Elastic Endpoint", + "type": "solution", + "version": "0.1.0", + "status": "installed", + "savedObject": { + "type": "epm-packages", + "id": "endpoint", + "attributes": { + "installed": [ + { + "id": "826759f0-7074-11ea-9bc8-6b38f4d29a16", + "type": "dashboard" + }, + { + "id": "55387750-729c-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "92b1edc0-706a-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "1cfceda0-728b-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "1e525190-7074-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "a3a3bd10-706b-11ea-9bc8-6b38f4d29a16", + "type": "map" + }, + { + "id": "events-endpoint", + "type": "index-template" + }, + { + "id": "metrics-endpoint", + "type": "index-template" + } + ], + "es_index_patterns": { + "events": "events-endpoint-*", + "metadata": "metrics-endpoint-*" + }, + "name": "endpoint", + "version": "0.1.0", + "internal": false, + "removable": false + }, + "references": [], + "updated_at": "2020-05-15T20:08:11.739Z", + "version": "WzEwOCwxXQ==" + } + }, + { + "description": "The log package should be used to create data sources for all type of logs for which an package doesn't exist yet.\n", + "download": "/epr/log/log-0.9.0.tar.gz", + "icons": [ + { + "src": "/package/log/0.9.0/img/icon.svg", + "type": "image/svg+xml" + } + ], + "name": "log", + "path": "/package/log/0.9.0", + "title": "Log Package", + "type": "integration", + "version": "0.9.0", + "status": "not_installed" + }, + { + "description": "This integration contains pretty long documentation.\nIt is used to show the different visualisations inside a documentation to test how we handle it.\nThe integration does not contain any assets except the documentation page.\n", + "download": "/epr/longdocs/longdocs-1.0.4.tar.gz", + "icons": [ + { + "src": "/package/longdocs/1.0.4/img/icon.svg", + "type": "image/svg+xml" + } + ], + "name": "longdocs", + "path": "/package/longdocs/1.0.4", + "title": "Long Docs", + "type": "integration", + "version": "1.0.4", + "status": "not_installed" + }, + { + "description": "This is an integration with only the metrics category.\n", + "download": "/epr/metricsonly/metricsonly-2.0.1.tar.gz", + "icons": [ + { + "src": "/package/metricsonly/2.0.1/img/icon.svg", + "type": "image/svg+xml" + } + ], + "name": "metricsonly", + "path": "/package/metricsonly/2.0.1", + "title": "Metrics Only", + "type": "integration", + "version": "2.0.1", + "status": "not_installed" + }, + { + "description": "Multiple versions of this integration exist.\n", + "download": "/epr/multiversion/multiversion-1.1.0.tar.gz", + "icons": [ + { + "src": "/package/multiversion/1.1.0/img/icon.svg", + "type": "image/svg+xml" + } + ], + "name": "multiversion", + "path": "/package/multiversion/1.1.0", + "title": "Multi Version", + "type": "integration", + "version": "1.1.0", + "status": "not_installed" + }, + { + "description": "MySQL Integration", + "download": "/epr/mysql/mysql-0.1.0.tar.gz", + "icons": [ + { + "src": "/package/mysql/0.1.0/img/logo_mysql.svg", + "title": "logo mysql", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "name": "mysql", + "path": "/package/mysql/0.1.0", + "title": "MySQL", + "type": "integration", + "version": "0.1.0", + "status": "not_installed" + }, + { + "description": "Nginx Integration", + "download": "/epr/nginx/nginx-0.1.0.tar.gz", + "icons": [ + { + "src": "/package/nginx/0.1.0/img/logo_nginx.svg", + "title": "logo nginx", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "name": "nginx", + "path": "/package/nginx/0.1.0", + "title": "Nginx", + "type": "integration", + "version": "0.1.0", + "status": "not_installed" + }, + { + "description": "Redis Integration", + "download": "/epr/redis/redis-0.1.0.tar.gz", + "icons": [ + { + "src": "/package/redis/0.1.0/img/logo_redis.svg", + "title": "logo redis", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "name": "redis", + "path": "/package/redis/0.1.0", + "title": "Redis", + "type": "integration", + "version": "0.1.0", + "status": "not_installed" + }, + { + "description": "This package is used for defining all the properties of a package, the possible assets etc. It serves as a reference on all the config options which are possible.\n", + "download": "/epr/reference/reference-1.0.0.tar.gz", + "icons": [ + { + "src": "/package/reference/1.0.0/img/icon.svg", + "size": "32x32", + "type": "image/svg+xml" + } + ], + "name": "reference", + "path": "/package/reference/1.0.0", + "title": "Reference package", + "type": "integration", + "version": "1.0.0", + "status": "not_installed" + }, + { + "description": "System Integration", + "download": "/epr/system/system-0.1.0.tar.gz", + "icons": [ + { + "src": "/package/system/0.1.0/img/system.svg", + "title": "system", + "size": "1000x1000", + "type": "image/svg+xml" + } + ], + "name": "system", + "path": "/package/system/0.1.0", + "title": "System", + "type": "integration", + "version": "0.1.0", + "status": "installed", + "savedObject": { + "type": "epm-packages", + "id": "system", + "attributes": { + "installed": [ + { + "id": "c431f410-f9ac-11e9-90e8-1fb18e796788", + "type": "dashboard" + }, + { + "id": "Metricbeat-system-overview-ecs", + "type": "dashboard" + }, + { + "id": "277876d0-fa2c-11e6-bbd3-29c986c96e5a-ecs", + "type": "dashboard" + }, + { + "id": "0d3f2380-fa78-11e6-ae9b-81e5311e8cab-ecs", + "type": "dashboard" + }, + { + "id": "CPU-slash-Memory-per-container-ecs", + "type": "dashboard" + }, + { + "id": "79ffd6e0-faa0-11e6-947f-177f697178b8-ecs", + "type": "dashboard" + }, + { + "id": "Filebeat-syslog-dashboard-ecs", + "type": "dashboard" + }, + { + "id": "5517a150-f9ce-11e6-8115-a7c18106d86a-ecs", + "type": "dashboard" + }, + { + "id": "9c69cad0-f9b0-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "855899e0-1b1c-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "a30871f0-f98f-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "e121b140-fa78-11e6-a1df-a78bd7504d38-ecs", + "type": "visualization" + }, + { + "id": "f398d2f0-fa77-11e6-ae9b-81e5311e8cab-ecs", + "type": "visualization" + }, + { + "id": "c5e3cf90-4d60-11e7-9a4c-ed99bbcaa42b-ecs", + "type": "visualization" + }, + { + "id": "d3166e80-1b91-11e7-bec4-a5e9ec5cab8b-ecs", + "type": "visualization" + }, + { + "id": "346bb290-fa80-11e6-a1df-a78bd7504d38-ecs", + "type": "visualization" + }, + { + "id": "Container-Block-IO-ecs", + "type": "visualization" + }, + { + "id": "590a60f0-5d87-11e7-8884-1bb4c3b890e4-ecs", + "type": "visualization" + }, + { + "id": "341ffe70-f9ce-11e6-8115-a7c18106d86a-ecs", + "type": "visualization" + }, + { + "id": "System-Navigation-ecs", + "type": "visualization" + }, + { + "id": "089b85d0-1b16-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "99381c80-4d60-11e7-9a4c-ed99bbcaa42b-ecs", + "type": "visualization" + }, + { + "id": "c6f2ffd0-4d17-11e7-a196-69b9a7a020a9-ecs", + "type": "visualization" + }, + { + "id": "d56ee420-fa79-11e6-a1df-a78bd7504d38-ecs", + "type": "visualization" + }, + { + "id": "1aae9140-1b93-11e7-8ada-3df93aab833e-ecs", + "type": "visualization" + }, + { + "id": "e0f001c0-1b18-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "dc589770-fa2b-11e6-bbd3-29c986c96e5a-ecs", + "type": "visualization" + }, + { + "id": "96976150-4d5d-11e7-aa29-87a97a796de6-ecs", + "type": "visualization" + }, + { + "id": "8c071e20-f999-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "d3f51850-f9b6-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "5c7af030-fa2a-11e6-bbd3-29c986c96e5a-ecs", + "type": "visualization" + }, + { + "id": "e6e639e0-f992-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "bfa5e400-1b16-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "7cdb1330-4d1a-11e7-a196-69b9a7a020a9-ecs", + "type": "visualization" + }, + { + "id": "78b74f30-f9cd-11e6-8115-a7c18106d86a-ecs", + "type": "visualization" + }, + { + "id": "Syslog-events-by-hostname-ecs", + "type": "visualization" + }, + { + "id": "3d65d450-a9c3-11e7-af20-67db8aecb295-ecs", + "type": "visualization" + }, + { + "id": "ab2d1e90-1b1a-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "825fdb80-4d1d-11e7-b5f2-2b7c1895bf32-ecs", + "type": "visualization" + }, + { + "id": "26732e20-1b91-11e7-bec4-a5e9ec5cab8b-ecs", + "type": "visualization" + }, + { + "id": "Syslog-hostnames-and-processes-ecs", + "type": "visualization" + }, + { + "id": "522ee670-1b92-11e7-bec4-a5e9ec5cab8b-ecs", + "type": "visualization" + }, + { + "id": "51164310-fa2b-11e6-bbd3-29c986c96e5a-ecs", + "type": "visualization" + }, + { + "id": "bb3a8720-f991-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "Container-Memory-stats-ecs", + "type": "visualization" + }, + { + "id": "5dd15c00-fa78-11e6-ae9b-81e5311e8cab-ecs", + "type": "visualization" + }, + { + "id": "327417e0-8462-11e7-bab8-bd2f0fb42c54-ecs", + "type": "visualization" + }, + { + "id": "d2e80340-4d5c-11e7-aa29-87a97a796de6-ecs", + "type": "visualization" + }, + { + "id": "19e123b0-4d5a-11e7-aee5-fdc812cc3bec-ecs", + "type": "visualization" + }, + { + "id": "3cec3eb0-f9d3-11e6-8a3e-2b904044ea1d-ecs", + "type": "visualization" + }, + { + "id": "2e224660-1b19-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "12667040-fa80-11e6-a1df-a78bd7504d38-ecs", + "type": "visualization" + }, + { + "id": "d16bb400-f9cc-11e6-8115-a7c18106d86a-ecs", + "type": "visualization" + }, + { + "id": "34f97ee0-1b96-11e7-8ada-3df93aab833e-ecs", + "type": "visualization" + }, + { + "id": "fe064790-1b1f-11e7-bec4-a5e9ec5cab8b-ecs", + "type": "visualization" + }, + { + "id": "83e12df0-1b91-11e7-bec4-a5e9ec5cab8b-ecs", + "type": "visualization" + }, + { + "id": "4e4bb1e0-1b1b-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "4b254630-f998-11e9-90e8-1fb18e796788", + "type": "visualization" + }, + { + "id": "6b7b9a40-faa1-11e6-86b1-cd7735ff7e23-ecs", + "type": "visualization" + }, + { + "id": "4d546850-1b15-11e7-b09e-037021c4f8df-ecs", + "type": "visualization" + }, + { + "id": "Container-CPU-usage-ecs", + "type": "visualization" + }, + { + "id": "b6f321e0-fa25-11e6-bbd3-29c986c96e5a-ecs", + "type": "search" + }, + { + "id": "62439dc0-f9c9-11e6-a747-6121780e0414-ecs", + "type": "search" + }, + { + "id": "8030c1b0-fa77-11e6-ae9b-81e5311e8cab-ecs", + "type": "search" + }, + { + "id": "Syslog-system-logs-ecs", + "type": "search" + }, + { + "id": "eb0039f0-fa7f-11e6-a1df-a78bd7504d38-ecs", + "type": "search" + }, + { + "id": "logs-system.auth-0.1.0", + "type": "ingest-pipeline" + }, + { + "id": "logs-system.auth-0.1.0", + "type": "ingest-pipeline" + }, + { + "id": "logs-system.syslog-0.1.0", + "type": "ingest-pipeline" + }, + { + "id": "logs-system.syslog-0.1.0", + "type": "ingest-pipeline" + }, + { + "id": "logs-system.auth", + "type": "index-template" + }, + { + "id": "metrics-system.core", + "type": "index-template" + }, + { + "id": "metrics-system.cpu", + "type": "index-template" + }, + { + "id": "metrics-system.diskio", + "type": "index-template" + }, + { + "id": "metrics-system.entropy", + "type": "index-template" + }, + { + "id": "metrics-system.filesystem", + "type": "index-template" + }, + { + "id": "metrics-system.fsstat", + "type": "index-template" + }, + { + "id": "metrics-system.load", + "type": "index-template" + }, + { + "id": "metrics-system.memory", + "type": "index-template" + }, + { + "id": "metrics-system.network", + "type": "index-template" + }, + { + "id": "metrics-system.network_summary", + "type": "index-template" + }, + { + "id": "metrics-system.process", + "type": "index-template" + }, + { + "id": "metrics-system.process_summary", + "type": "index-template" + }, + { + "id": "metrics-system.raid", + "type": "index-template" + }, + { + "id": "metrics-system.service", + "type": "index-template" + }, + { + "id": "metrics-system.socket", + "type": "index-template" + }, + { + "id": "metrics-system.socket_summary", + "type": "index-template" + }, + { + "id": "logs-system.syslog", + "type": "index-template" + }, + { + "id": "metrics-system.uptime", + "type": "index-template" + }, + { + "id": "metrics-system.users", + "type": "index-template" + } + ], + "es_index_patterns": { + "auth": "logs-system.auth-*", + "core": "metrics-system.core-*", + "cpu": "metrics-system.cpu-*", + "diskio": "metrics-system.diskio-*", + "entropy": "metrics-system.entropy-*", + "filesystem": "metrics-system.filesystem-*", + "fsstat": "metrics-system.fsstat-*", + "load": "metrics-system.load-*", + "memory": "metrics-system.memory-*", + "network": "metrics-system.network-*", + "network_summary": "metrics-system.network_summary-*", + "process": "metrics-system.process-*", + "process_summary": "metrics-system.process_summary-*", + "raid": "metrics-system.raid-*", + "service": "metrics-system.service-*", + "socket": "metrics-system.socket-*", + "socket_summary": "metrics-system.socket_summary-*", + "syslog": "logs-system.syslog-*", + "uptime": "metrics-system.uptime-*", + "users": "metrics-system.users-*" + }, + "name": "system", + "version": "0.1.0", + "internal": false, + "removable": false + }, + "references": [], + "updated_at": "2020-05-15T20:08:08.708Z", + "version": "Wzk4LDFd" + } + }, + { + "description": "This package contains a yaml pipeline.\n", + "download": "/epr/yamlpipeline/yamlpipeline-1.0.0.tar.gz", + "name": "yamlpipeline", + "path": "/package/yamlpipeline/1.0.0", + "title": "Yaml Pipeline package", + "type": "integration", + "version": "1.0.0", + "status": "not_installed" + } + ], + "success": true + } + } + } + } + } + } + }, + "operationId": "get-epm-list" + }, + "parameters": [] + }, + "/epm/categories": { + "get": { + "summary": "EPM - Categories", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "count": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "count" + ] + } + } + } + } + } + }, + "operationId": "get-epm-categories" + } + }, + "/fleet/agents": { + "get": { + "summary": "Fleet - Agent - List", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object" + } + }, + "success": { + "type": "boolean" + }, + "total": { + "type": "number" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" + } + }, + "required": [ + "list", + "success", + "total", + "page", + "perPage" + ] + }, + "examples": { + "example-1": { + "value": { + "list": [ + { + "id": "205661d0-5e53-11ea-ad31-4f31c06bd9a4", + "active": true, + "config_id": "ae556400-5e39-11ea-8b49-f9747e466f7b", + "type": "PERMANENT", + "enrolled_at": "2020-03-04T20:02:50.605Z", + "user_provided_metadata": { + "dev_agent_version": "0.0.1", + "region": "us-east" + }, + "local_metadata": { + "host": "localhost", + "ip": "127.0.0.1", + "system": "Darwin 18.7.0", + "memory": 34359738368 + }, + "actions": [ + { + "data": "{\"config\":{\"id\":\"ae556400-5e39-11ea-8b49-f9747e466f7b\",\"outputs\":{\"default\":{\"type\":\"elasticsearch\",\"hosts\":[\"http://localhost:9200\"],\"api_key\":\"\",\"api_token\":\"6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw\"}},\"datasources\":[]}}", + "created_at": "2020-03-04T20:02:56.149Z", + "id": "6a95c00a-d76d-4931-97c3-0bf935272d7d", + "type": "CONFIG_CHANGE" + } + ], + "access_api_key_id": "6Mkkp3ABz7e_XRqrzLNJ", + "default_api_key": "6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw", + "current_error_events": [], + "last_checkin": "2020-03-04T20:03:05.700Z", + "status": "online" + } + ], + "success": true, + "total": 1, + "page": 1, + "perPage": 20 + } + } + } + } + } + } + }, + "operationId": "get-fleet-agents", + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/fleet/agents/{agentId}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Fleet - Agent - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "type": "object" + }, + "success": { + "type": "string" + } + }, + "required": [ + "item", + "success" + ] + } + } + } + } + }, + "operationId": "get-fleet-agents-agentId" + }, + "put": { + "summary": "Fleet - Agent - Update", + "tags": [], + "responses": {}, + "operationId": "put-fleet-agents-agentId", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + }, + "delete": { + "summary": "Fleet - Agent - Delete", + "tags": [], + "responses": {}, + "operationId": "delete-fleet-agents-agentId", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/fleet/agents/{agentId}/events": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Fleet - Agent - Events", + "tags": [], + "responses": {}, + "operationId": "get-fleet-agents-agentId-events" + } + }, + "/fleet/agents/{agentId}/checkin": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], + "post": { + "summary": "Fleet - Agent - Check In", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "checkin" + ] + }, + "success": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "data": { + "type": "object" + }, + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + }, + "required": [ + "agent_id", + "data", + "id", + "created_at", + "type" + ] + } + } + } + }, + "examples": { + "success": { + "value": { + "action": "checkin", + "success": true, + "actions": [ + { + "agent_id": "a6f14bd2-1a2a-481c-9212-9494d064ffdf", + "type": "CONFIG_CHANGE", + "data": { + "config": { + "id": "2fe89350-a5e0-11ea-a587-5f886c8a849f", + "outputs": { + "default": { + "type": "elasticsearch", + "hosts": [ + "http://localhost:9200" + ], + "api_key": "Z-XkgHIBvwtjzIKtSCTh:AejRqdKpQx6z-6dqSI1LHg" + } + }, + "datasources": [ + { + "id": "33d6bd70-a5e0-11ea-a587-5f886c8a849f", + "name": "system-1", + "namespace": "default", + "enabled": true, + "use_output": "default", + "inputs": [ + { + "type": "logs", + "enabled": true, + "streams": [ + { + "id": "logs-system.auth", + "enabled": true, + "dataset": "system.auth", + "paths": [ + "/var/log/auth.log*", + "/var/log/secure*" + ], + "exclude_files": [ + ".gz$" + ], + "multiline": { + "pattern": "^\\s", + "match": "after" + }, + "processors": [ + { + "add_locale": null + }, + { + "add_fields": { + "target": "", + "fields": { + "ecs.version": "1.5.0" + } + } + } + ] + }, + { + "id": "logs-system.syslog", + "enabled": true, + "dataset": "system.syslog", + "paths": [ + "/var/log/messages*", + "/var/log/syslog*" + ], + "exclude_files": [ + ".gz$" + ], + "multiline": { + "pattern": "^\\s", + "match": "after" + }, + "processors": [ + { + "add_locale": null + }, + { + "add_fields": { + "target": "", + "fields": { + "ecs.version": "1.5.0" + } + } + } + ] + } + ] + }, + { + "type": "system/metrics", + "enabled": true, + "streams": [ + { + "id": "system/metrics-system.core", + "enabled": true, + "dataset": "system.core", + "metricsets": [ + "core" + ], + "core.metrics": "percentages" + }, + { + "id": "system/metrics-system.cpu", + "enabled": true, + "dataset": "system.cpu", + "metricsets": [ + "cpu" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.diskio", + "enabled": true, + "dataset": "system.diskio", + "metricsets": [ + "diskio" + ] + }, + { + "id": "system/metrics-system.entropy", + "enabled": true, + "dataset": "system.entropy", + "metricsets": [ + "entropy" + ] + }, + { + "id": "system/metrics-system.filesystem", + "enabled": true, + "dataset": "system.filesystem", + "metricsets": [ + "filesystem" + ], + "period": "1m", + "processors": [ + { + "drop_event.when.regexp": { + "system.filesystem.mount_point": "^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)" + } + } + ] + }, + { + "id": "system/metrics-system.fsstat", + "enabled": true, + "dataset": "system.fsstat", + "metricsets": [ + "fsstat" + ], + "period": "1m", + "processors": [ + { + "drop_event.when.regexp": { + "system.filesystem.mount_point": "^/(sys|cgroup|proc|dev|etc|host|lib|snap)($|/)" + } + } + ] + }, + { + "id": "system/metrics-system.load", + "enabled": true, + "dataset": "system.load", + "metricsets": [ + "load" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.memory", + "enabled": true, + "dataset": "system.memory", + "metricsets": [ + "memory" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.network", + "enabled": true, + "dataset": "system.network", + "metricsets": [ + "network" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.network_summary", + "enabled": true, + "dataset": "system.network_summary", + "metricsets": [ + "network_summary" + ] + }, + { + "id": "system/metrics-system.process", + "enabled": true, + "dataset": "system.process", + "metricsets": [ + "process" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.process_summary", + "enabled": true, + "dataset": "system.process_summary", + "metricsets": [ + "process_summary" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.raid", + "enabled": true, + "dataset": "system.raid", + "metricsets": [ + "raid" + ] + }, + { + "id": "system/metrics-system.service", + "enabled": true, + "dataset": "system.service", + "metricsets": [ + "service" + ] + }, + { + "id": "system/metrics-system.socket", + "enabled": true, + "dataset": "system.socket", + "metricsets": [ + "socket" + ] + }, + { + "id": "system/metrics-system.socket_summary", + "enabled": true, + "dataset": "system.socket_summary", + "metricsets": [ + "socket_summary" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "process.include_top_n.by_cpu": 5, + "process.include_top_n.by_memory": 5, + "processes": ".*" + }, + { + "id": "system/metrics-system.uptime", + "enabled": true, + "dataset": "system.uptime", + "metricsets": [ + "uptime" + ], + "core.metrics": "percentages", + "cpu.metrics": "percentages,normalized_percentages", + "period": "10s", + "processes": ".*" + }, + { + "id": "system/metrics-system.users", + "enabled": true, + "dataset": "system.users", + "metricsets": [ + "users" + ] + } + ] + } + ], + "package": { + "name": "system", + "version": "0.1.0" + } + }, + { + "id": "fdb1fea0-a5f6-11ea-ad52-534e35d3cd6f", + "name": "endpoint-1", + "namespace": "default", + "enabled": true, + "use_output": "default", + "inputs": [], + "package": { + "name": "endpoint", + "version": "0.2.0" + } + }, + { + "id": "2d792280-a5f7-11ea-ad52-534e35d3cd6f", + "name": "endpoint-2", + "namespace": "default", + "enabled": true, + "use_output": "default", + "inputs": [], + "package": { + "name": "endpoint", + "version": "0.2.0" + } + } + ], + "revision": 4, + "settings": { + "monitoring": { + "use_output": "default", + "enabled": true, + "logs": true, + "metrics": true + } + } + } + }, + "id": "51c6ad1e-a9c0-4c70-80da-99a5c51eedaf", + "created_at": "2020-06-04T19:52:24.667Z" + } + ] + } + } + } + } + } + } + }, + "operationId": "post-fleet-agents-agentId-checkin", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ], + "security": [ + { + "Access API Key": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "local_metadata": { + "$ref": "#/components/schemas/AgentMetadata" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NewAgentEvent" + } + } + } + }, + "examples": { + "stoped to starting": { + "value": { + "events": [ + { + "type": "STATE", + "subtype": "STARTING", + "message": "state changed from STOPPED to STARTING", + "timestamp": "2019-10-01T13:42:54.323Z", + "payload": {}, + "agent_id": "bee40627-8cbd-45df-add9-98c390f9db10" + } + ] + } + }, + "running": { + "value": { + "events": [ + { + "type": "STATE", + "subtype": "RUNNING", + "message": "state changed from STOPPED to RUNNING", + "timestamp": "2020-05-26T20:44:57.480Z", + "payload": { + "random": "data", + "state": "RUNNING", + "previous_state": "STOPPED" + }, + "agent_id": "bee40627-8cbd-45df-add9-98c390f9db10" + } + ] + } + } + } + } + } + } + } + }, + "/fleet/agents/{agentId}/acks": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], + "post": { + "summary": "Fleet - Agent - Acks", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "action": { + "type": "string", + "enum": [ + "acks" + ] + } + }, + "required": [ + "success", + "action" + ] + }, + "examples": { + "success": { + "value": { + "action": "checkin", + "success": true + } + } + } + } + } + } + }, + "operationId": "post-fleet-agents-agentId-acks", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + }, + "examples": { + "example-1": { + "value": { + "events": [ + { + "type": "ACTION_RESULT", + "subtype": "CONFIG", + "timestamp": "2019-01-04T14:32:03.36764-05:00", + "action_id": "51c6ad1e-a9c0-4c70-80da-99a5c51eedaf", + "agent_id": "a6f14bd2-1a2a-481c-9212-9494d064ffdf", + "message": "acknowledge" + } + ] + } + } + } + } + } + } + } + }, + "/fleet/agents/enroll": { + "post": { + "summary": "Fleet - Agent - Enroll", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "item": { + "$ref": "#/components/schemas/Agent" + } + } + }, + "examples": { + "success": { + "value": { + "action": "created", + "success": true, + "item": { + "id": "8086fb1a-72ca-4a67-8533-09300c1639fa", + "active": true, + "config_id": "2fe89350-a5e0-11ea-a587-5f886c8a849f", + "type": "PERMANENT", + "enrolled_at": "2020-06-04T13:03:57.856Z", + "user_provided_metadata": { + "dev_agent_version": "0.0.1", + "region": "us-east" + }, + "local_metadata": { + "host": "localhost", + "ip": "127.0.0.1", + "system": "Darwin 18.7.0", + "memory": 34359738368 + }, + "current_error_events": [], + "access_api_key": "cU9KdWYzSUJ2d3RqeklLdFdnNF86ZW05ZjFrMThUWW1GRW13OHMwRGZvdw==", + "status": "error" + } + } + } + } + } + } + } + }, + "operationId": "post-fleet-agents-enroll", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "PERMANENT", + "EPHEMERAL", + "TEMPORARY" + ] + }, + "shared_id": { + "type": "string" + }, + "metadata": { + "type": "object", + "required": [ + "local", + "user_provided" + ], + "properties": { + "local": { + "$ref": "#/components/schemas/AgentMetadata" + }, + "user_provided": { + "$ref": "#/components/schemas/AgentMetadata" + } + } + } + }, + "required": [ + "type", + "metadata" + ] + }, + "examples": { + "good": { + "value": { + "type": "PERMANENT", + "metadata": { + "local": { + "host": "localhost", + "ip": "127.0.0.1", + "system": "Darwin 18.7.0", + "memory": 34359738368 + }, + "user_provided": { + "dev_agent_version": "0.0.1", + "region": "us-east" + } + } + } + } + } + } + } + }, + "security": [ + { + "Enrollment API Key": [] + } + ] + } + }, + "/fleet/agents/unenroll": { + "post": { + "summary": "Fleet - Agent - Unenroll", + "tags": [], + "responses": {}, + "operationId": "post-fleet-agents-unenroll", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/fleet/config/{configId}/agent-status": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "configId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Fleet - Agent - Status for config", + "tags": [], + "responses": {}, + "operationId": "get-fleet-config-configId-agent-status" + } + }, + "/fleet/enrollment-api-keys": { + "get": { + "summary": "Enrollment - List", + "tags": [], + "responses": {}, + "operationId": "get-fleet-enrollment-api-keys", + "parameters": [] + }, + "post": { + "summary": "Enrollment - Create", + "tags": [], + "responses": {}, + "operationId": "post-fleet-enrollment-api-keys", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/fleet/enrollment-api-keys/{keyId}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "keyId", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Enrollment - Info", + "tags": [], + "responses": {}, + "operationId": "get-fleet-enrollment-api-keys-keyId" + }, + "delete": { + "summary": "Enrollment - Delete", + "tags": [], + "responses": {}, + "operationId": "delete-fleet-enrollment-api-keys-keyId", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/setup": { + "post": { + "summary": "Ingest Manager - Setup", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isInitialized": { + "type": "boolean" + } + } + }, + "examples": { + "success": { + "value": { + "isInitialized": true + } + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "examples": {} + } + } + } + }, + "operationId": "post-setup", + "parameters": [ + { + "$ref": "#/components/parameters/xsrfHeader" + } + ] + } + }, + "/fleet/install/{osType}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "osType", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Fleet - Get OS install script", + "tags": [], + "responses": {}, + "operationId": "get-fleet-install-osType" + } + } + }, + "components": { + "schemas": { + "AgentConfig": { + "allOf": [ + { + "$ref": "#/components/schemas/NewAgentConfig" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "datasources": { + "oneOf": [ + { + "items": { + "type": "string" + } + }, + { + "items": { + "$ref": "#/components/schemas/Datasource" + } + } + ], + "type": "array" + }, + "updated_on": { + "type": "string", + "format": "date-time" + }, + "updated_by": { + "type": "string" + }, + "revision": { + "type": "number" + }, + "agents": { + "type": "number" + } + }, + "required": [ + "id", + "status" + ] + } + ] + }, + "Datasource": { + "title": "Datasource", + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision": { + "type": "number" + }, + "inputs": { + "type": "array", + "items": {} + } + }, + "required": [ + "id", + "revision" + ] + }, + { + "$ref": "#/components/schemas/NewDatasource" + } + ], + "x-examples": { + "example-1": {} + } + }, + "NewAgentConfig": { + "title": "NewAgentConfig", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "NewDatasource": { + "title": "NewDatasource", + "type": "object", + "x-examples": { + "example-1": { + "enabled": true, + "title": "This is a nice title for human", + "package": { + "name": "epm/nginx", + "version": "1.7.0" + }, + "namespace": "prod", + "use_output": "long_term_storage", + "inputs": [ + { + "type": "logs", + "streams": [ + { + "enabled": true, + "dataset": "nginx.acccess", + "paths": [ + "/var/log/nginx/access.log" + ] + }, + { + "enabled": true, + "dataset": "nginx.error", + "paths": [ + "/var/log/nginx/error.log" + ] + } + ] + }, + { + "type": "nginx/metrics", + "streams": [ + { + "id": "id string", + "enabled": true, + "dataset": "nginx.stub_status", + "metricset": "stub_status" + } + ] + } + ] + } + }, + "description": "", + "properties": { + "enabled": { + "type": "boolean" + }, + "package": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "name", + "version", + "title" + ] + }, + "namespace": { + "type": "string" + }, + "output_id": { + "type": "string" + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "processors": { + "type": "array", + "items": { + "type": "string" + } + }, + "streams": { + "type": "array", + "items": {} + }, + "config": { + "type": "object" + }, + "vars": { + "type": "object" + } + }, + "required": [ + "type", + "enabled", + "streams" + ] + } + }, + "config_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "output_id", + "inputs", + "config_id", + "name" + ] + }, + "PackageInfo": { + "title": "PackageInfo", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "readme": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "requirement": { + "oneOf": [ + { + "properties": { + "kibana": { + "type": "object", + "properties": { + "versions": { + "type": "string" + } + } + } + } + }, + { + "properties": { + "elasticsearch": { + "type": "object", + "properties": { + "versions": { + "type": "string" + } + } + } + } + } + ], + "type": "object" + }, + "screenshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "src" + ] + } + }, + "icons": { + "type": "array", + "items": { + "type": "string" + } + }, + "assets": { + "type": "array", + "items": { + "type": "string" + } + }, + "internal": { + "type": "boolean" + }, + "format_version": { + "type": "string" + }, + "datasets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "name": { + "type": "string" + }, + "release": { + "type": "string" + }, + "ingeset_pipeline": { + "type": "string" + }, + "vars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "default": { + "type": "string" + } + }, + "required": [ + "name", + "default" + ] + } + }, + "type": { + "type": "string" + }, + "package": { + "type": "string" + } + }, + "required": [ + "title", + "name", + "release", + "ingeset_pipeline", + "type", + "package" + ] + } + }, + "download": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "name", + "title", + "version", + "description", + "type", + "categories", + "requirement", + "assets", + "format_version", + "download", + "path" + ] + }, + "SearchResult": { + "title": "SearchResult", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "download": { + "type": "string" + }, + "icons": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + }, + "status": { + "type": "string" + }, + "savedObject": { + "type": "object" + } + }, + "required": [ + "description", + "download", + "icons", + "name", + "path", + "title", + "type", + "version", + "status" + ] + }, + "AgentStatus": { + "type": "string", + "title": "AgentStatus", + "enum": [ + "offline", + "error", + "online", + "inactive", + "warning" + ] + }, + "Agent": { + "title": "Agent", + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/AgentType" + }, + "active": { + "type": "boolean" + }, + "enrolled_at": { + "type": "string" + }, + "shared_id": { + "type": "string" + }, + "access_api_key_id": { + "type": "string" + }, + "default_api_key_id": { + "type": "string" + }, + "config_id": { + "type": "string" + }, + "config_revision": { + "type": [ + "number", + "null" + ] + }, + "config_newest_revision": { + "type": "number" + }, + "last_checkin": { + "type": "string" + }, + "user_provided_metadata": { + "$ref": "#/components/schemas/AgentMetadata" + }, + "local_metadata": { + "$ref": "#/components/schemas/AgentMetadata" + }, + "id": { + "type": "string" + }, + "current_error_events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentEvent" + } + }, + "access_api_key": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AgentStatus" + }, + "default_api_key": { + "type": "string" + } + }, + "required": [ + "type", + "active", + "enrolled_at", + "id", + "current_error_events", + "status" + ] + }, + "AgentType": { + "type": "string", + "title": "AgentType", + "enum": [ + "PERMANENT", + "EPHEMERAL", + "TEMPORARY" + ] + }, + "AgentMetadata": { + "title": "AgentMetadata", + "type": "object" + }, + "NewAgentEvent": { + "title": "NewAgentEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "STATE", + "ERROR", + "ACTION_RESULT", + "ACTION" + ] + }, + "subtype": { + "type": "string", + "enum": [ + "RUNNING", + "STARTING", + "IN_PROGRESS", + "CONFIG", + "FAILED", + "STOPPING", + "STOPPED", + "DATA_DUMP", + "ACKNOWLEDGED", + "UNKNOWN" + ] + }, + "timestamp": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "config_id": { + "type": "string" + }, + "stream_id": { + "type": "string" + }, + "action_id": { + "type": "string" + } + }, + "required": [ + "type", + "subtype", + "timestamp", + "message", + "agent_id" + ] + }, + "AgentEvent": { + "title": "AgentEvent", + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + { + "$ref": "#/components/schemas/NewAgentEvent" + } + ] + }, + "AccessApiKey": { + "type": "string", + "title": "AccessApiKey", + "format": "byte" + }, + "EnrollmentApiKey": { + "type": "string", + "title": "EnrollmentApiKey", + "format": "byte" + } + }, + "parameters": { + "pageSizeParam": { + "name": "perPage", + "in": "query", + "description": "The number of items to return", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } + }, + "pageIndexParam": { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + "kueryParam": { + "name": "kuery", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "xsrfHeader": { + "schema": { + "type": "string" + }, + "in": "header", + "name": "kbn-xsrf", + "required": true + } + }, + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "Enrollment API Key": { + "name": "Authorization", + "type": "apiKey", + "in": "header", + "description": "e.g. Authorization: ApiKey base64EnrollmentApiKey" + }, + "Access API Key": { + "name": "Authorization", + "type": "apiKey", + "in": "header", + "description": "e.g. Authorization: ApiKey base64AccessApiKey" + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/custom_configure_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/custom_configure_datasource.tsx index aff764cb8ba3e..4263feb7cd8c7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/custom_configure_datasource.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/custom_configure_datasource.tsx @@ -12,7 +12,8 @@ import { CreateDatasourceFrom } from '../types'; export interface CustomConfigureDatasourceProps { packageName: string; from: CreateDatasourceFrom; - datasource: NewDatasource | (NewDatasource & { id: string }); + datasource: NewDatasource; + datasourceId?: string; } /** diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx index d9cf0fbfb7987..5499ac287ff05 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx @@ -16,7 +16,8 @@ import { CreateDatasourceFrom } from './types'; export const StepConfigureDatasource: React.FunctionComponent<{ from?: CreateDatasourceFrom; packageInfo: PackageInfo; - datasource: NewDatasource | (NewDatasource & { id: string }); + datasource: NewDatasource; + datasourceId?: string; updateDatasource: (fields: Partial) => void; validationResults: DatasourceValidationResults; submitAttempted: boolean; @@ -24,6 +25,7 @@ export const StepConfigureDatasource: React.FunctionComponent<{ from = 'config', packageInfo, datasource, + datasourceId, updateDatasource, validationResults, submitAttempted, @@ -70,9 +72,10 @@ export const StepConfigureDatasource: React.FunctionComponent<{ ) : ( ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx index 4bb42faedf7f6..d47eea80da8b7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx @@ -69,8 +69,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => { const [loadingError, setLoadingError] = useState(); const [agentConfig, setAgentConfig] = useState(); const [packageInfo, setPackageInfo] = useState(); - const [datasource, setDatasource] = useState({ - id: '', + const [datasource, setDatasource] = useState({ name: '', description: '', config_id: '', @@ -94,6 +93,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => { } if (datasourceData?.item) { const { + id, revision, inputs, created_by, @@ -302,6 +302,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => { from={'edit'} packageInfo={packageInfo} datasource={datasource} + datasourceId={datasourceId} updateDatasource={updateDatasource} validationResults={validationResults!} submitAttempted={formState === 'INVALID'} diff --git a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts index e4c860713a6f1..2c65b08a68700 100644 --- a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts @@ -31,12 +31,12 @@ export const getListHandler: RequestHandler = async (context, request, response) must: [ { exists: { - field: 'stream.namespace', + field: 'dataset.namespace', }, }, { exists: { - field: 'stream.dataset', + field: 'dataset.name', }, }, ], @@ -54,19 +54,19 @@ export const getListHandler: RequestHandler = async (context, request, response) aggs: { dataset: { terms: { - field: 'stream.dataset', + field: 'dataset.name', size: 1, }, }, namespace: { terms: { - field: 'stream.namespace', + field: 'dataset.namespace', size: 1, }, }, type: { terms: { - field: 'stream.type', + field: 'dataset.type', size: 1, }, }, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index 85d4a85e245c2..ef3542b7ecffd 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -388,12 +388,12 @@ const getIndexQuery = (templateName: string) => ({ must: [ { exists: { - field: 'stream.namespace', + field: 'dataset.namespace', }, }, { exists: { - field: 'stream.dataset', + field: 'dataset.name', }, }, ], diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_api.tsx new file mode 100644 index 0000000000000..45e180d9d617c --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +import * as Api from '../api'; +import { HttpStart } from '../../../../../../src/core/public'; +import { ExceptionListItemSchema, ExceptionListSchema } from '../../../common/schemas'; +import { ApiCallMemoProps } from '../types'; + +export interface ExceptionsApi { + deleteExceptionItem: (arg: ApiCallMemoProps) => Promise; + deleteExceptionList: (arg: ApiCallMemoProps) => Promise; + getExceptionItem: ( + arg: ApiCallMemoProps & { onSuccess: (arg: ExceptionListItemSchema) => void } + ) => Promise; + getExceptionList: ( + arg: ApiCallMemoProps & { onSuccess: (arg: ExceptionListSchema) => void } + ) => Promise; +} + +export const useApi = (http: HttpStart): ExceptionsApi => { + return useMemo( + (): ExceptionsApi => ({ + async deleteExceptionItem({ + id, + namespaceType, + onSuccess, + onError, + }: ApiCallMemoProps): Promise { + const abortCtrl = new AbortController(); + + try { + await Api.deleteExceptionListItemById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }); + onSuccess(); + } catch (error) { + onError(error); + } + }, + async deleteExceptionList({ + id, + namespaceType, + onSuccess, + onError, + }: ApiCallMemoProps): Promise { + const abortCtrl = new AbortController(); + + try { + await Api.deleteExceptionListById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }); + onSuccess(); + } catch (error) { + onError(error); + } + }, + async getExceptionItem({ + id, + namespaceType, + onSuccess, + onError, + }: ApiCallMemoProps & { onSuccess: (arg: ExceptionListItemSchema) => void }): Promise { + const abortCtrl = new AbortController(); + + try { + const item = await Api.fetchExceptionListItemById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }); + onSuccess(item); + } catch (error) { + onError(error); + } + }, + async getExceptionList({ + id, + namespaceType, + onSuccess, + onError, + }: ApiCallMemoProps & { onSuccess: (arg: ExceptionListSchema) => void }): Promise { + const abortCtrl = new AbortController(); + + try { + const list = await Api.fetchExceptionListById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }); + onSuccess(list); + } catch (error) { + onError(error); + } + }, + }), + [http] + ); +}; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx index a6a25ab4d4e9d..fbd43787a822e 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx @@ -10,7 +10,8 @@ import * as api from '../api'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; -import { ExceptionListAndItems, UseExceptionListProps } from '../types'; +import { ExceptionListItemSchema } from '../../../common/schemas'; +import { ExceptionList, UseExceptionListProps } from '../types'; import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; @@ -34,15 +35,23 @@ describe('useExceptionList', () => { >(() => useExceptionList({ http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }) ); await waitForNextUpdate(); - expect(result.current).toEqual([true, null, result.current[2]]); - expect(typeof result.current[2]).toEqual('function'); + expect(result.current).toEqual([ + true, + [], + [], + { + page: 1, + perPage: 20, + total: 0, + }, + null, + ]); }); }); @@ -54,27 +63,32 @@ describe('useExceptionList', () => { >(() => useExceptionList({ http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }) ); await waitForNextUpdate(); await waitForNextUpdate(); - const expectedResult: ExceptionListAndItems = { - ...getExceptionListSchemaMock(), - exceptionItems: { - items: [{ ...getExceptionListItemSchemaMock() }], - pagination: { - page: 1, - perPage: 20, - total: 1, - }, - }, - }; + const expectedListResult: ExceptionList[] = [ + { ...getExceptionListSchemaMock(), totalItems: 1 }, + ]; + + const expectedListItemsResult: ExceptionListItemSchema[] = [ + { ...getExceptionListItemSchemaMock() }, + ]; - expect(result.current).toEqual([false, expectedResult, result.current[2]]); + expect(result.current).toEqual([ + false, + expectedListResult, + expectedListItemsResult, + { + page: 1, + perPage: 20, + total: 1, + }, + result.current[4], + ]); }); }); @@ -86,13 +100,12 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >( - ({ filterOptions, http, id, namespaceType, pagination, onError }) => - useExceptionList({ filterOptions, http, id, namespaceType, onError, pagination }), + ({ filterOptions, http, lists, pagination, onError }) => + useExceptionList({ filterOptions, http, lists, onError, pagination }), { initialProps: { http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }, } @@ -100,8 +113,7 @@ describe('useExceptionList', () => { await waitForNextUpdate(); rerender({ http: mockKibanaHttpService, - id: 'newListId', - namespaceType: 'single', + lists: [{ id: 'newListId', namespaceType: 'single' }], onError: onErrorMock, }); await waitForNextUpdate(); @@ -121,14 +133,19 @@ describe('useExceptionList', () => { >(() => useExceptionList({ http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }) ); await waitForNextUpdate(); await waitForNextUpdate(); - result.current[2](); + + expect(typeof result.current[4]).toEqual('function'); + + if (result.current[4] != null) { + result.current[4](); + } + await waitForNextUpdate(); expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(2); @@ -147,8 +164,7 @@ describe('useExceptionList', () => { () => useExceptionList({ http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }) ); @@ -170,8 +186,7 @@ describe('useExceptionList', () => { () => useExceptionList({ http: mockKibanaHttpService, - id: 'myListId', - namespaceType: 'single', + lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, }) ); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx index 116233cd89348..1d7a63ba880bf 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { fetchExceptionListById, fetchExceptionListItemsByListId } from '../api'; -import { ExceptionListAndItems, UseExceptionListProps } from '../types'; +import { ExceptionIdentifiers, ExceptionList, Pagination, UseExceptionListProps } from '../types'; +import { ExceptionListItemSchema } from '../../../common/schemas'; -export type ReturnExceptionListAndItems = [boolean, ExceptionListAndItems | null, () => void]; +type Func = () => void; +export type ReturnExceptionListAndItems = [ + boolean, + ExceptionList[], + ExceptionListItemSchema[], + Pagination, + Func | null +]; /** * Hook for using to get an ExceptionList and it's ExceptionListItems @@ -24,8 +32,7 @@ export type ReturnExceptionListAndItems = [boolean, ExceptionListAndItems | null */ export const useExceptionList = ({ http, - id, - namespaceType, + lists, pagination = { page: 1, perPage: 20, @@ -36,20 +43,37 @@ export const useExceptionList = ({ tags: [], }, onError, + dispatchListsInReducer, }: UseExceptionListProps): ReturnExceptionListAndItems => { - const [exceptionListAndItems, setExceptionList] = useState(null); - const [shouldRefresh, setRefresh] = useState(true); - const refreshExceptionList = useCallback(() => setRefresh(true), [setRefresh]); + const [exceptionLists, setExceptionLists] = useState([]); + const [exceptionItems, setExceptionListItems] = useState([]); + const [paginationInfo, setPagination] = useState(pagination); + const fetchExceptionList = useRef(null); const [loading, setLoading] = useState(true); - const tags = filterOptions.tags.sort().join(); + const tags = useMemo(() => filterOptions.tags.sort().join(), [filterOptions.tags]); + const listIds = useMemo( + () => + lists + .map((t) => t.id) + .sort() + .join(), + [lists] + ); useEffect( () => { - let isSubscribed = true; - const abortCtrl = new AbortController(); + let isSubscribed = false; + let abortCtrl: AbortController; + + const fetchLists = async (): Promise => { + isSubscribed = true; + abortCtrl = new AbortController(); - const fetchData = async (idToFetch: string): Promise => { - if (shouldRefresh) { + // TODO: workaround until api updated, will be cleaned up + let exceptions: ExceptionListItemSchema[] = []; + let exceptionListsReturned: ExceptionList[] = []; + + const fetchData = async ({ id, namespaceType }: ExceptionIdentifiers): Promise => { try { setLoading(true); @@ -59,7 +83,7 @@ export const useExceptionList = ({ ...restOfExceptionList } = await fetchExceptionListById({ http, - id: idToFetch, + id, namespaceType, signal: abortCtrl.signal, }); @@ -72,40 +96,68 @@ export const useExceptionList = ({ signal: abortCtrl.signal, }); - setRefresh(false); - if (isSubscribed) { - setExceptionList({ - list_id, - namespace_type, - ...restOfExceptionList, - exceptionItems: { - items: [...fetchListItemsResult.data], + exceptionListsReturned = [ + ...exceptionListsReturned, + { + list_id, + namespace_type, + ...restOfExceptionList, + totalItems: fetchListItemsResult.total, + }, + ]; + setExceptionLists(exceptionListsReturned); + setPagination({ + page: fetchListItemsResult.page, + perPage: fetchListItemsResult.per_page, + total: fetchListItemsResult.total, + }); + + exceptions = [...exceptions, ...fetchListItemsResult.data]; + setExceptionListItems(exceptions); + + if (dispatchListsInReducer != null) { + dispatchListsInReducer({ + exceptions, + lists: exceptionListsReturned, pagination: { page: fetchListItemsResult.page, perPage: fetchListItemsResult.per_page, total: fetchListItemsResult.total, }, - }, - }); + }); + } } } catch (error) { - setRefresh(false); if (isSubscribed) { - setExceptionList(null); + setExceptionLists([]); + setExceptionListItems([]); + setPagination({ + page: 1, + perPage: 20, + total: 0, + }); onError(error); } } - } + }; + + // TODO: Workaround for now. Once api updated, we can pass in array of lists to fetch + await Promise.all( + lists.map( + ({ id, namespaceType }: ExceptionIdentifiers): Promise => + fetchData({ id, namespaceType }) + ) + ); if (isSubscribed) { setLoading(false); } }; - if (id != null) { - fetchData(id); - } + fetchLists(); + + fetchExceptionList.current = fetchLists; return (): void => { isSubscribed = false; abortCtrl.abort(); @@ -113,9 +165,9 @@ export const useExceptionList = ({ }, // eslint-disable-next-line react-hooks/exhaustive-deps [ http, - id, - onError, - shouldRefresh, + listIds, + setExceptionLists, + setExceptionListItems, pagination.page, pagination.perPage, filterOptions.filter, @@ -123,5 +175,5 @@ export const useExceptionList = ({ ] ); - return [loading, exceptionListAndItems, refreshExceptionList]; + return [loading, exceptionLists, exceptionItems, paginationInfo, fetchExceptionList.current]; }; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index cf6b6c3ec1c59..286eb0570ebb8 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -24,15 +24,6 @@ export interface Pagination { total: number; } -export interface ExceptionItemsAndPagination { - items: ExceptionListItemSchema[]; - pagination: Pagination; -} - -export interface ExceptionListAndItems extends ExceptionListSchema { - exceptionItems: ExceptionItemsAndPagination; -} - export type AddExceptionList = ExceptionListSchema | CreateExceptionListSchemaPartial; export type AddExceptionListItem = CreateExceptionListItemSchemaPartial | ExceptionListItemSchema; @@ -42,13 +33,31 @@ export interface PersistHookProps { onError: (arg: Error) => void; } +export interface ExceptionList extends ExceptionListSchema { + totalItems: number; +} + export interface UseExceptionListProps { - filterOptions?: FilterExceptionsOptions; http: HttpStart; - id: string | undefined; - namespaceType: NamespaceType; + lists: ExceptionIdentifiers[]; onError: (arg: Error) => void; + filterOptions?: FilterExceptionsOptions; pagination?: Pagination; + dispatchListsInReducer?: ({ + lists, + exceptions, + pagination, + }: { + lists: ExceptionList[]; + exceptions: ExceptionListItemSchema[]; + pagination: Pagination; + }) => void; +} + +export interface ExceptionIdentifiers { + id: string; + namespaceType: NamespaceType; + type?: string; } export interface ApiCallByListIdProps { @@ -67,6 +76,13 @@ export interface ApiCallByIdProps { signal: AbortSignal; } +export interface ApiCallMemoProps { + id: string; + namespaceType: NamespaceType; + onError: (arg: Error) => void; + onSuccess: () => void; +} + export interface AddExceptionListProps { http: HttpStart; list: AddExceptionList; diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/index.tsx index fb4d5de06ae54..1e25275a0d38b 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/index.tsx @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ // Exports to be shared with plugins +export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; export { useExceptionList } from './exceptions/hooks/use_exception_list'; +export { ExceptionList, ExceptionIdentifiers } from './exceptions/types'; export { mockNewExceptionItem, mockNewExceptionList } from './exceptions/mock'; diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json new file mode 100644 index 0000000000000..306195f4226e3 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json @@ -0,0 +1,9 @@ +{ + "list_id": "detection_list", + "_tags": ["detection"], + "tags": ["detection", "sample_tag"], + "type": "detection", + "description": "This is a sample detection type exception list", + "name": "Sample Detection Exception List", + "namespace_type": "single" +} diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json index d68a26eb8ffe2..c89c7a8f080cf 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json @@ -5,6 +5,7 @@ "type": "simple", "description": "This is a sample endpoint type exception that has no item_id so it creates a new id each time", "name": "Sample Endpoint Exception List", + "comment": [], "entries": [ { "field": "actingProcess.file.signer", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json new file mode 100644 index 0000000000000..3fe4458a73769 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json @@ -0,0 +1,26 @@ +{ + "list_id": "detection_list", + "_tags": ["detection"], + "tags": ["test_tag", "detection", "no_more_bad_guys"], + "type": "simple", + "description": "This is a sample detection type exception that has no item_id so it creates a new id each time", + "name": "Sample Detection Exception List Item", + "comment": [], + "entries": [ + { + "field": "host.name", + "operator": "included", + "match": "sampleHostName" + }, + { + "field": "event.category", + "operator": "included", + "match_any": ["process", "malware"] + }, + { + "field": "event.action", + "operator": "included", + "match": "user-password-change" + } + ] +} diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx index 116e03096b0f5..0192a9d7ca68f 100644 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx +++ b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx @@ -139,11 +139,11 @@ const COLOR_PALETTES_CONFIGS: ColorPalette[] = [ }, { id: 'palette_20', - colors: euiPaletteColorBlind(2), + colors: euiPaletteColorBlind({ rotations: 2 }), }, { id: 'palette_30', - colors: euiPaletteColorBlind(3), + colors: euiPaletteColorBlind({ rotations: 3 }), }, ]; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index 5508c021d3313..140593cb17f6e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -1,5 +1,3 @@ @import 'pages/analytics_exploration/components/regression_exploration/index'; @import 'pages/analytics_management/components/analytics_list/index'; -@import 'pages/analytics_management/components/create_analytics_form/index'; -@import 'pages/analytics_management/components/create_analytics_flyout/index'; @import 'pages/analytics_management/components/create_analytics_button/index'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index ad540285e49f0..09aea596d81e9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -85,7 +85,7 @@ export const MemoizedAnalysisFieldsTable: FC<{ if (excludes.length > 0) { setCurrentSelection(excludes); } - }, []); + }, [tableItems]); // Only set form state on unmount to prevent re-renders due to props changing if exludes was updated on each selection useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 9446dfd4ed525..e63756686a4ba 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -20,13 +20,13 @@ import { TRAINING_PERCENT_MAX, } from '../../../../common/analytics'; import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; -import { Messages } from '../../../analytics_management/components/create_analytics_form/messages'; +import { Messages } from '../shared'; import { DEFAULT_MODEL_MEMORY_LIMIT, getJobConfigFromFormState, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; -import { shouldAddAsDepVarOption } from '../../../analytics_management/components/create_analytics_form/form_options_validation'; +import { shouldAddAsDepVarOption } from './form_options_validation'; import { ml } from '../../../../../services/ml_api_service'; import { getToastNotifications } from '../../../../../util/dependency_cache'; @@ -56,7 +56,7 @@ export const ConfigurationStepForm: FC = ({ const { currentSavedSearch, currentIndexPattern } = mlContext; const { savedSearchQuery, savedSearchQueryStr } = useSavedSearch(); - const { initiateWizard, setEstimatedModelMemoryLimit, setFormState } = actions; + const { setEstimatedModelMemoryLimit, setFormState } = actions; const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state; const firstUpdate = useRef(true); const { @@ -75,9 +75,12 @@ export const ConfigurationStepForm: FC = ({ modelMemoryLimit, previousJobType, requiredFieldsError, + sourceIndex, trainingPercent, } = form; + const toastNotifications = getToastNotifications(); + const setJobConfigQuery = ({ query, queryString }: { query: any; queryString: string }) => { setFormState({ jobConfigQuery: query, jobConfigQueryString: queryString }); }; @@ -90,7 +93,7 @@ export const ConfigurationStepForm: FC = ({ const indexPreviewProps = { ...indexData, dataTestSubj: 'mlAnalyticsCreationDataGrid', - toastNotifications: getToastNotifications(), + toastNotifications, }; const isJobTypeWithDepVar = @@ -209,7 +212,8 @@ export const ConfigurationStepForm: FC = ({ }); } } catch (e) { - let errorMessage; + let maxDistinctValuesErrorMessage; + if ( jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && e.body && @@ -218,7 +222,23 @@ export const ConfigurationStepForm: FC = ({ (e.body.message.includes('must have at most') || e.body.message.includes('must have at least')) ) { - errorMessage = e.body.message; + maxDistinctValuesErrorMessage = e.body.message; + } + + if ( + e.body && + e.body.message !== undefined && + e.body.message.includes('status_exception') && + e.body.message.includes('Unable to estimate memory usage as no documents') + ) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.dataframe.analytics.create.allDocsMissingFieldsErrorMessage', { + defaultMessage: `Unable to estimate memory usage. There are mapped fields for source index [{index}] that do not exist in any indexed documents. You will have to switch to the JSON editor for explicit field selection and include only fields that exist in indexed documents.`, + values: { + index: sourceIndex, + }, + }) + ); } const fallbackModelMemoryLimit = jobType !== undefined @@ -227,17 +247,13 @@ export const ConfigurationStepForm: FC = ({ setEstimatedModelMemoryLimit(fallbackModelMemoryLimit); setFormState({ fieldOptionsFetchFail: true, - maxDistinctValuesError: errorMessage, + maxDistinctValuesError: maxDistinctValuesErrorMessage, loadingFieldOptions: false, ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), }); } }, 300); - useEffect(() => { - initiateWizard(); - }, []); - useEffect(() => { setFormState({ sourceIndex: currentIndexPattern.title }); }, []); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts similarity index 93% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts index 0579283c97d61..bf3ab01549139 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/form_options_validation.ts @@ -7,7 +7,7 @@ import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; -import { AnalyticsJobType } from '../../hooks/use_create_analytics_form/state'; +import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields'; export const CATEGORICAL_TYPES = new Set(['ip', 'keyword']); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx index fe13cc1d6edfc..0a4ba67831818 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx @@ -12,10 +12,7 @@ import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields'; -import { - OMIT_FIELDS, - CATEGORICAL_TYPES, -} from '../../../analytics_management/components/create_analytics_form/form_options_validation'; +import { OMIT_FIELDS, CATEGORICAL_TYPES } from './form_options_validation'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx similarity index 95% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index 17b905cab135b..a35a314bec985 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -22,9 +22,9 @@ import { XJsonMode } from '../../../../../../../shared_imports'; const xJsonMode = new XJsonMode(); -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CreateStep } from '../../../analytics_creation/components/create_step'; -import { ANALYTICS_STEPS } from '../../../analytics_creation/page'; +import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { CreateStep } from '../create_step'; +import { ANALYTICS_STEPS } from '../../page'; export const CreateAnalyticsAdvancedEditor: FC = (props) => { const { actions, state } = props; @@ -125,7 +125,6 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop onChange={onChange} setOptions={{ fontSize: '12px', - maxLines: 20, }} theme="textmate" aria-label={i18n.translate( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/index.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/index.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx index 2dda5f5d819b7..8d51848a25f50 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx @@ -16,7 +16,7 @@ import { import { i18n } from '@kbn/i18n'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; -import { Messages } from '../../../analytics_management/components/create_analytics_form/messages'; +import { Messages } from '../shared'; import { ANALYTICS_STEPS } from '../../page'; import { BackToListPanel } from '../back_to_list_panel'; @@ -26,14 +26,7 @@ interface Props extends CreateAnalyticsFormProps { export const CreateStep: FC = ({ actions, state, step }) => { const { createAnalyticsJob, startAnalyticsJob } = actions; - const { - isAdvancedEditorValidJson, - isJobCreated, - isJobStarted, - isModalButtonDisabled, - isValid, - requestMessages, - } = state; + const { isAdvancedEditorValidJson, isJobCreated, isJobStarted, isValid, requestMessages } = state; const [checked, setChecked] = useState(true); @@ -75,7 +68,7 @@ export const CreateStep: FC = ({ actions, state, step }) => { { +interface Props { + jobId?: DataFrameAnalyticsId; +} + +export const Page: FC = ({ jobId }) => { const [currentStep, setCurrentStep] = useState(ANALYTICS_STEPS.CONFIGURATION); const [activatedSteps, setActivatedSteps] = useState([true, false, false, false]); @@ -44,23 +50,36 @@ export const Page: FC = () => { const createAnalyticsForm = useCreateAnalyticsForm(); const { isAdvancedEditorEnabled } = createAnalyticsForm.state; const { jobType } = createAnalyticsForm.state.form; - const { switchToAdvancedEditor } = createAnalyticsForm.actions; + const { initiateWizard, setJobClone, switchToAdvancedEditor } = createAnalyticsForm.actions; useEffect(() => { - if (activatedSteps[currentStep] === false) { - activatedSteps.splice(currentStep, 1, true); - setActivatedSteps(activatedSteps); - } - }, [currentStep]); + initiateWizard(); - useEffect(() => { if (currentIndexPattern) { (async function () { await newJobCapsService.initializeFromIndexPattern(currentIndexPattern, false, false); + + if (jobId !== undefined) { + const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); + if ( + Array.isArray(analyticsConfigs.data_frame_analytics) && + analyticsConfigs.data_frame_analytics.length > 0 + ) { + const clonedJobConfig: any = analyticsConfigs.data_frame_analytics[0]; + await setJobClone(clonedJobConfig); + } + } })(); } }, []); + useEffect(() => { + if (activatedSteps[currentStep] === false) { + activatedSteps.splice(currentStep, 1, true); + setActivatedSteps(activatedSteps); + } + }, [currentStep]); + const analyticsWizardSteps = [ { title: i18n.translate('xpack.ml.dataframe.analytics.creation.configurationStepTitle', { @@ -127,10 +146,19 @@ export const Page: FC = () => {

- + {jobId === undefined && ( + + )} + {jobId !== undefined && ( + + )}

diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts index 9221f8c500326..01d92d8e192c1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts @@ -140,8 +140,8 @@ describe('Analytics job clone action', () => { expect(isAdvancedConfig(advancedClassificationJob)).toBe(true); }); - test('should detect advanced outlier_detection job', () => { - const advancedOutlierDetectionJob = { + test('should detect advanced regression job', () => { + const advancedRegressionJob = { description: "Outlier detection job with 'glass' dataset", source: { index: ['glass_withoutdupl_norm'], @@ -155,10 +155,8 @@ describe('Analytics job clone action', () => { results_field: 'ml', }, analysis: { - outlier_detection: { - compute_feature_influence: false, - outlier_fraction: 0.05, - standardization_enabled: true, + regression: { + loss_function: 'msle', }, }, analyzed_fields: { @@ -168,7 +166,7 @@ describe('Analytics job clone action', () => { model_memory_limit: '1mb', allow_lazy_start: false, }; - expect(isAdvancedConfig(advancedOutlierDetectionJob)).toBe(true); + expect(isAdvancedConfig(advancedRegressionJob)).toBe(true); }); test('should detect a custom query', () => { @@ -207,32 +205,6 @@ describe('Analytics job clone action', () => { expect(isAdvancedConfig(advancedRegressionJob)).toBe(true); }); - test('should detect custom analysis settings', () => { - const config = { - description: "Classification clone with 'bank-marketing' dataset", - source: { - index: 'bank-marketing', - }, - dest: { - index: 'bank_classification4', - }, - analyzed_fields: { - excludes: [], - }, - analysis: { - classification: { - dependent_variable: 'y', - training_percent: 71, - max_trees: 1500, - num_top_feature_importance_values: 4, - }, - }, - model_memory_limit: '400mb', - }; - - expect(isAdvancedConfig(config)).toBe(true); - }); - test('should detect as advanced if the prop is unknown', () => { const config = { description: "Classification clone with 'bank-marketing' dataset", diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx index cfb11856670c4..a1f0448b819d1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx @@ -8,10 +8,12 @@ import { EuiButtonEmpty } from '@elastic/eui'; import React, { FC } from 'react'; import { isEqual, cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { IIndexPattern } from 'src/plugins/data/common'; import { DeepReadonly } from '../../../../../../../common/types/common'; import { DataFrameAnalyticsConfig, isOutlierAnalysis } from '../../../../common'; import { isClassificationAnalysis, isRegressionAnalysis } from '../../../../common/analytics'; import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants'; +import { useMlKibana } from '../../../../../contexts/kibana'; import { CreateAnalyticsFormProps, DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES, @@ -19,6 +21,7 @@ import { import { State } from '../../hooks/use_create_analytics_form/state'; import { DataFrameAnalyticsListRow } from './common'; import { checkPermission } from '../../../../../capabilities/check_capabilities'; +import { extractErrorMessage } from '../../../../../util/error_utils'; interface PropDefinition { /** @@ -74,31 +77,39 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo }, eta: { optional: true, + formKey: 'eta', }, feature_bag_fraction: { optional: true, + formKey: 'featureBagFraction', }, max_trees: { optional: true, + formKey: 'maxTrees', }, gamma: { optional: true, + formKey: 'gamma', }, lambda: { optional: true, + formKey: 'lambda', }, num_top_classes: { optional: true, defaultValue: 2, + formKey: 'numTopClasses', }, prediction_field_name: { optional: true, defaultValue: `${config.analysis.classification.dependent_variable}_prediction`, + formKey: 'predictionFieldName', }, randomize_seed: { optional: true, // By default it is randomly generated ignore: true, + formKey: 'randomizeSeed', }, num_top_feature_importance_values: { optional: true, @@ -118,23 +129,29 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo standardization_enabled: { defaultValue: true, optional: true, + formKey: 'standardizationEnabled', }, compute_feature_influence: { defaultValue: true, optional: true, + formKey: 'computeFeatureInfluence', }, outlier_fraction: { defaultValue: 0.05, optional: true, + formKey: 'outlierFraction', }, feature_influence_threshold: { optional: true, + formKey: 'featureInfluenceThreshold', }, method: { optional: true, + formKey: 'method', }, n_neighbors: { optional: true, + formKey: 'nNeighbors', }, }, } @@ -152,22 +169,28 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo }, eta: { optional: true, + formKey: 'eta', }, feature_bag_fraction: { optional: true, + formKey: 'featureBagFraction', }, max_trees: { optional: true, + formKey: 'maxTrees', }, gamma: { optional: true, + formKey: 'gamma', }, lambda: { optional: true, + formKey: 'lambda', }, prediction_field_name: { optional: true, defaultValue: `${config.analysis.regression.dependent_variable}_prediction`, + formKey: 'predictionFieldName', }, num_top_feature_importance_values: { optional: true, @@ -178,11 +201,15 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo optional: true, // By default it is randomly generated ignore: true, + formKey: 'randomizeSeed', }, loss_function: { optional: true, defaultValue: 'mse', }, + loss_function_parameter: { + optional: true, + }, }, } : {}), @@ -332,9 +359,52 @@ export const CloneAction: FC = ({ createAnalyticsForm, item }) const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { defaultMessage: 'Clone job', }); - const { actions } = createAnalyticsForm; + + const { notifications, savedObjects } = useMlKibana().services; + const savedObjectsClient = savedObjects.client; + const onClick = async () => { - await actions.setJobClone(item.config); + const sourceIndex = Array.isArray(item.config.source.index) + ? item.config.source.index[0] + : item.config.source.index; + let sourceIndexId; + + try { + const response = await savedObjectsClient.find({ + type: 'index-pattern', + perPage: 10, + search: `"${sourceIndex}"`, + searchFields: ['title'], + fields: ['title'], + }); + + const ip = response.savedObjects.find( + (obj) => obj.attributes.title.toLowerCase() === sourceIndex.toLowerCase() + ); + if (ip !== undefined) { + sourceIndexId = ip.id; + } + } catch (e) { + const { toasts } = notifications; + const error = extractErrorMessage(e); + + toasts.addDanger( + i18n.translate( + 'xpack.ml.dataframe.analyticsList.fetchSourceIndexPatternForCloneErrorMessage', + { + defaultMessage: + 'An error occurred checking if index pattern {indexPattern} exists: {error}', + values: { indexPattern: sourceIndex, error }, + } + ) + ); + } + + if (sourceIndexId) { + window.location.href = `ml#/data_frame_analytics/new_job?index=${encodeURIComponent( + sourceIndexId + )}&jobId=${item.config.id}`; + } }; return ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx index 295a3988e1b58..72514c91ff58b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -// import { DeepReadonly } from '../../../../../../../common/types/common'; +import { DeepReadonly } from '../../../../../../../common/types/common'; import { checkPermission, @@ -21,7 +21,7 @@ import { isClassificationAnalysis, } from '../../../../common/analytics'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -// import { CloneAction } from './action_clone'; +import { CloneAction } from './action_clone'; import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; import { stopAnalytics } from '../../services/analytics_service'; @@ -106,10 +106,10 @@ export const getActions = (createAnalyticsForm: CreateAnalyticsFormProps) => { return ; }, }, - // { - // render: (item: DeepReadonly) => { - // return ; - // }, - // }, + { + render: (item: DeepReadonly) => { + return ; + }, + }, ]; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index bb012a2190859..25e3a2808fc61 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -51,7 +51,6 @@ import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CreateAnalyticsFlyoutWrapper } from '../create_analytics_flyout_wrapper'; import { getSelectedJobIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; @@ -286,9 +285,6 @@ export const DataFrameAnalyticsList: FC = ({ } data-test-subj="mlNoDataFrameAnalyticsFound" /> - {!isManagementTable && createAnalyticsForm && ( - - )} {isSourceIndexModalVisible === true && ( setIsSourceIndexModalVisible(false)} /> )} @@ -440,9 +436,6 @@ export const DataFrameAnalyticsList: FC = ({ /> - {!isManagementTable && createAnalyticsForm?.state.isModalVisible && ( - - )} {isSourceIndexModalVisible === true && ( setIsSourceIndexModalVisible(false)} /> )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_create_analytics_flyout.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_create_analytics_flyout.scss deleted file mode 100644 index e6c6ffafc446a..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_create_analytics_flyout.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlAnalyticsCreateFlyout__footerButton { - float: right; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_index.scss deleted file mode 100644 index 668b35f8370d2..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'create_analytics_flyout'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.test.tsx deleted file mode 100644 index dc91c955184b0..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; -import { mountHook } from 'test_utils/enzyme_helpers'; - -import { CreateAnalyticsFlyout } from './create_analytics_flyout'; - -import { MlContext } from '../../../../../contexts/ml'; -import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; - -import { useCreateAnalyticsForm } from '../../hooks/use_create_analytics_form'; - -const getMountedHook = () => - mountHook( - () => useCreateAnalyticsForm(), - ({ children }) => ( - {children} - ) - ); - -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - -describe('Data Frame Analytics: ', () => { - test('Minimal initialization', () => { - const { getLastHookValue } = getMountedHook(); - const props = getLastHookValue(); - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="mlDataFrameAnalyticsFlyoutHeaderTitle"]').text()).toBe( - 'Create analytics job' - ); - }); -}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx deleted file mode 100644 index b0f13e398cc50..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx +++ /dev/null @@ -1,108 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiTitle, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; - -export const CreateAnalyticsFlyout: FC = ({ - actions, - children, - state, -}) => { - const { closeModal, createAnalyticsJob, startAnalyticsJob } = actions; - const { - isJobCreated, - isJobStarted, - isModalButtonDisabled, - isValid, - isAdvancedEditorValidJson, - cloneJob, - } = state; - - const headerText = !!cloneJob - ? i18n.translate('xpack.ml.dataframe.analytics.clone.flyoutHeaderTitle', { - defaultMessage: 'Clone job from {job_id}', - values: { job_id: cloneJob.id }, - }) - : i18n.translate('xpack.ml.dataframe.analytics.create.flyoutHeaderTitle', { - defaultMessage: 'Create analytics job', - }); - - return ( - - - -

{headerText}

-
-
- {children} - - {(!isJobCreated || !isJobStarted) && ( - - {isJobCreated === true - ? i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCloseButton', { - defaultMessage: 'Close', - }) - : i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCancelButton', { - defaultMessage: 'Cancel', - })} - - )} - - {!isJobCreated && !isJobStarted && ( - - {i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCreateButton', { - defaultMessage: 'Create', - })} - - )} - {isJobCreated && !isJobStarted && ( - - {i18n.translate('xpack.ml.dataframe.analytics.create.flyoutStartButton', { - defaultMessage: 'Start', - })} - - )} - {isJobCreated && isJobStarted && ( - - {i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCloseButton', { - defaultMessage: 'Close', - })} - - )} - -
- ); -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/create_analytics_flyout_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/create_analytics_flyout_wrapper.tsx deleted file mode 100644 index 2f3c38b6ffe4e..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout_wrapper/create_analytics_flyout_wrapper.tsx +++ /dev/null @@ -1,28 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; - -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; - -import { CreateAnalyticsAdvancedEditor } from '../create_analytics_advanced_editor'; -import { CreateAnalyticsForm } from '../create_analytics_form'; -import { CreateAnalyticsFlyout } from '../create_analytics_flyout'; - -export const CreateAnalyticsFlyoutWrapper: FC = (props) => { - const { isAdvancedEditorEnabled, isModalVisible } = props.state; - - if (isModalVisible === false) { - return null; - } - - return ( - - {isAdvancedEditorEnabled === false && } - {isAdvancedEditorEnabled === true && } - - ); -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_create_analytics_form.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_create_analytics_form.scss deleted file mode 100644 index 9b4559f9e2cb2..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_create_analytics_form.scss +++ /dev/null @@ -1,31 +0,0 @@ -/* - The job creation form displayed in the modal adapts its height dynamically - if the content changes. If a form element changes to show/hide error messages, - this results in a changing height of the modal. If you type quickly e.g. - in the job ID input field and type chars which are invalid only for example - at the end of the string, this will result in an unwanted height toggling - effect. The following CSS avoids this by 1) delaying the visilibity of the - error message by 500ms and 2) animating the height and opacity to create - a fade-in effect after that so the modal grows smoothly and doesn't - toggle its height. - */ - -@keyframes mlDelayedShow { - 0%, 50% { - max-height: 0; - opacity: 0; - padding: 0; - visibility: hidden; - } - 100% { - max-height: 300px; - opacity: 1; - padding-top: $euiSizeS; - } -} - -.mlDataFrameAnalyticsCreateForm { - .euiFormErrorText { - animation: mlDelayedShow 1s; - } -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_index.scss deleted file mode 100644 index 66fa2c02e60f5..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'create_analytics_form'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx deleted file mode 100644 index 85cd70912b41f..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx +++ /dev/null @@ -1,69 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; -import { mountHook } from 'test_utils/enzyme_helpers'; - -import { CreateAnalyticsForm } from './create_analytics_form'; - -import { MlContext } from '../../../../../contexts/ml'; -import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; - -import { useCreateAnalyticsForm } from '../../hooks/use_create_analytics_form'; - -const getMountedHook = () => - mountHook( - () => useCreateAnalyticsForm(), - ({ children }) => ( - {children} - ) - ); - -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - -jest.mock('../../../../../contexts/kibana', () => ({ - useMlKibana: () => { - return { - services: { - docLinks: () => ({ - ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', - DOC_LINK_VERSION: 'jest-metadata-mock-branch', - }), - }, - }; - }, -})); - -describe('Data Frame Analytics: ', () => { - test('Minimal initialization', () => { - const { getLastHookValue } = getMountedHook(); - const props = getLastHookValue(); - const wrapper = mount( - - - - ); - - const euiFormRows = wrapper.find('EuiFormRow'); - expect(euiFormRows.length).toBe(10); - - const row1 = euiFormRows.at(0); - expect(row1.find('label').text()).toBe('Job type'); - - const options = row1.find('option'); - expect(options.at(0).props().value).toBe(''); - expect(options.at(1).props().value).toBe('outlier_detection'); - expect(options.at(2).props().value).toBe('regression'); - - const row2 = euiFormRows.at(1); - expect(row2.find('EuiSwitch').text()).toBe('Enable advanced editor'); - }); -}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx deleted file mode 100644 index 64fe736e67b17..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx +++ /dev/null @@ -1,850 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FC, useEffect, useMemo, useRef } from 'react'; - -import { - EuiComboBox, - EuiComboBoxOptionOption, - EuiForm, - EuiFieldNumber, - EuiFieldText, - EuiFormRow, - EuiLink, - EuiRange, - EuiSwitch, -} from '@elastic/eui'; -import { debounce } from 'lodash'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { useMlKibana } from '../../../../../contexts/kibana'; -import { ml } from '../../../../../services/ml_api_service'; -import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; -import { useMlContext } from '../../../../../contexts/ml'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { - DEFAULT_MODEL_MEMORY_LIMIT, - getJobConfigFromFormState, - State, -} from '../../hooks/use_create_analytics_form/state'; -import { JOB_ID_MAX_LENGTH } from '../../../../../../../common/constants/validation'; -import { Messages } from './messages'; -import { JobType } from './job_type'; -import { JobDescriptionInput } from './job_description'; -import { getModelMemoryLimitErrors } from '../../hooks/use_create_analytics_form/reducer'; -import { IndexPattern, indexPatterns } from '../../../../../../../../../../src/plugins/data/public'; -import { - ANALYSIS_CONFIG_TYPE, - DfAnalyticsExplainResponse, - FieldSelectionItem, - NUM_TOP_FEATURE_IMPORTANCE_VALUES_MIN, - TRAINING_PERCENT_MIN, - TRAINING_PERCENT_MAX, -} from '../../../../common/analytics'; -import { shouldAddAsDepVarOption, OMIT_FIELDS } from './form_options_validation'; - -const requiredFieldsErrorText = i18n.translate( - 'xpack.ml.dataframe.analytics.create.requiredFieldsErrorMessage', - { - defaultMessage: 'At least one field must be included in the analysis.', - } -); - -export const CreateAnalyticsForm: FC = ({ actions, state }) => { - const { - services: { docLinks }, - } = useMlKibana(); - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; - const { setFormState, setEstimatedModelMemoryLimit } = actions; - const mlContext = useMlContext(); - const { - estimatedModelMemoryLimit, - form, - indexPatternsMap, - isAdvancedEditorEnabled, - isJobCreated, - requestMessages, - } = state; - - const forceInput = useRef(null); - const firstUpdate = useRef(true); - - const { - createIndexPattern, - dependentVariable, - dependentVariableFetchFail, - dependentVariableOptions, - description, - destinationIndex, - destinationIndexNameEmpty, - destinationIndexNameExists, - destinationIndexNameValid, - destinationIndexPatternTitleExists, - excludes, - excludesOptions, - fieldOptionsFetchFail, - jobId, - jobIdEmpty, - jobIdExists, - jobIdValid, - jobIdInvalidMaxLength, - jobType, - loadingDepVarOptions, - loadingFieldOptions, - maxDistinctValuesError, - modelMemoryLimit, - modelMemoryLimitValidationResult, - numTopFeatureImportanceValues, - numTopFeatureImportanceValuesValid, - previousJobType, - previousSourceIndex, - requiredFieldsError, - sourceIndex, - sourceIndexNameEmpty, - sourceIndexNameValid, - sourceIndexContainsNumericalFields, - sourceIndexFieldsCheckFailed, - trainingPercent, - } = form; - const characterList = indexPatterns.ILLEGAL_CHARACTERS_VISIBLE.join(', '); - - const mmlErrors = useMemo(() => getModelMemoryLimitErrors(modelMemoryLimitValidationResult), [ - modelMemoryLimitValidationResult, - ]); - - const isJobTypeWithDepVar = - jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; - - // Find out if index pattern contain numeric fields. Provides a hint in the form - // that an analytics jobs is not able to identify outliers if there are no numeric fields present. - const validateSourceIndexFields = async () => { - try { - const indexPattern: IndexPattern = await mlContext.indexPatterns.get( - indexPatternsMap[sourceIndex].value - ); - const containsNumericalFields: boolean = indexPattern.fields.some( - ({ name, type }) => !OMIT_FIELDS.includes(name) && type === 'number' - ); - - setFormState({ - sourceIndexContainsNumericalFields: containsNumericalFields, - sourceIndexFieldsCheckFailed: false, - }); - } catch (e) { - setFormState({ - sourceIndexFieldsCheckFailed: true, - }); - } - }; - - const onCreateOption = (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[]) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); - - if (!normalizedSearchValue) { - return; - } - - const newOption = { - label: searchValue, - }; - - // Create the option if it doesn't exist. - if ( - !flattenedOptions.some( - (option: EuiComboBoxOptionOption) => - option.label.trim().toLowerCase() === normalizedSearchValue - ) - ) { - excludesOptions.push(newOption); - setFormState({ excludes: [...excludes, newOption.label] }); - } - }; - - const debouncedGetExplainData = debounce(async () => { - const jobTypeOrIndexChanged = - previousSourceIndex !== sourceIndex || previousJobType !== jobType; - const shouldUpdateModelMemoryLimit = !firstUpdate.current || !modelMemoryLimit; - const shouldUpdateEstimatedMml = - !firstUpdate.current || !modelMemoryLimit || estimatedModelMemoryLimit === ''; - - if (firstUpdate.current) { - firstUpdate.current = false; - } - // Reset if sourceIndex or jobType changes (jobType requires dependent_variable to be set - - // which won't be the case if switching from outlier detection) - if (jobTypeOrIndexChanged) { - setFormState({ - loadingFieldOptions: true, - }); - } - - try { - const jobConfig = getJobConfigFromFormState(form); - delete jobConfig.dest; - delete jobConfig.model_memory_limit; - const resp: DfAnalyticsExplainResponse = await ml.dataFrameAnalytics.explainDataFrameAnalytics( - jobConfig - ); - const expectedMemoryWithoutDisk = resp.memory_estimation?.expected_memory_without_disk; - - if (shouldUpdateEstimatedMml) { - setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); - } - - const fieldSelection: FieldSelectionItem[] | undefined = resp.field_selection; - - let hasRequiredFields = false; - if (fieldSelection) { - for (let i = 0; i < fieldSelection.length; i++) { - const field = fieldSelection[i]; - if (field.is_included === true && field.is_required === false) { - hasRequiredFields = true; - break; - } - } - } - - // If sourceIndex has changed load analysis field options again - if (jobTypeOrIndexChanged) { - const analyzedFieldsOptions: EuiComboBoxOptionOption[] = []; - - if (resp.field_selection) { - resp.field_selection.forEach((selectedField: FieldSelectionItem) => { - if (selectedField.is_included === true && selectedField.name !== dependentVariable) { - analyzedFieldsOptions.push({ label: selectedField.name }); - } - }); - } - - setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), - excludesOptions: analyzedFieldsOptions, - loadingFieldOptions: false, - fieldOptionsFetchFail: false, - maxDistinctValuesError: undefined, - requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, - }); - } else { - setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), - requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, - }); - } - } catch (e) { - let errorMessage; - if ( - jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - e.body && - e.body.message !== undefined && - e.body.message.includes('status_exception') && - e.body.message.includes('must have at most') - ) { - errorMessage = e.body.message; - } - const fallbackModelMemoryLimit = - jobType !== undefined - ? DEFAULT_MODEL_MEMORY_LIMIT[jobType] - : DEFAULT_MODEL_MEMORY_LIMIT.outlier_detection; - setEstimatedModelMemoryLimit(fallbackModelMemoryLimit); - setFormState({ - fieldOptionsFetchFail: true, - maxDistinctValuesError: errorMessage, - loadingFieldOptions: false, - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), - }); - } - }, 400); - - const loadDepVarOptions = async (formState: State['form']) => { - setFormState({ - loadingDepVarOptions: true, - // clear when the source index changes - maxDistinctValuesError: undefined, - sourceIndexFieldsCheckFailed: false, - sourceIndexContainsNumericalFields: true, - }); - try { - const indexPattern: IndexPattern = await mlContext.indexPatterns.get( - indexPatternsMap[sourceIndex].value - ); - - if (indexPattern !== undefined) { - const formStateUpdate: { - loadingDepVarOptions: boolean; - dependentVariableFetchFail: boolean; - dependentVariableOptions: State['form']['dependentVariableOptions']; - dependentVariable?: State['form']['dependentVariable']; - } = { - loadingDepVarOptions: false, - dependentVariableFetchFail: false, - dependentVariableOptions: [] as State['form']['dependentVariableOptions'], - }; - - await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); - // Get fields and filter for supported types for job type - const { fields } = newJobCapsService; - - let resetDependentVariable = true; - for (const field of fields) { - if (shouldAddAsDepVarOption(field, jobType)) { - formStateUpdate.dependentVariableOptions.push({ - label: field.id, - }); - - if (formState.dependentVariable === field.id) { - resetDependentVariable = false; - } - } - } - - if (resetDependentVariable) { - formStateUpdate.dependentVariable = ''; - } - - setFormState(formStateUpdate); - } - } catch (e) { - setFormState({ loadingDepVarOptions: false, dependentVariableFetchFail: true }); - } - }; - - const getSourceIndexErrorMessages = () => { - const errors = []; - if (!sourceIndexNameEmpty && !sourceIndexNameValid) { - errors.push( - - - - ); - } - - if (sourceIndexFieldsCheckFailed === true) { - errors.push( - - - - ); - } - - return errors; - }; - - const onSourceIndexChange = (selectedOptions: EuiComboBoxOptionOption[]) => { - setFormState({ - excludes: [], - excludesOptions: [], - previousSourceIndex: sourceIndex, - sourceIndex: selectedOptions[0].label || '', - requiredFieldsError: undefined, - }); - }; - - useEffect(() => { - if (isJobTypeWithDepVar && sourceIndexNameEmpty === false) { - loadDepVarOptions(form); - } - - if (jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && sourceIndexNameEmpty === false) { - validateSourceIndexFields(); - } - }, [sourceIndex, jobType, sourceIndexNameEmpty]); - - useEffect(() => { - const hasBasicRequiredFields = - jobType !== undefined && sourceIndex !== '' && sourceIndexNameValid === true; - - const hasRequiredAnalysisFields = - (isJobTypeWithDepVar && dependentVariable !== '') || - jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; - - if (hasBasicRequiredFields && hasRequiredAnalysisFields) { - debouncedGetExplainData(); - } - - return () => { - debouncedGetExplainData.cancel(); - }; - }, [ - jobType, - sourceIndex, - sourceIndexNameEmpty, - dependentVariable, - trainingPercent, - JSON.stringify(excludes), - ]); - - // Temp effect to close the context menu popover on Clone button click - useEffect(() => { - if (forceInput.current === null) { - return; - } - const evt = document.createEvent('MouseEvents'); - evt.initEvent('mouseup', true, true); - forceInput.current.dispatchEvent(evt); - }, []); - - const noSupportetdAnalysisFields = - excludesOptions.length === 0 && fieldOptionsFetchFail === false && !sourceIndexNameEmpty; - - return ( - - - {!isJobCreated && ( - - - - - - - { - if (input) { - forceInput.current = input; - } - }} - disabled={isJobCreated} - placeholder={i18n.translate('xpack.ml.dataframe.analytics.create.jobIdPlaceholder', { - defaultMessage: 'Job ID', - })} - value={jobId} - onChange={(e) => setFormState({ jobId: e.target.value })} - aria-label={i18n.translate( - 'xpack.ml.dataframe.analytics.create.jobIdInputAriaLabel', - { - defaultMessage: 'Choose a unique analytics job ID.', - } - )} - isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists} - data-test-subj="mlAnalyticsCreateJobFlyoutJobIdInput" - /> - - - - - {!isJobCreated && ( - - a.label.localeCompare(b.label) - )} - selectedOptions={ - indexPatternsMap[sourceIndex] !== undefined ? [{ label: sourceIndex }] : [] - } - onChange={onSourceIndexChange} - isClearable={false} - data-test-subj="mlAnalyticsCreateJobFlyoutSourceIndexSelect" - /> - )} - {isJobCreated && ( - - )} - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.destinationIndexInvalidError', - { - defaultMessage: 'Invalid destination index name.', - } - )} -
- - {i18n.translate( - 'xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink', - { - defaultMessage: 'Learn more about index name limitations.', - } - )} - -
, - ] - } - > - setFormState({ destinationIndex: e.target.value })} - aria-label={i18n.translate( - 'xpack.ml.dataframe.analytics.create.destinationIndexInputAriaLabel', - { - defaultMessage: 'Choose a unique destination index name.', - } - )} - isInvalid={!destinationIndexNameEmpty && !destinationIndexNameValid} - data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput" - /> - - {(jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || - jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) && ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.dependentVariableMaxDistictValuesError', - { - defaultMessage: 'Invalid. {message}', - values: { message: maxDistinctValuesError }, - } - )} - , - ] - : []), - ]} - > - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.dependentVariableOptionsFetchError', - { - defaultMessage: - 'There was a problem fetching fields. Please refresh the page and try again.', - } - )} - , - ] - : []), - ]} - > - - setFormState({ - dependentVariable: selectedOptions[0].label || '', - }) - } - isClearable={false} - isInvalid={dependentVariable === ''} - data-test-subj="mlAnalyticsCreateJobFlyoutDependentVariableSelect" - /> - - - setFormState({ trainingPercent: +e.target.value })} - data-test-subj="mlAnalyticsCreateJobFlyoutTrainingPercentSlider" - /> - - {/* num_top_feature_importance_values */} - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.numTopFeatureImportanceValuesErrorText', - { - defaultMessage: - 'Invalid maximum number of feature importance values.', - } - )} - , - ] - : []), - ]} - > - setFormState({ numTopFeatureImportanceValues: +e.target.value })} - step={1} - value={numTopFeatureImportanceValues} - /> - - - )} - - - - - ({ - label: field, - }))} - onCreateOption={onCreateOption} - onChange={(selectedOptions) => - setFormState({ excludes: selectedOptions.map((option) => option.label) }) - } - isClearable={true} - data-test-subj="mlAnalyticsCreateJobFlyoutExcludesSelect" - /> - - - setFormState({ modelMemoryLimit: e.target.value })} - isInvalid={modelMemoryLimitValidationResult !== null} - data-test-subj="mlAnalyticsCreateJobFlyoutModelMemoryInput" - /> - - - setFormState({ createIndexPattern: !createIndexPattern })} - data-test-subj="mlAnalyticsCreateJobFlyoutCreateIndexPatternSwitch" - /> - - - )} -
- ); -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_description.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_description.tsx deleted file mode 100644 index 46301a6f832e7..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_description.tsx +++ /dev/null @@ -1,37 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; -import { EuiFormRow, EuiTextArea } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const helpText = i18n.translate('xpack.ml.dataframe.analytics.create.jobDescription.helpText', { - defaultMessage: 'Optional descriptive text', -}); - -interface Props { - description: string; - setFormState: React.Dispatch>; -} - -export const JobDescriptionInput: FC = ({ description, setFormState }) => ( - - { - const value = e.target.value; - setFormState({ description: value }); - }} - data-test-subj="mlDFAnalyticsJobCreationJobDescription" - /> - -); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx deleted file mode 100644 index 6daa72dd805b1..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx +++ /dev/null @@ -1,80 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FC } from 'react'; -import { i18n } from '@kbn/i18n'; - -import { EuiFormRow, EuiSelect } from '@elastic/eui'; -import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; - -import { AnalyticsJobType } from '../../hooks/use_create_analytics_form/state'; - -interface Props { - type: AnalyticsJobType; - setFormState: React.Dispatch>; -} - -export const JobType: FC = ({ type, setFormState }) => { - const outlierHelpText = i18n.translate( - 'xpack.ml.dataframe.analytics.create.outlierDetectionHelpText', - { - defaultMessage: - 'Outlier detection jobs require a source index that is mapped as a table-like data structure and analyze only numeric and boolean fields. Use the advanced editor to add custom options to the configuration.', - } - ); - - const regressionHelpText = i18n.translate( - 'xpack.ml.dataframe.analytics.create.outlierRegressionHelpText', - { - defaultMessage: - 'Regression jobs analyze only numeric fields. Use the advanced editor to apply custom options, such as the prediction field name.', - } - ); - - const classificationHelpText = i18n.translate( - 'xpack.ml.dataframe.analytics.create.classificationHelpText', - { - defaultMessage: - 'Classification jobs require a source index that is mapped as a table-like data structure and support fields that are numeric, boolean, text, keyword, or ip. Use the advanced editor to apply custom options, such as the prediction field name.', - } - ); - - const helpText = { - [ANALYSIS_CONFIG_TYPE.REGRESSION]: regressionHelpText, - [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: outlierHelpText, - [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: classificationHelpText, - }; - - return ( - - - ({ - value: jobType, - text: jobType.replace(/_/g, ' '), - }))} - value={type} - hasNoInitialSelection={true} - onChange={(e) => { - const value = e.target.value as AnalyticsJobType; - setFormState({ - previousJobType: type, - jobType: value, - excludes: [], - requiredFieldsError: undefined, - }); - }} - data-test-subj="mlAnalyticsCreateJobFlyoutJobTypeSelect" - /> - - - ); -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts index c42e03b584a56..a9eedbb2bc5e3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts @@ -57,11 +57,6 @@ export type Action = } | { type: ACTION.SET_IS_JOB_CREATED; isJobCreated: State['isJobCreated'] } | { type: ACTION.SET_IS_JOB_STARTED; isJobStarted: State['isJobStarted'] } - | { - type: ACTION.SET_IS_MODAL_BUTTON_DISABLED; - isModalButtonDisabled: State['isModalButtonDisabled']; - } - | { type: ACTION.SET_IS_MODAL_VISIBLE; isModalVisible: State['isModalVisible'] } | { type: ACTION.SET_JOB_CONFIG; payload: State['jobConfig'] } | { type: ACTION.SET_JOB_IDS; jobIds: State['jobIds'] } | { type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT; value: State['estimatedModelMemoryLimit'] } @@ -71,12 +66,10 @@ export type Action = export interface ActionDispatchers { closeModal: () => void; createAnalyticsJob: () => void; - openModal: () => Promise; initiateWizard: () => Promise; resetAdvancedEditorMessages: () => void; setAdvancedEditorRawString: (payload: State['advancedEditorRawString']) => void; setFormState: (payload: Partial) => void; - setIsModalVisible: (payload: State['isModalVisible']) => void; setJobConfig: (payload: State['jobConfig']) => void; startAnalyticsJob: () => void; switchToAdvancedEditor: () => void; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts index fc604c9f5eb0b..e6769a7b64e2b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts @@ -79,21 +79,6 @@ describe('useCreateAnalyticsForm', () => { expect(resettedState).toEqual(initialState); }); - test('reducer(): open/close the modal', () => { - const initialState = getInitialState(); - expect(initialState.isModalVisible).toBe(false); - - const openModalState = reducer(initialState, { - type: ACTION.OPEN_MODAL, - }); - expect(openModalState.isModalVisible).toBe(true); - - const closedModalState = reducer(openModalState, { - type: ACTION.CLOSE_MODAL, - }); - expect(closedModalState.isModalVisible).toBe(false); - }); - test('reducer(): add/reset request messages', () => { const initialState = getInitialState(); expect(initialState.requestMessages).toHaveLength(0); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index a79a8fcf61ed4..1353a35d8ecc6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -442,12 +442,6 @@ export function reducer(state: State, action: Action): State { case ACTION.RESET_REQUEST_MESSAGES: return { ...state, requestMessages: [] }; - case ACTION.CLOSE_MODAL: - return { ...state, isModalVisible: false }; - - case ACTION.OPEN_MODAL: - return { ...state, isModalVisible: true }; - case ACTION.RESET_ADVANCED_EDITOR_MESSAGES: return { ...state, advancedEditorMessages: [] }; @@ -536,12 +530,6 @@ export function reducer(state: State, action: Action): State { case ACTION.SET_IS_JOB_STARTED: return { ...state, isJobStarted: action.isJobStarted }; - case ACTION.SET_IS_MODAL_BUTTON_DISABLED: - return { ...state, isModalButtonDisabled: action.isModalButtonDisabled }; - - case ACTION.SET_IS_MODAL_VISIBLE: - return { ...state, isModalVisible: action.isModalVisible }; - case ACTION.SET_JOB_CONFIG: return validateAdvancedEditor({ ...state, jobConfig: action.payload }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts index 547a55da7438b..b9a9caadcebd0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts @@ -4,7 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getInitialState, getJobConfigFromFormState } from './state'; +import { + getCloneFormStateFromJobConfig, + getInitialState, + getJobConfigFromFormState, +} from './state'; + +const regJobConfig = { + id: 'reg-test-01', + description: 'Reg test job description', + source: { + index: ['reg-test-index'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'reg-test-01-index', + results_field: 'ml', + }, + analysis: { + regression: { + dependent_variable: 'price', + num_top_feature_importance_values: 2, + prediction_field_name: 'airbnb_test', + training_percent: 5, + randomize_seed: 4998776294664380000, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '22mb', + create_time: 1590514291395, + version: '8.0.0', + allow_lazy_start: false, +}; describe('useCreateAnalyticsForm', () => { test('state: getJobConfigFromFormState()', () => { @@ -28,4 +64,20 @@ describe('useCreateAnalyticsForm', () => { 'the-source-index-2', ]); }); + + test('state: getCloneFormStateFromJobConfig()', () => { + const clonedState = getCloneFormStateFromJobConfig(regJobConfig); + + expect(clonedState?.sourceIndex).toBe('reg-test-index'); + expect(clonedState?.excludes).toStrictEqual([]); + expect(clonedState?.dependentVariable).toBe('price'); + expect(clonedState?.numTopFeatureImportanceValues).toBe(2); + expect(clonedState?.predictionFieldName).toBe('airbnb_test'); + expect(clonedState?.trainingPercent).toBe(5); + expect(clonedState?.randomizeSeed).toBe(4998776294664380000); + expect(clonedState?.modelMemoryLimit).toBe('22mb'); + // destination index and job id should be undefined + expect(clonedState?.destinationIndex).toBe(undefined); + expect(clonedState?.jobId).toBe(undefined); + }); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 387ce89ee4120..8a07704e39910 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -92,7 +92,6 @@ export interface State { outlierFraction: undefined | number; predictionFieldName: undefined | string; previousJobType: null | AnalyticsJobType; - previousSourceIndex: EsIndexName | undefined; requiredFieldsError: string | undefined; randomizeSeed: undefined | number; sourceIndex: EsIndexName; @@ -110,8 +109,6 @@ export interface State { isAdvancedEditorValidJson: boolean; isJobCreated: boolean; isJobStarted: boolean; - isModalButtonDisabled: boolean; - isModalVisible: boolean; isValid: boolean; jobConfig: DeepPartial; jobIds: DataFrameAnalyticsId[]; @@ -167,7 +164,6 @@ export const getInitialState = (): State => ({ outlierFraction: undefined, predictionFieldName: undefined, previousJobType: null, - previousSourceIndex: undefined, requiredFieldsError: undefined, randomizeSeed: undefined, sourceIndex: '', @@ -189,8 +185,6 @@ export const getInitialState = (): State => ({ isAdvancedEditorValidJson: true, isJobCreated: false, isJobStarted: false, - isModalVisible: false, - isModalButtonDisabled: false, isValid: false, jobIds: [], requestMessages: [], @@ -328,6 +322,14 @@ export const getJobConfigFromFormState = ( return jobConfig; }; +function toCamelCase(property: string): string { + const camelCased = property.replace(/_([a-z])/g, function (g) { + return g[1].toUpperCase(); + }); + + return camelCased; +} + /** * Extracts form state for a job clone from the analytics job configuration. * For cloning we keep job id and destination index empty. @@ -353,13 +355,12 @@ export function getCloneFormStateFromJobConfig( ) { const analysisConfig = analyticsJobConfig.analysis[jobType]; - resultState.dependentVariable = analysisConfig.dependent_variable; - resultState.numTopFeatureImportanceValues = analysisConfig.num_top_feature_importance_values; - resultState.trainingPercent = analysisConfig.training_percent; - - if (isClassificationAnalysis(analyticsJobConfig.analysis)) { - // @ts-ignore - resultState.numTopClasses = analysisConfig.num_top_classes; + for (const key in analysisConfig) { + if (analysisConfig.hasOwnProperty(key)) { + const camelCased = toCamelCase(key); + // @ts-ignore + resultState[camelCased] = analysisConfig[key]; + } } } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx index 182e50a5d74d1..ac1c710e1d106 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx @@ -46,39 +46,13 @@ describe('getErrorMessage()', () => { describe('useCreateAnalyticsForm()', () => { test('initialization', () => { const { getLastHookValue } = getMountedHook(); - const { state, actions } = getLastHookValue(); + const { actions } = getLastHookValue(); - expect(state.isModalVisible).toBe(false); - expect(typeof actions.closeModal).toBe('function'); expect(typeof actions.createAnalyticsJob).toBe('function'); - expect(typeof actions.openModal).toBe('function'); expect(typeof actions.startAnalyticsJob).toBe('function'); expect(typeof actions.setFormState).toBe('function'); }); - test('open/close modal', () => { - const { act, getLastHookValue } = getMountedHook(); - const { state, actions } = getLastHookValue(); - - expect(state.isModalVisible).toBe(false); - - act(() => { - // this should be actions.openModal(), but that doesn't work yet because act() doesn't support async yet. - // we need to wait for an update to React 16.9 - actions.setIsModalVisible(true); - }); - const { state: stateModalOpen } = getLastHookValue(); - expect(stateModalOpen.isModalVisible).toBe(true); - - act(() => { - // this should be actions.closeModal(), but that doesn't work yet because act() doesn't support async yet. - // we need to wait for an update to React 16.9 - actions.setIsModalVisible(false); - }); - const { state: stateModalClosed } = getLastHookValue(); - expect(stateModalClosed.isModalVisible).toBe(false); - }); - // TODO // add tests for createAnalyticsJob() and startAnalyticsJob() // once React 16.9 with support for async act() is available. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index c4cbe149f88bc..2de9a1dcadd4b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -87,12 +87,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SET_IS_JOB_STARTED, isJobStarted }); }; - const setIsModalButtonDisabled = (isModalButtonDisabled: boolean) => - dispatch({ type: ACTION.SET_IS_MODAL_BUTTON_DISABLED, isModalButtonDisabled }); - - const setIsModalVisible = (isModalVisible: boolean) => - dispatch({ type: ACTION.SET_IS_MODAL_VISIBLE, isModalVisible }); - const setJobIds = (jobIds: DataFrameAnalyticsId[]) => dispatch({ type: ACTION.SET_JOB_IDS, jobIds }); @@ -102,7 +96,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const createAnalyticsJob = async () => { resetRequestMessages(); - setIsModalButtonDisabled(true); const analyticsJobConfig = (isAdvancedEditorEnabled ? jobConfig @@ -123,7 +116,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } ), }); - setIsModalButtonDisabled(false); setIsJobCreated(true); if (createIndexPattern) { createKibanaIndexPattern(); @@ -139,7 +131,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } ), }); - setIsModalButtonDisabled(false); } }; @@ -267,13 +258,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } }; - const openModal = async () => { - await mlContext.indexPatterns.clearCache(); - resetForm(); - await prepareFormValidation(); - dispatch({ type: ACTION.OPEN_MODAL }); - }; - const initiateWizard = async () => { await mlContext.indexPatterns.clearCache(); await prepareFormValidation(); @@ -327,8 +311,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const setJobClone = async (cloneJob: DeepReadonly) => { resetForm(); - await prepareFormValidation(); - const config = extractCloningConfig(cloneJob); if (isAdvancedConfig(config)) { setJobConfig(config); @@ -339,18 +321,15 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } dispatch({ type: ACTION.SET_JOB_CLONE, cloneJob }); - dispatch({ type: ACTION.OPEN_MODAL }); }; const actions: ActionDispatchers = { closeModal, createAnalyticsJob, - openModal, initiateWizard, resetAdvancedEditorMessages, setAdvancedEditorRawString, setFormState, - setIsModalVisible, setJobConfig, startAnalyticsJob, switchToAdvancedEditor, diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx index 68af9a2a49cab..ebc7bd95fb0c3 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx @@ -30,12 +30,15 @@ export const analyticsJobsCreationRoute: MlRoute = { }; const PageWrapper: FC = ({ location, deps }) => { - const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { index, jobId, savedSearchId }: Record = parse(location.search, { + sort: false, + }); + const { context } = useResolver(index, savedSearchId, deps.config, basicResolvers(deps)); return ( - + ); }; diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json index 4c0186023b458..11d4f8e0b97df 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/manifest.json @@ -7,7 +7,7 @@ "defaultIndexPattern": "kibana_sample_data_ecommerce", "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_ecommerce" } }] + "filter": [{ "term": { "event.dataset": "sample_ecommerce" } }] } }, "jobs": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/datafeed_high_sum_total_sales.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/datafeed_high_sum_total_sales.json index 0a955a766bd53..d61e6ba22ec2f 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/datafeed_high_sum_total_sales.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/datafeed_high_sum_total_sales.json @@ -3,7 +3,7 @@ "indices": ["INDEX_PATTERN_NAME"], "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_ecommerce" } }] + "filter": [{ "term": { "event.dataset": "sample_ecommerce" } }] } } } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json index a0b47e7135312..446f56a717e11 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/manifest.json @@ -7,7 +7,7 @@ "defaultIndexPattern": "kibana_sample_data_logs", "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_logs" } }] + "filter": [{ "term": { "event.dataset": "sample_web_logs" } }] } }, "jobs": [ diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_low_request_rate.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_low_request_rate.json index 843a7d1651dc8..a9865183320d5 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_low_request_rate.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_low_request_rate.json @@ -3,7 +3,7 @@ "indices": ["INDEX_PATTERN_NAME"], "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_logs" } }] + "filter": [{ "term": { "event.dataset": "sample_web_logs" } }] } }, "aggregations": { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_response_code_rates.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_response_code_rates.json index 3a0f67daa392a..9aef3386cb9ef 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_response_code_rates.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_response_code_rates.json @@ -3,7 +3,7 @@ "indices": ["INDEX_PATTERN_NAME"], "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_logs" } }] + "filter": [{ "term": { "event.dataset": "sample_web_logs" } }] } } } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_url_scanning.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_url_scanning.json index 3a0f67daa392a..9aef3386cb9ef 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_url_scanning.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_weblogs/ml/datafeed_url_scanning.json @@ -3,7 +3,7 @@ "indices": ["INDEX_PATTERN_NAME"], "query": { "bool": { - "filter": [{ "term": { "_index": "kibana_sample_data_logs" } }] + "filter": [{ "term": { "event.dataset": "sample_web_logs" } }] } } } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d04d1f2c91b97..7a2b531346ac3 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -143,3 +143,9 @@ export const showAllOthersBucket: string[] = [ 'destination.ip', 'user.name', ]; + +/** + * CreateTemplateTimelineBtn + * Remove the comment here to enable template timeline + */ +export const disableTemplate = true; diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx index 43792e8bd19f4..0e527bf4dfc72 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx @@ -5,6 +5,7 @@ */ /* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable complexity */ import { EuiButton, @@ -70,10 +71,13 @@ import { FailureHistory } from './failure_history'; import { RuleStatus } from '../../../../components/rules//rule_status'; import { useMlCapabilities } from '../../../../../common/components/ml_popover/hooks/use_ml_capabilities'; import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; +import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; +import { ExceptionListType } from '../../../../../common/components/exceptions/types'; enum RuleDetailTabs { alerts = 'alerts', failures = 'failures', + exceptions = 'exceptions', } const ruleDetailTabs = [ @@ -82,6 +86,11 @@ const ruleDetailTabs = [ name: detectionI18n.ALERT, disabled: false, }, + { + id: RuleDetailTabs.exceptions, + name: i18n.EXCEPTIONS_TAB, + disabled: false, + }, { id: RuleDetailTabs.failures, name: i18n.FAILURE_HISTORY_TAB, @@ -387,6 +396,17 @@ export const RuleDetailsPageComponent: FC = ({ )} )} + {ruleDetailTab === RuleDetailTabs.exceptions && ( + + )} {ruleDetailTab === RuleDetailTabs.failures && } diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/translations.ts b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/translations.ts index 9cf510f4a9b5d..94dfdc3e9daa0 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/translations.ts +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/translations.ts @@ -89,3 +89,10 @@ export const TYPE_FAILED = i18n.translate( defaultMessage: 'Failed', } ); + +export const EXCEPTIONS_TAB = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.exceptionsTab', + { + defaultMessage: 'Exceptions', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index a57fae8081bea..a830b299d655b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -8,6 +8,7 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; @@ -18,6 +19,12 @@ import { Form, useForm, UseField } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; +import { + dispatchUpdateTimeline, + queryTimelineById, +} from '../../../timelines/components/open_timeline/helpers'; +import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; +import { useApolloClient } from '../../../common/utils/apollo_context'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; @@ -46,6 +53,8 @@ export const AddComment = React.memo( options: { stripEmptyFields: false }, schema, }); + const dispatch = useDispatch(); + const apolloClient = useApolloClient(); const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'comment' @@ -62,6 +71,28 @@ export const AddComment = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [insertQuote]); + const handleTimelineClick = useCallback( + (timelineId: string) => { + queryTimelineById({ + apolloClient, + timelineId, + updateIsLoading: ({ + id: currentTimelineId, + isLoading: isLoadingTimeline, + }: { + id: string; + isLoading: boolean; + }) => + dispatch( + dispatchUpdateIsLoading({ id: currentTimelineId, isLoading: isLoadingTimeline }) + ), + updateTimeline: dispatchUpdateTimeline(dispatch), + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [apolloClient] + ); + const onSubmit = useCallback(async () => { const { isValid, data } = await form.submit(); if (isValid) { @@ -86,6 +117,7 @@ export const AddComment = React.memo( dataTestSubj: 'add-comment', placeholder: i18n.ADD_COMMENT_HELP_TEXT, onCursorPositionUpdate: handleCursorChange, + onClickTimeline: handleTimelineClick, bottomRightContent: ( export const getCasesColumns = ( actions: Array>, - filterStatus: string -): CasesColumns[] => [ - { - name: i18n.NAME, - render: (theCase: Case) => { - if (theCase.id != null && theCase.title != null) { - const caseDetailsLinkComponent = ( - - {theCase.title} - - ); - return theCase.status === 'open' ? ( - caseDetailsLinkComponent - ) : ( - <> - + filterStatus: string, + isModal: boolean +): CasesColumns[] => { + const columns = [ + { + name: i18n.NAME, + render: (theCase: Case) => { + if (theCase.id != null && theCase.title != null) { + const caseDetailsLinkComponent = !isModal ? ( + + {theCase.title} + + ) : ( + {theCase.title} + ); + return theCase.status === 'open' ? ( + caseDetailsLinkComponent + ) : ( + <> {caseDetailsLinkComponent} - {i18n.CLOSED} - - - ); - } - return getEmptyTagValue(); + + {i18n.CLOSED} + + + ); + } + return getEmptyTagValue(); + }, }, - }, - { - field: 'createdBy', - name: i18n.REPORTER, - render: (createdBy: Case['createdBy']) => { - if (createdBy != null) { - return ( - <> - - - {createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''} - - - ); - } - return getEmptyTagValue(); + { + field: 'createdBy', + name: i18n.REPORTER, + render: (createdBy: Case['createdBy']) => { + if (createdBy != null) { + return ( + <> + + + {createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''} + + + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + return ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + } + return getEmptyTagValue(); + }, + truncateText: true, }, - }, - { - field: 'tags', - name: i18n.TAGS, - render: (tags: Case['tags']) => { - if (tags != null && tags.length > 0) { - return ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); - } - return getEmptyTagValue(); + { + align: 'right' as HorizontalAlignment, + field: 'totalComment', + name: i18n.COMMENTS, + sortable: true, + render: (totalComment: Case['totalComment']) => + totalComment != null + ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) + : getEmptyTagValue(), }, - truncateText: true, - }, - { - align: 'right', - field: 'totalComment', - name: i18n.COMMENTS, - sortable: true, - render: (totalComment: Case['totalComment']) => - totalComment != null - ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) - : getEmptyTagValue(), - }, - filterStatus === 'open' - ? { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - - - ); - } - return getEmptyTagValue(); - }, - } - : { - field: 'closedAt', - name: i18n.CLOSED_ON, - sortable: true, - render: (closedAt: Case['closedAt']) => { - if (closedAt != null) { - return ( - - - - ); - } - return getEmptyTagValue(); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + + + ); + } + return getEmptyTagValue(); + }, + } + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + + + ); + } + return getEmptyTagValue(); + }, }, + { + name: i18n.EXTERNAL_INCIDENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ; + } + return getEmptyTagValue(); }, - { - name: i18n.EXTERNAL_INCIDENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ; - } - return getEmptyTagValue(); }, - }, - { - name: i18n.INCIDENT_MANAGEMENT_SYSTEM, - render: (theCase: Case) => { - if (theCase.externalService != null) { - return renderStringField( - `${theCase.externalService.connectorName}`, - `case-table-column-connector` - ); - } - return getEmptyTagValue(); + { + name: i18n.INCIDENT_MANAGEMENT_SYSTEM, + render: (theCase: Case) => { + if (theCase.externalService != null) { + return renderStringField( + `${theCase.externalService.connectorName}`, + `case-table-column-connector` + ); + } + return getEmptyTagValue(); + }, + }, + { + name: i18n.ACTIONS, + actions, }, - }, - { - name: i18n.ACTIONS, - actions, - }, -]; + ]; + if (isModal) { + columns.pop(); // remove actions if in modal + } + return columns; +}; interface Props { theCase: Case; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index e3f4fee15ce68..bbb96f433d3c8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -151,8 +151,22 @@ describe('AllCases', () => { expect(column.find('.euiTableRowCell--hideForDesktop').text()).toEqual(columnName); expect(column.find('span').text()).toEqual(emptyTag); }; - getCasesColumns([], 'open').map((i, key) => i.name != null && checkIt(`${i.name}`, key)); + getCasesColumns([], 'open', false).map((i, key) => i.name != null && checkIt(`${i.name}`, key)); }); + + it('should not render case link or actions on modal=true', () => { + const wrapper = mount( + + + + ); + const checkIt = (columnName: string) => { + expect(columnName).not.toEqual(i18n.ACTIONS); + }; + getCasesColumns([], 'open', true).map((i, key) => i.name != null && checkIt(`${i.name}`)); + expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy(); + }); + it('should tableHeaderSortButton AllCases', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 32a7c4078071e..d27f383fb94e3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +/* eslint-disable react-hooks/exhaustive-deps */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBasicTable, @@ -72,7 +72,6 @@ const ProgressLoader = styled(EuiProgress)` z-index: ${theme.eui.euiZHeader}; `} `; - const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; @@ -83,368 +82,373 @@ const getSortField = (field: string): SortFieldCase => { }; interface AllCasesProps { + onRowClick?: (id: string) => void; + isModal?: boolean; userCanCrud: boolean; } -export const AllCases = React.memo(({ userCanCrud }) => { - const urlSearch = useGetUrlSearch(navTabs.case); - const { actionLicense } = useGetActionLicense(); - const { - countClosedCases, - countOpenCases, - isLoading: isCasesStatusLoading, - fetchCasesStatus, - } = useGetCasesStatus(); - const { - data, - dispatchUpdateCaseProperty, - filterOptions, - loading, - queryParams, - selectedCases, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - } = useGetCases(); +export const AllCases = React.memo( + ({ onRowClick = () => {}, isModal = false, userCanCrud }) => { + const urlSearch = useGetUrlSearch(navTabs.case); + const { actionLicense } = useGetActionLicense(); + const { + countClosedCases, + countOpenCases, + isLoading: isCasesStatusLoading, + fetchCasesStatus, + } = useGetCasesStatus(); + const { + data, + dispatchUpdateCaseProperty, + filterOptions, + loading, + queryParams, + selectedCases, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + } = useGetCases(); - // Delete case - const { - dispatchResetIsDeleted, - handleOnDeleteConfirm, - handleToggleModal, - isLoading: isDeleting, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - // Update case - const { - dispatchResetIsUpdated, - isLoading: isUpdating, - isUpdated, - updateBulkStatus, - } = useUpdateCases(); - const [deleteThisCase, setDeleteThisCase] = useState({ - title: '', - id: '', - }); - const [deleteBulk, setDeleteBulk] = useState([]); - const filterRefetch = useRef<() => void>(); - const setFilterRefetch = useCallback( - (refetchFilter: () => void) => { - filterRefetch.current = refetchFilter; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterRefetch.current] - ); - const refreshCases = useCallback( - (dataRefresh = true) => { - if (dataRefresh) refetchCases(); - fetchCasesStatus(); - setSelectedCases([]); - setDeleteBulk([]); - if (filterRefetch.current != null) { - filterRefetch.current(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterOptions, queryParams, filterRefetch.current] - ); + // Delete case + const { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isLoading: isDeleting, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); - useEffect(() => { - if (isDeleted) { - refreshCases(); - dispatchResetIsDeleted(); - } - if (isUpdated) { - refreshCases(); - dispatchResetIsUpdated(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDeleted, isUpdated]); - const confirmDeleteModal = useMemo( - () => ( - 0} - onCancel={handleToggleModal} - onConfirm={handleOnDeleteConfirm.bind( - null, - deleteBulk.length > 0 ? deleteBulk : [deleteThisCase] - )} - /> - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] - ); + // Update case + const { + dispatchResetIsUpdated, + isLoading: isUpdating, + isUpdated, + updateBulkStatus, + } = useUpdateCases(); + const [deleteThisCase, setDeleteThisCase] = useState({ + title: '', + id: '', + }); + const [deleteBulk, setDeleteBulk] = useState([]); + const filterRefetch = useRef<() => void>(); + const setFilterRefetch = useCallback( + (refetchFilter: () => void) => { + filterRefetch.current = refetchFilter; + }, + [filterRefetch.current] + ); + const refreshCases = useCallback( + (dataRefresh = true) => { + if (dataRefresh) refetchCases(); + fetchCasesStatus(); + setSelectedCases([]); + setDeleteBulk([]); + if (filterRefetch.current != null) { + filterRefetch.current(); + } + }, + [filterOptions, queryParams, filterRefetch.current] + ); - const toggleDeleteModal = useCallback((deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + if (isDeleted) { + refreshCases(); + dispatchResetIsDeleted(); + } + if (isUpdated) { + refreshCases(); + dispatchResetIsUpdated(); + } + }, [isDeleted, isUpdated]); + const confirmDeleteModal = useMemo( + () => ( + 0} + onCancel={handleToggleModal} + onConfirm={handleOnDeleteConfirm.bind( + null, + deleteBulk.length > 0 ? deleteBulk : [deleteThisCase] + )} + /> + ), + [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] + ); - const toggleBulkDeleteModal = useCallback( - (caseIds: string[]) => { + const toggleDeleteModal = useCallback((deleteCase: Case) => { handleToggleModal(); - if (caseIds.length === 1) { - const singleCase = selectedCases.find((theCase) => theCase.id === caseIds[0]); - if (singleCase) { - return setDeleteThisCase({ id: singleCase.id, title: singleCase.title }); + setDeleteThisCase(deleteCase); + }, []); + + const toggleBulkDeleteModal = useCallback( + (caseIds: string[]) => { + handleToggleModal(); + if (caseIds.length === 1) { + const singleCase = selectedCases.find((theCase) => theCase.id === caseIds[0]); + if (singleCase) { + return setDeleteThisCase({ id: singleCase.id, title: singleCase.title }); + } } - } - const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id })); - setDeleteBulk(convertToDeleteCases); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedCases] - ); + const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id })); + setDeleteBulk(convertToDeleteCases); + }, + [selectedCases] + ); - const handleUpdateCaseStatus = useCallback( - (status: string) => { - updateBulkStatus(selectedCases, status); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedCases] - ); + const handleUpdateCaseStatus = useCallback( + (status: string) => { + updateBulkStatus(selectedCases, status); + }, + [selectedCases] + ); - const selectedCaseIds = useMemo( - (): string[] => selectedCases.map((caseObj: Case) => caseObj.id), - [selectedCases] - ); + const selectedCaseIds = useMemo( + (): string[] => selectedCases.map((caseObj: Case) => caseObj.id), + [selectedCases] + ); - const getBulkItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal] - ); - const handleDispatchUpdate = useCallback( - (args: Omit) => { - dispatchUpdateCaseProperty({ ...args, refetchCasesStatus: fetchCasesStatus }); - }, - [dispatchUpdateCaseProperty, fetchCasesStatus] - ); + const getBulkItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + + ), + [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal] + ); + const handleDispatchUpdate = useCallback( + (args: Omit) => { + dispatchUpdateCaseProperty({ ...args, refetchCasesStatus: fetchCasesStatus }); + }, + [dispatchUpdateCaseProperty, fetchCasesStatus] + ); - const actions = useMemo( - () => - getActions({ - caseStatus: filterOptions.status, - deleteCaseOnClick: toggleDeleteModal, - dispatchUpdate: handleDispatchUpdate, - }), - [filterOptions.status, toggleDeleteModal, handleDispatchUpdate] - ); + const actions = useMemo( + () => + getActions({ + caseStatus: filterOptions.status, + deleteCaseOnClick: toggleDeleteModal, + dispatchUpdate: handleDispatchUpdate, + }), + [filterOptions.status, toggleDeleteModal, handleDispatchUpdate] + ); - const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]); + const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]); - const tableOnChangeCallback = useCallback( - ({ page, sort }: EuiBasicTableOnChange) => { - let newQueryParams = queryParams; - if (sort) { - newQueryParams = { - ...newQueryParams, - sortField: getSortField(sort.field), - sortOrder: sort.direction, - }; - } - if (page) { - newQueryParams = { - ...newQueryParams, - page: page.index + 1, - perPage: page.size, - }; - } - setQueryParams(newQueryParams); - refreshCases(false); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [queryParams] - ); + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + let newQueryParams = queryParams; + if (sort) { + newQueryParams = { + ...newQueryParams, + sortField: getSortField(sort.field), + sortOrder: sort.direction, + }; + } + if (page) { + newQueryParams = { + ...newQueryParams, + page: page.index + 1, + perPage: page.size, + }; + } + setQueryParams(newQueryParams); + refreshCases(false); + }, + [queryParams] + ); - const onFilterChangedCallback = useCallback( - (newFilterOptions: Partial) => { - if (newFilterOptions.status && newFilterOptions.status === 'closed') { - setQueryParams({ sortField: SortFieldCase.closedAt }); - } else if (newFilterOptions.status && newFilterOptions.status === 'open') { - setQueryParams({ sortField: SortFieldCase.createdAt }); - } - setFilters(newFilterOptions); - refreshCases(false); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterOptions, queryParams] - ); + const onFilterChangedCallback = useCallback( + (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ sortField: SortFieldCase.createdAt }); + } + setFilters(newFilterOptions); + refreshCases(false); + }, + [filterOptions, queryParams] + ); - const memoizedGetCasesColumns = useMemo( - () => getCasesColumns(userCanCrud ? actions : [], filterOptions.status), - [actions, filterOptions.status, userCanCrud] - ); - const memoizedPagination = useMemo( - () => ({ - pageIndex: queryParams.page - 1, - pageSize: queryParams.perPage, - totalItemCount: data.total, - pageSizeOptions: [5, 10, 15, 20, 25], - }), - [data, queryParams] - ); + const memoizedGetCasesColumns = useMemo( + () => getCasesColumns(userCanCrud ? actions : [], filterOptions.status, isModal), + [actions, filterOptions.status, userCanCrud, isModal] + ); + const memoizedPagination = useMemo( + () => ({ + pageIndex: queryParams.page - 1, + pageSize: queryParams.perPage, + totalItemCount: data.total, + pageSizeOptions: [5, 10, 15, 20, 25], + }), + [data, queryParams] + ); - const sorting: EuiTableSortingType = { - sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, - }; - const euiBasicTableSelectionProps = useMemo>( - () => ({ onSelectionChange: setSelectedCases }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedCases] - ); - const isCasesLoading = useMemo( - () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, - [loading] - ); - const isDataEmpty = useMemo(() => data.total === 0, [data]); + const sorting: EuiTableSortingType = { + sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, + }; + const euiBasicTableSelectionProps = useMemo>( + () => ({ onSelectionChange: setSelectedCases }), + [selectedCases] + ); + const isCasesLoading = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const isDataEmpty = useMemo(() => data.total === 0, [data]); - return ( - <> - {!isEmpty(actionsErrors) && ( - - )} - - - - - - - - - - } - titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} - urlSearch={urlSearch} - /> - - - - {i18n.CREATE_TITLE} - - - - - {(isCasesLoading || isDeleting || isUpdating) && !isDataEmpty && ( - - )} - - - {isCasesLoading && isDataEmpty ? ( -
- -
- ) : ( -
- - - - - {i18n.SHOWING_CASES(data.total ?? 0)} - - - - - {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} - - {userCanCrud && ( - - {i18n.BULK_ACTIONS} - - )} - - {i18n.REFRESH} - - - - - {i18n.NO_CASES}} - titleSize="xs" - body={i18n.NO_CASES_BODY} - actions={ - - {i18n.ADD_NEW_CASE} - - } + const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]); + return ( + <> + {!isEmpty(actionsErrors) && ( + + )} + {!isModal && ( + + + + + + + - } - onChange={tableOnChangeCallback} - pagination={memoizedPagination} - selection={userCanCrud ? euiBasicTableSelectionProps : {}} - sorting={sorting} - /> -
+ + + } + titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} + urlSearch={urlSearch} + /> + + + + {i18n.CREATE_TITLE} + + + + + )} + {(isCasesLoading || isDeleting || isUpdating) && !isDataEmpty && ( + )} -
- {confirmDeleteModal} - - ); -}); + + + {isCasesLoading && isDataEmpty ? ( +
+ +
+ ) : ( +
+ + + + + {i18n.SHOWING_CASES(data.total ?? 0)} + + + {!isModal && ( + + + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} + + {userCanCrud && ( + + {i18n.BULK_ACTIONS} + + )} + + {i18n.REFRESH} + + + )} + + + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + + {i18n.ADD_NEW_CASE} + + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + rowProps={(item) => + isModal + ? { + onClick: () => onRowClick(item.id), + } + : {} + } + selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} + sorting={sorting} + /> +
+ )} +
+ {confirmDeleteModal} + + ); + } +); AllCases.displayName = 'AllCases'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx new file mode 100644 index 0000000000000..a24cb6a87de74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { mount } from 'enzyme'; +import React from 'react'; +import { AllCasesModal } from '.'; +import { TestProviders } from '../../../common/mock'; + +import { useGetCasesMockState, basicCaseId } from '../../containers/mock'; +import { useDeleteCases } from '../../containers/use_delete_cases'; +import { useGetCases } from '../../containers/use_get_cases'; +import { useGetCasesStatus } from '../../containers/use_get_cases_status'; +import { useUpdateCases } from '../../containers/use_bulk_update_case'; +import { EuiTableRow } from '@elastic/eui'; + +jest.mock('../../containers/use_bulk_update_case'); +jest.mock('../../containers/use_delete_cases'); +jest.mock('../../containers/use_get_cases'); +jest.mock('../../containers/use_get_cases_status'); + +const useDeleteCasesMock = useDeleteCases as jest.Mock; +const useGetCasesMock = useGetCases as jest.Mock; +const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useUpdateCasesMock = useUpdateCases as jest.Mock; +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +const onCloseCaseModal = jest.fn(); +const onRowClick = jest.fn(); +const defaultProps = { + onCloseCaseModal, + onRowClick, + showCaseModal: true, +}; +describe('AllCasesModal', () => { + const dispatchResetIsDeleted = jest.fn(); + const dispatchResetIsUpdated = jest.fn(); + const dispatchUpdateCaseProperty = jest.fn(); + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const refetchCases = jest.fn(); + const setFilters = jest.fn(); + const setQueryParams = jest.fn(); + const setSelectedCases = jest.fn(); + const updateBulkStatus = jest.fn(); + const fetchCasesStatus = jest.fn(); + + const defaultGetCases = { + ...useGetCasesMockState, + dispatchUpdateCaseProperty, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + }; + const defaultDeleteCases = { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + isLoading: false, + }; + const defaultCasesStatus = { + countClosedCases: 0, + countOpenCases: 5, + fetchCasesStatus, + isError: false, + isLoading: true, + }; + const defaultUpdateCases = { + isUpdated: false, + isLoading: false, + isError: false, + dispatchResetIsUpdated, + updateBulkStatus, + }; + /* eslint-disable no-console */ + // Silence until enzyme fixed to use ReactTestUtils.act() + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + /* eslint-enable no-console */ + beforeEach(() => { + jest.resetAllMocks(); + useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); + useGetCasesMock.mockImplementation(() => defaultGetCases); + useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); + useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); + }); + + it('renders with unselectable rows', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + expect(wrapper.find(EuiTableRow).first().prop('isSelectable')).toBeFalsy(); + }); + it('does not render modal if showCaseModal: false', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + }); + it('onRowClick called when row is clicked', () => { + const wrapper = mount( + + + + ); + const firstRow = wrapper.find(EuiTableRow).first(); + firstRow.simulate('click'); + expect(onRowClick.mock.calls[0][0]).toEqual(basicCaseId); + }); + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + const modalClose = wrapper.find('.euiModal__closeIcon').first(); + modalClose.simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx new file mode 100644 index 0000000000000..d2ca0f0cd02ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, +} from '@elastic/eui'; +import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; +import { AllCases } from '../all_cases'; +import * as i18n from './translations'; + +interface AllCasesModalProps { + onCloseCaseModal: () => void; + showCaseModal: boolean; + onRowClick: (id: string) => void; +} + +export const AllCasesModalComponent = ({ + onCloseCaseModal, + onRowClick, + showCaseModal, +}: AllCasesModalProps) => { + const userPermissions = useGetUserSavedObjectPermissions(); + let modal; + if (showCaseModal) { + modal = ( + + + + {i18n.SELECT_CASE_TITLE} + + + + + + + ); + } + + return <>{modal}; +}; + +export const AllCasesModal = React.memo(AllCasesModalComponent); + +AllCasesModal.displayName = 'AllCasesModal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts new file mode 100644 index 0000000000000..e0f84d8541424 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.case.caseModal.title', { + defaultMessage: 'Select case to attach timeline', +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 5dfe12179b990..780de303c02d3 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -10,7 +10,6 @@ import { useParams, Redirect } from 'react-router-dom'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; -import { SpyRoute } from '../../common/utils/route/spy_routes'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; @@ -36,7 +35,6 @@ export const CaseDetailsPage = React.memo(() => { )} - ) : null; }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx index b6620ed103bc8..8942832798a5e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx @@ -8,15 +8,15 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { ExceptionItem } from '../viewer'; +import { ExceptionItem } from '../viewer/exception_item'; import { Operator } from '../types'; import { getExceptionItemMock } from '../mocks'; -storiesOf('components/exceptions', module) - .add('ExceptionItem/with os', () => { +storiesOf('ExceptionItem', module) + .add('with os', () => { const payload = getExceptionItemMock(); payload.description = ''; - payload.comments = []; + payload.comment = []; payload.entries = [ { field: 'actingProcess.file.signer', @@ -29,6 +29,7 @@ storiesOf('components/exceptions', module) return ( ({ eui: euiLightVars, darkMode: false })}> {}} @@ -37,10 +38,10 @@ storiesOf('components/exceptions', module) ); }) - .add('ExceptionItem/with description', () => { + .add('with description', () => { const payload = getExceptionItemMock(); payload._tags = []; - payload.comments = []; + payload.comment = []; payload.entries = [ { field: 'actingProcess.file.signer', @@ -53,6 +54,7 @@ storiesOf('components/exceptions', module) return ( ({ eui: euiLightVars, darkMode: false })}> {}} @@ -61,7 +63,7 @@ storiesOf('components/exceptions', module) ); }) - .add('ExceptionItem/with comments', () => { + .add('with comments', () => { const payload = getExceptionItemMock(); payload._tags = []; payload.description = ''; @@ -77,6 +79,7 @@ storiesOf('components/exceptions', module) return ( ({ eui: euiLightVars, darkMode: false })}> {}} @@ -85,15 +88,16 @@ storiesOf('components/exceptions', module) ); }) - .add('ExceptionItem/with nested entries', () => { + .add('with nested entries', () => { const payload = getExceptionItemMock(); payload._tags = []; payload.description = ''; - payload.comments = []; + payload.comment = []; return ( ({ eui: euiLightVars, darkMode: false })}> {}} @@ -102,12 +106,13 @@ storiesOf('components/exceptions', module) ); }) - .add('ExceptionItem/with everything', () => { + .add('with everything', () => { const payload = getExceptionItemMock(); return ( ({ eui: euiLightVars, darkMode: false })}> {}} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx new file mode 100644 index 0000000000000..29cded8f69165 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionsViewerHeader } from '../viewer/exceptions_viewer_header'; +import { ExceptionListType } from '../types'; + +storiesOf('ExceptionsViewerHeader', module) + .add('loading', () => { + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + onAddExceptionClick={() => {}} + /> + + ); + }) + .add('all lists', () => { + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + onAddExceptionClick={() => {}} + /> + + ); + }) + .add('endpoint only', () => { + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + onAddExceptionClick={() => {}} + /> + + ); + }) + .add('detections only', () => { + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + onAddExceptionClick={() => {}} + /> + + ); + }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 223eabb0ea4ee..7698605588e76 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -439,7 +439,7 @@ describe('Exception helpers', () => { describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { - const payload = getExceptionItemMock().comments; + const payload = getExceptionItemMock().comment; const result = getFormattedComments(payload); expect(result[0].username).toEqual('user_name'); @@ -447,7 +447,7 @@ describe('Exception helpers', () => { }); test('it returns formatted timeline icon with comment users initial', () => { - const payload = getExceptionItemMock().comments; + const payload = getExceptionItemMock().comment; const result = getFormattedComments(payload); const wrapper = mount(result[0].timelineIcon as React.ReactElement); @@ -456,7 +456,7 @@ describe('Exception helpers', () => { }); test('it returns comment text', () => { - const payload = getExceptionItemMock().comments; + const payload = getExceptionItemMock().comment; const result = getFormattedComments(payload); const wrapper = mount(result[0].children as React.ReactElement); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts index 15aec3533b325..0dba3fd26c487 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts @@ -11,6 +11,25 @@ import { NestedExceptionEntry, FormattedEntry, } from './types'; +import { ExceptionList } from '../../../lists_plugin_deps'; + +export const getExceptionListMock = (): ExceptionList => ({ + id: '5b543420', + created_at: '2020-04-23T00:19:13.289Z', + created_by: 'user_name', + list_id: 'test-exception', + tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', + updated_at: '2020-04-23T00:19:13.289Z', + updated_by: 'user_name', + namespace_type: 'single', + name: '', + description: 'This is a description', + _tags: ['os:windows'], + tags: [], + type: 'endpoint', + meta: {}, + totalItems: 0, +}); export const getExceptionItemEntryMock = (): ExceptionEntry => ({ field: 'actingProcess.file.signer', @@ -44,7 +63,7 @@ export const getExceptionItemMock = (): ExceptionListItemSchema => ({ namespace_type: 'single', name: '', description: 'This is a description', - comments: [ + comment: [ { user: 'user_name', timestamp: '2020-04-23T00:19:13.289Z', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 704849430daf9..23e9f64caf695 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -5,6 +5,17 @@ */ import { i18n } from '@kbn/i18n'; +export const DETECTION_LIST = i18n.translate( + 'xpack.securitySolution.exceptions.detectionListLabel', + { + defaultMessage: 'Detection list', + } +); + +export const ENDPOINT_LIST = i18n.translate('xpack.securitySolution.exceptions.endpointListLabel', { + defaultMessage: 'Endpoint list', +}); + export const EDIT = i18n.translate('xpack.securitySolution.exceptions.editButtonLabel', { defaultMessage: 'Edit', }); @@ -47,3 +58,82 @@ export const OPERATING_SYSTEM = i18n.translate( defaultMessage: 'OS', } ); + +export const SEARCH_DEFAULT = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.searchDefaultPlaceholder', + { + defaultMessage: 'Search field (ex: host.name)', + } +); + +export const ADD_EXCEPTION_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addExceptionLabel', + { + defaultMessage: 'Add new exception', + } +); + +export const ADD_TO_ENDPOINT_LIST = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToEndpointListLabel', + { + defaultMessage: 'Add to endpoint list', + } +); + +export const ADD_TO_DETECTIONS_LIST = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToDetectionsListLabel', + { + defaultMessage: 'Add to detections list', + } +); + +export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.emptyPromptTitle', + { + defaultMessage: 'You have no exceptions', + } +); + +export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.emptyPromptBody', + { + defaultMessage: + 'You can add an exception to fine tune the rule so that it suppresses alerts that meet specified conditions. Exceptions leverage detection accuracy, which can help reduce the number of false positives.', + } +); + +export const FETCH_LIST_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.fetchingListError', + { + defaultMessage: 'Error fetching exceptions', + } +); + +export const DELETE_EXCEPTION_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.deleteExceptionError', + { + defaultMessage: 'Error deleting exception', + } +); + +export const ITEMS_PER_PAGE = (items: number) => + i18n.translate('xpack.securitySolution.exceptions.exceptionsPaginationLabel', { + values: { items }, + defaultMessage: 'Items per page: {items}', + }); + +export const NUMBER_OF_ITEMS = (items: number) => + i18n.translate('xpack.securitySolution.exceptions.paginationNumberOfItemsLabel', { + values: { items }, + defaultMessage: '{items} items', + }); + +export const REFRESH = i18n.translate('xpack.securitySolution.exceptions.utilityRefreshLabel', { + defaultMessage: 'Refresh', +}); + +export const SHOWING_EXCEPTIONS = (items: number) => + i18n.translate('xpack.securitySolution.exceptions.utilityNumberExceptionsLabel', { + values: { items }, + defaultMessage: 'Showing {items} exceptions', + }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index e8393610e459d..d60d1ef71e502 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -5,6 +5,12 @@ */ import { ReactNode } from 'react'; +import { + NamespaceType, + ExceptionList, + ExceptionListItemSchema as ExceptionItem, +} from '../../../lists_plugin_deps'; + export interface OperatorOption { message: string; value: string; @@ -56,10 +62,51 @@ export interface Comment { comment: string; } +export enum ExceptionListType { + DETECTION_ENGINE = 'detection', + ENDPOINT = 'endpoint', +} + +export interface FilterOptions { + filter: string; + showDetectionsList: boolean; + showEndpointList: boolean; + tags: string[]; +} + +export interface Filter { + filter: Partial; + pagination: Partial; +} + +export interface SetExceptionsProps { + lists: ExceptionList[]; + exceptions: ExceptionItem[]; + pagination: Pagination; +} + +export interface ApiProps { + id: string; + namespaceType: NamespaceType; +} + +export interface Pagination { + page: number; + perPage: number; + total: number; +} + +export interface ExceptionsPagination { + pageIndex: number; + pageSize: number; + totalItemCount: number; + pageSizeOptions: number[]; +} + // TODO: Delete once types are updated export interface ExceptionListItemSchema { _tags: string[]; - comments: Comment[]; + comment: Comment[]; created_at: string; created_by: string; description?: string; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index 536d005c57b6e..c5d2ffc7ac2bf 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -11,7 +11,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; -import { getExceptionItemMock } from '../mocks'; +import { getExceptionItemMock } from '../../mocks'; describe('ExceptionDetails', () => { beforeEach(() => { @@ -24,7 +24,7 @@ describe('ExceptionDetails', () => { test('it renders no comments button if no comments exist', () => { const exceptionItem = getExceptionItemMock(); - exceptionItem.comments = []; + exceptionItem.comment = []; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -77,7 +77,7 @@ describe('ExceptionDetails', () => { test('it renders comments plural if more than one', () => { const exceptionItem = getExceptionItemMock(); - exceptionItem.comments = [ + exceptionItem.comment = [ { user: 'user_1', timestamp: '2020-04-23T00:19:13.289Z', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 8745e80a21548..6f418808b239a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -9,9 +9,9 @@ import React, { useMemo } from 'react'; import styled, { css } from 'styled-components'; import { transparentize } from 'polished'; -import { ExceptionListItemSchema } from '../types'; -import { getDescriptionListContent } from '../helpers'; -import * as i18n from '../translations'; +import { ExceptionListItemSchema } from '../../types'; +import { getDescriptionListContent } from '../../helpers'; +import * as i18n from '../../translations'; const StyledExceptionDetails = styled(EuiFlexItem)` ${({ theme }) => css` @@ -40,8 +40,9 @@ const ExceptionDetailsComponent = ({ const descriptionList = useMemo(() => getDescriptionListContent(exceptionItem), [exceptionItem]); const commentsSection = useMemo((): JSX.Element => { - const { comments } = exceptionItem; - if (comments.length > 0) { + // TODO: return back to exceptionItem.comments once updated + const { comment } = exceptionItem; + if (comment.length > 0) { return ( - {!showComments - ? i18n.COMMENTS_SHOW(comments.length) - : i18n.COMMENTS_HIDE(comments.length)} + {!showComments ? i18n.COMMENTS_SHOW(comment.length) : i18n.COMMENTS_HIDE(comment.length)} ); } else { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx index e0c62f51d032a..10f11231ace01 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx @@ -10,14 +10,15 @@ import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionEntries } from './exception_entries'; -import { getFormattedEntryMock } from '../mocks'; -import { getEmptyValue } from '../../empty_value'; +import { getFormattedEntryMock } from '../../mocks'; +import { getEmptyValue } from '../../../empty_value'; describe('ExceptionEntries', () => { test('it does NOT render the and badge if only one exception item entry exists', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> theme.eui.euiSize}; @@ -47,12 +47,14 @@ const AndOrBadgeContainer = styled(EuiFlexItem)` interface ExceptionEntriesComponentProps { entries: FormattedEntry[]; + disableDelete: boolean; handleDelete: () => void; handleEdit: () => void; } const ExceptionEntriesComponent = ({ entries, + disableDelete, handleDelete, handleEdit, }: ExceptionEntriesComponentProps): JSX.Element => { @@ -141,6 +143,7 @@ const ExceptionEntriesComponent = ({ size="s" color="primary" onClick={handleEdit} + isDisabled={disableDelete} data-test-subj="exceptionsViewerEditBtn" > {i18n.EDIT} @@ -151,6 +154,7 @@ const ExceptionEntriesComponent = ({ size="s" color="danger" onClick={handleDelete} + isLoading={disableDelete} data-test-subj="exceptionsViewerDeleteBtn" > {i18n.REMOVE} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx new file mode 100644 index 0000000000000..784fc4336a5ff --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionItem } from './'; +import { getExceptionItemMock } from '../../mocks'; + +describe('ExceptionItem', () => { + it('it renders ExceptionDetails and ExceptionEntries', () => { + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('ExceptionDetails')).toHaveLength(1); + expect(wrapper.find('ExceptionEntries')).toHaveLength(1); + }); + + it('it invokes "handleEdit" when edit button clicked', () => { + const mockHandleEdit = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); + editBtn.simulate('click'); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + }); + + it('it invokes "handleDelete" when delete button clicked', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); + editBtn.simulate('click'); + + expect(mockHandleDelete).toHaveBeenCalledTimes(1); + }); + + it('it renders comment accordion closed to begin with', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(0); + }); + + it('it renders comment accordion open when showComments is true', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const commentsBtn = wrapper + .find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]') + .at(0); + commentsBtn.simulate('click'); + + expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx new file mode 100644 index 0000000000000..386ab6f3c3c7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiPanel, + EuiFlexGroup, + EuiCommentProps, + EuiCommentList, + EuiAccordion, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import styled from 'styled-components'; + +import { ExceptionDetails } from './exception_details'; +import { ExceptionEntries } from './exception_entries'; +import { getFormattedEntries, getFormattedComments } from '../../helpers'; +import { FormattedEntry, ExceptionListItemSchema, ApiProps } from '../../types'; + +const MyFlexItem = styled(EuiFlexItem)` + &.comments--show { + padding: ${({ theme }) => theme.eui.euiSize}; + border-top: ${({ theme }) => `${theme.eui.euiBorderThin}`} + +`; + +interface ExceptionItemProps { + loadingItemIds: ApiProps[]; + exceptionItem: ExceptionListItemSchema; + commentsAccordionId: string; + handleDelete: (arg: ApiProps) => void; + handleEdit: (item: ExceptionListItemSchema) => void; +} + +const ExceptionItemComponent = ({ + loadingItemIds, + exceptionItem, + commentsAccordionId, + handleDelete, + handleEdit, +}: ExceptionItemProps): JSX.Element => { + const [entryItems, setEntryItems] = useState([]); + const [showComments, setShowComments] = useState(false); + + useEffect((): void => { + const formattedEntries = getFormattedEntries(exceptionItem.entries); + setEntryItems(formattedEntries); + }, [exceptionItem.entries]); + + const onDelete = useCallback((): void => { + handleDelete({ id: exceptionItem.id, namespaceType: exceptionItem.namespace_type }); + }, [handleDelete, exceptionItem]); + + const onEdit = useCallback((): void => { + handleEdit(exceptionItem); + }, [handleEdit, exceptionItem]); + + const onCommentsClick = useCallback((): void => { + setShowComments(!showComments); + }, [setShowComments, showComments]); + + const formattedComments = useMemo((): EuiCommentProps[] => { + // TODO: return back to exceptionItem.comments once updated + return getFormattedComments(exceptionItem.comment); + }, [exceptionItem]); + + const disableDelete = useMemo((): boolean => { + const foundItems = loadingItemIds.filter((t) => t.id === exceptionItem.id); + return foundItems.length > 0; + }, [loadingItemIds, exceptionItem.id]); + + return ( + + + + + + + + + + + + + + + + ); +}; + +ExceptionItemComponent.displayName = 'ExceptionItemComponent'; + +export const ExceptionItem = React.memo(ExceptionItemComponent); + +ExceptionItem.displayName = 'ExceptionItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx new file mode 100644 index 0000000000000..dcc8611cd7298 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionsViewerPagination } from './exceptions_pagination'; + +describe('ExceptionsViewerPagination', () => { + it('it renders passed in "pageSize" as selected option', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsPerPageBtn"]').at(0).text()).toEqual( + 'Items per page: 50' + ); + }); + + it('it renders all passed in page size options when per page button clicked', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); + + expect(wrapper.find('button[data-test-subj="exceptionsPerPageItem"]').at(0).text()).toEqual( + '20 items' + ); + expect(wrapper.find('button[data-test-subj="exceptionsPerPageItem"]').at(1).text()).toEqual( + '50 items' + ); + expect(wrapper.find('button[data-test-subj="exceptionsPerPageItem"]').at(2).text()).toEqual( + '100 items' + ); + }); + + it('it invokes "onPaginationChange" when per page item is clicked', () => { + const mockOnPaginationChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); + wrapper.find('button[data-test-subj="exceptionsPerPageItem"]').at(0).simulate('click'); + + expect(mockOnPaginationChange).toHaveBeenCalledWith({ + filter: {}, + pagination: { pageIndex: 0, pageSize: 20, totalItemCount: 1 }, + }); + }); + + it('it renders correct total page count', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsPagination"]').at(0).prop('pageCount')).toEqual( + 4 + ); + expect( + wrapper.find('[data-test-subj="exceptionsPagination"]').at(0).prop('activePage') + ).toEqual(0); + }); + + it('it invokes "onPaginationChange" when next clicked', () => { + const mockOnPaginationChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="pagination-button-next"]').at(1).simulate('click'); + + expect(mockOnPaginationChange).toHaveBeenCalledWith({ + filter: {}, + pagination: { pageIndex: 2, pageSize: 50, totalItemCount: 160 }, + }); + }); + + it('it invokes "onPaginationChange" when page clicked', () => { + const mockOnPaginationChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('button[data-test-subj="pagination-button-3"]').simulate('click'); + + expect(mockOnPaginationChange).toHaveBeenCalledWith({ + filter: {}, + pagination: { pageIndex: 4, pageSize: 50, totalItemCount: 160 }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx new file mode 100644 index 0000000000000..0953a5c666c5d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactElement, useCallback, useState, useMemo } from 'react'; +import { + EuiContextMenuItem, + EuiButtonEmpty, + EuiPagination, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiContextMenuPanel, +} from '@elastic/eui'; + +import * as i18n from '../translations'; +import { ExceptionsPagination, Filter } from '../types'; + +interface ExceptionsViewerPaginationProps { + pagination: ExceptionsPagination; + onPaginationChange: (arg: Filter) => void; +} + +const ExceptionsViewerPaginationComponent = ({ + pagination, + onPaginationChange, +}: ExceptionsViewerPaginationProps): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + + const closePerPageMenu = useCallback((): void => setIsOpen(false), [setIsOpen]); + + const onPerPageMenuClick = useCallback((): void => setIsOpen((isPopoverOpen) => !isPopoverOpen), [ + setIsOpen, + ]); + + const onPageClick = useCallback( + (pageIndex: number): void => { + onPaginationChange({ + filter: {}, + pagination: { + pageIndex: pageIndex + 1, + pageSize: pagination.pageSize, + totalItemCount: pagination.totalItemCount, + }, + }); + }, + [pagination, onPaginationChange] + ); + + const items = useMemo((): ReactElement[] => { + return pagination.pageSizeOptions.map((rows) => ( + { + onPaginationChange({ + filter: {}, + pagination: { + pageIndex: pagination.pageIndex, + pageSize: rows, + totalItemCount: pagination.totalItemCount, + }, + }); + closePerPageMenu(); + }} + data-test-subj="exceptionsPerPageItem" + > + {i18n.NUMBER_OF_ITEMS(rows)} + + )); + }, [pagination, onPaginationChange, closePerPageMenu]); + + const totalPages = useMemo((): number => { + if (pagination.totalItemCount > 0) { + return Math.ceil(pagination.totalItemCount / pagination.pageSize); + } else { + return 1; + } + }, [pagination]); + + return ( + + + + {i18n.ITEMS_PER_PAGE(pagination.pageSize)} + + } + isOpen={isOpen} + closePopover={closePerPageMenu} + panelPaddingSize="none" + > + + + + + + + + + ); +}; + +ExceptionsViewerPaginationComponent.displayName = 'ExceptionsViewerPaginationComponent'; + +export const ExceptionsViewerPagination = React.memo(ExceptionsViewerPaginationComponent); + +ExceptionsViewerPagination.displayName = 'ExceptionsViewerPagination'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx new file mode 100644 index 0000000000000..bdc99370a6293 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx @@ -0,0 +1,337 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionsViewerHeader } from './exceptions_viewer_header'; +import { ExceptionListType } from '../types'; + +describe('ExceptionsViewerHeader', () => { + it('it renders all disabled if "isInitLoading" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('input[data-test-subj="exceptionsHeaderSearch"]').at(0).prop('disabled') + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="exceptionsDetectionFilterBtn"] button').at(0).prop('disabled') + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="exceptionsEndpointFilterBtn"] button').at(0).prop('disabled') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="exceptionsHeaderAddExceptionPopoverBtn"] button') + .at(0) + .prop('disabled') + ).toBeTruthy(); + }); + + it('it displays toggles and add exception popover when more than one list type available', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]').exists()).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionPopoverBtn"]').exists() + ).toBeTruthy(); + }); + + it('it does not display toggles and add exception popover if only one list type is available', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionPopoverBtn"]')).toHaveLength( + 0 + ); + }); + + it('it displays add exception button without popover if only one list type is available', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').exists() + ).toBeTruthy(); + }); + + it('it renders detections filter toggle selected when clicked', () => { + const mockOnFilterChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsDetectionFilterBtn"] button').simulate('click'); + + expect( + wrapper + .find('EuiFilterButton[data-test-subj="exceptionsDetectionFilterBtn"]') + .at(0) + .prop('hasActiveFilters') + ).toBeTruthy(); + expect( + wrapper + .find('EuiFilterButton[data-test-subj="exceptionsEndpointFilterBtn"]') + .at(0) + .prop('hasActiveFilters') + ).toBeFalsy(); + expect(mockOnFilterChange).toHaveBeenCalledWith({ + filter: { + filter: '', + showDetectionsList: true, + showEndpointList: false, + tags: [], + }, + pagination: {}, + }); + }); + + it('it renders endpoint filter toggle selected and invokes "onFilterChange" when clicked', () => { + const mockOnFilterChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsEndpointFilterBtn"] button').simulate('click'); + + expect( + wrapper + .find('EuiFilterButton[data-test-subj="exceptionsEndpointFilterBtn"]') + .at(0) + .prop('hasActiveFilters') + ).toBeTruthy(); + expect( + wrapper + .find('EuiFilterButton[data-test-subj="exceptionsDetectionFilterBtn"]') + .at(0) + .prop('hasActiveFilters') + ).toBeFalsy(); + expect(mockOnFilterChange).toHaveBeenCalledWith({ + filter: { + filter: '', + showDetectionsList: false, + showEndpointList: true, + tags: [], + }, + pagination: {}, + }); + }); + + it('it invokes "onAddExceptionClick" when user selects to add an exception item and only endpoint exception lists are available', () => { + const mockOnAddExceptionClick = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); + + expect(mockOnAddExceptionClick).toHaveBeenCalledTimes(1); + }); + + it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item and only endpoint detections lists are available', () => { + const mockOnAddExceptionClick = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); + + expect(mockOnAddExceptionClick).toHaveBeenCalledTimes(1); + }); + + it('it invokes "onAddEndpointExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { + const mockOnAddExceptionClick = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper + .find('[data-test-subj="exceptionsHeaderAddExceptionPopoverBtn"] button') + .simulate('click'); + wrapper.find('[data-test-subj="addEndpointExceptionBtn"] button').simulate('click'); + + expect(mockOnAddExceptionClick).toHaveBeenCalledTimes(1); + }); + + it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { + const mockOnAddExceptionClick = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper + .find('[data-test-subj="exceptionsHeaderAddExceptionPopoverBtn"] button') + .simulate('click'); + wrapper.find('[data-test-subj="addDetectionsExceptionBtn"] button').simulate('click'); + + expect(mockOnAddExceptionClick).toHaveBeenCalledTimes(1); + }); + + it('it invokes "onFilterChange" with filter value when search used', () => { + const mockOnFilterChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper + .find('input[data-test-subj="exceptionsHeaderSearch"]') + .at(0) + .simulate('change', { + target: { value: 'host' }, + }); + + expect(mockOnFilterChange).toHaveBeenCalledWith({ + filter: { + filter: 'host', + showDetectionsList: false, + showEndpointList: false, + tags: [], + }, + pagination: {}, + }); + }); + + it('it invokes "onFilterChange" with tags values when search value includes "tags:..."', () => { + const mockOnFilterChange = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper + .find('input[data-test-subj="exceptionsHeaderSearch"]') + .at(0) + .simulate('change', { + target: { value: 'tags:malware' }, + }); + + expect(mockOnFilterChange).toHaveBeenCalledWith({ + filter: { + filter: '', + showDetectionsList: false, + showEndpointList: false, + tags: ['malware'], + }, + pagination: {}, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx new file mode 100644 index 0000000000000..92a8830310b51 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiContextMenu, + EuiButton, + EuiFilterGroup, + EuiFilterButton, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; + +import * as i18n from '../translations'; +import { ExceptionListType, Filter } from '../types'; + +interface ExceptionsViewerHeaderProps { + isInitLoading: boolean; + supportedListTypes: ExceptionListType[]; + detectionsListItems: number; + endpointListItems: number; + onFilterChange: (arg: Filter) => void; + onAddExceptionClick: (type: ExceptionListType) => void; +} + +/** + * Collection of filters and toggles for filtering exception items. + */ +const ExceptionsViewerHeaderComponent = ({ + isInitLoading, + supportedListTypes, + detectionsListItems, + endpointListItems, + onFilterChange, + onAddExceptionClick, +}: ExceptionsViewerHeaderProps): JSX.Element => { + const [filter, setFilter] = useState(''); + const [tags, setTags] = useState([]); + const [showDetectionsList, setShowDetectionsList] = useState(false); + const [showEndpointList, setShowEndpointList] = useState(false); + const [isAddExceptionMenuOpen, setAddExceptionMenuOpen] = useState(false); + + useEffect((): void => { + onFilterChange({ + filter: { filter, showDetectionsList, showEndpointList, tags }, + pagination: {}, + }); + }, [filter, tags, showDetectionsList, showEndpointList, onFilterChange]); + + const onAddExceptionDropdownClick = useCallback( + (): void => setAddExceptionMenuOpen(!isAddExceptionMenuOpen), + [setAddExceptionMenuOpen, isAddExceptionMenuOpen] + ); + + const handleDetectionsListClick = useCallback((): void => { + setShowDetectionsList(!showDetectionsList); + setShowEndpointList(false); + }, [showDetectionsList, setShowDetectionsList, setShowEndpointList]); + + const handleEndpointListClick = useCallback((): void => { + setShowEndpointList(!showEndpointList); + setShowDetectionsList(false); + }, [showEndpointList, setShowEndpointList, setShowDetectionsList]); + + const handleOnSearch = useCallback( + (event: React.ChangeEvent): void => { + const searchValue = event.target.value; + const tagsRegex = /(tags:[^\s]*)/i; + const tagsMatch = searchValue.match(tagsRegex); + const foundTags: string = tagsMatch != null ? tagsMatch[0].split(':')[1] : ''; + const filterString = tagsMatch != null ? searchValue.replace(tagsRegex, '') : searchValue; + + if (foundTags.length > 0) { + setTags(foundTags.split(',')); + } + + setFilter(filterString.trim()); + }, + [setTags, setFilter] + ); + + const onAddException = useCallback( + (type: ExceptionListType): void => { + onAddExceptionClick(type); + setAddExceptionMenuOpen(false); + }, + [onAddExceptionClick, setAddExceptionMenuOpen] + ); + + const addExceptionButtonOptions = useMemo( + (): EuiContextMenuPanelDescriptor[] => [ + { + id: 0, + items: [ + { + name: i18n.ADD_TO_ENDPOINT_LIST, + onClick: () => onAddException(ExceptionListType.ENDPOINT), + 'data-test-subj': 'addEndpointExceptionBtn', + }, + { + name: i18n.ADD_TO_DETECTIONS_LIST, + onClick: () => onAddException(ExceptionListType.DETECTION_ENGINE), + 'data-test-subj': 'addDetectionsExceptionBtn', + }, + ], + }, + ], + [onAddException] + ); + + return ( + + + + + + {supportedListTypes.length < 2 && ( + + onAddException(supportedListTypes[0])} + isDisabled={isInitLoading} + fill + > + {i18n.ADD_EXCEPTION_LABEL} + + + )} + + {supportedListTypes.length > 1 && ( + + + + + + {i18n.DETECTION_LIST} + {detectionsListItems != null ? ` (${detectionsListItems})` : ''} + + + {i18n.ENDPOINT_LIST} + {endpointListItems != null ? ` (${endpointListItems})` : ''} + + + + + + + {i18n.ADD_EXCEPTION_LABEL} +
+ } + isOpen={isAddExceptionMenuOpen} + closePopover={onAddExceptionDropdownClick} + anchorPosition="downCenter" + panelPaddingSize="none" + repositionOnScroll + > + + +
+ + + )} + + ); +}; + +ExceptionsViewerHeaderComponent.displayName = 'ExceptionsViewerHeaderComponent'; + +export const ExceptionsViewerHeader = React.memo(ExceptionsViewerHeaderComponent); + +ExceptionsViewerHeader.displayName = 'ExceptionsViewerHeader'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index 7d3b7195def80..cc8e8111064bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -9,108 +9,120 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { ExceptionItem } from './'; -import { getExceptionItemMock } from '../mocks'; - -describe('ExceptionItem', () => { - it('it renders ExceptionDetails and ExceptionEntries', () => { - const exceptionItem = getExceptionItemMock(); - - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - expect(wrapper.find('ExceptionDetails')).toHaveLength(1); - expect(wrapper.find('ExceptionEntries')).toHaveLength(1); - }); - - it('it invokes "handleEdit" when edit button clicked', () => { - const mockHandleEdit = jest.fn(); - const exceptionItem = getExceptionItemMock(); - - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); - editBtn.simulate('click'); - - expect(mockHandleEdit).toHaveBeenCalledTimes(1); +import { ExceptionsViewer } from './'; +import { ExceptionListType } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useExceptionList, useApi } from '../../../../../public/lists_plugin_deps'; +import { getExceptionListMock } from '../mocks'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../../public/lists_plugin_deps'); + +describe('ExceptionsViewer', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: {}, + application: { + getUrlForApp: () => 'some/url', + }, + }, + }); + + (useApi as jest.Mock).mockReturnValue({ + deleteExceptionItem: jest.fn().mockResolvedValue(true), + }); + + (useExceptionList as jest.Mock).mockReturnValue([ + false, + [], + [], + { + page: 1, + perPage: 20, + total: 0, + }, + jest.fn(), + ]); }); - it('it invokes "handleDelete" when delete button clicked', () => { - const mockHandleDelete = jest.fn(); - const exceptionItem = getExceptionItemMock(); - + it('it renders loader if "initLoading" is true', () => { + (useExceptionList as jest.Mock).mockReturnValue([ + true, + [], + [], + { + page: 1, + perPage: 20, + total: 0, + }, + jest.fn(), + ]); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> - ); - const editBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); - editBtn.simulate('click'); - - expect(mockHandleDelete).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-test-subj="loadingPanelAllRulesTable"]').exists()).toBeTruthy(); }); - it('it renders comment accordion closed to begin with', () => { - const mockHandleDelete = jest.fn(); - const exceptionItem = getExceptionItemMock(); - + it('it renders empty prompt if no "exceptionListMeta" passed in', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> - ); - expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(0); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); }); - it('it renders comment accordion open when showComments is true', () => { - const mockHandleDelete = jest.fn(); - const exceptionItem = getExceptionItemMock(); + it('it renders empty prompt if no exception items exist', () => { + (useExceptionList as jest.Mock).mockReturnValue([ + false, + [getExceptionListMock()], + [], + { + page: 1, + perPage: 20, + total: 0, + }, + jest.fn(), + ]); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> - ); - const commentsBtn = wrapper - .find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]') - .at(0); - commentsBtn.simulate('click'); - - expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index f4cdce62f56b3..ff52e395c3b1e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -4,96 +4,412 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { useCallback, useState, useMemo, useEffect, useReducer } from 'react'; import { - EuiPanel, + EuiEmptyPrompt, + EuiText, + EuiLink, + EuiOverlayMask, + EuiModal, + EuiModalBody, + EuiCodeBlock, EuiFlexGroup, - EuiCommentProps, - EuiCommentList, - EuiAccordion, EuiFlexItem, + EuiSpacer, } from '@elastic/eui'; -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; import styled from 'styled-components'; +import uuid from 'uuid'; -import { ExceptionDetails } from './exception_details'; -import { ExceptionEntries } from './exception_entries'; -import { getFormattedEntries, getFormattedComments } from '../helpers'; -import { FormattedEntry, ExceptionListItemSchema } from '../types'; +import * as i18n from '../translations'; +import { useStateToaster } from '../../toasters'; +import { useKibana } from '../../../../common/lib/kibana'; +import { Panel } from '../../../../common/components/panel'; +import { Loader } from '../../../../common/components/loader'; +import { ExceptionsViewerHeader } from './exceptions_viewer_header'; +import { + ExceptionListType, + ExceptionListItemSchema, + ApiProps, + Filter, + SetExceptionsProps, +} from '../types'; +import { allExceptionItemsReducer, State } from './reducer'; +import { + useExceptionList, + ExceptionIdentifiers, + useApi, +} from '../../../../../public/lists_plugin_deps'; +import { ExceptionItem } from './exception_item'; +import { AndOrBadge } from '../../and_or_badge'; +import { ExceptionsViewerPagination } from './exceptions_pagination'; +import { + UtilityBar, + UtilityBarSection, + UtilityBarGroup, + UtilityBarText, + UtilityBarAction, +} from '../../utility_bar'; -const MyFlexItem = styled(EuiFlexItem)` - &.comments--show { - padding: ${({ theme }) => theme.eui.euiSize}; - border-top: ${({ theme }) => `${theme.eui.euiBorderThin}`} +const StyledText = styled(EuiText)` + font-style: italic; +`; +const MyExceptionsContainer = styled.div` + height: 600px; + overflow: hidden; `; -interface ExceptionItemProps { - exceptionItem: ExceptionListItemSchema; +const initialState: State = { + filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] }, + pagination: { + pageIndex: 0, + pageSize: 20, + totalItemCount: 0, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }, + endpointList: null, + detectionsList: null, + allExceptions: [], + exceptions: [], + exceptionToEdit: null, + loadingItemIds: [], + isModalOpen: false, +}; + +enum ModalAction { + CREATE = 'CREATE', + EDIT = 'EDIT', +} + +interface ExceptionsViewerProps { + ruleId: string; + exceptionListsMeta: ExceptionIdentifiers[]; + availableListTypes: ExceptionListType[]; commentsAccordionId: string; - handleDelete: ({ id }: { id: string }) => void; - handleEdit: (item: ExceptionListItemSchema) => void; + onAssociateList?: (listId: string) => void; } -const ExceptionItemComponent = ({ - exceptionItem, +const ExceptionsViewerComponent = ({ + ruleId, + exceptionListsMeta, + availableListTypes, + onAssociateList, commentsAccordionId, - handleDelete, - handleEdit, -}: ExceptionItemProps): JSX.Element => { - const [entryItems, setEntryItems] = useState([]); - const [showComments, setShowComments] = useState(false); +}: ExceptionsViewerProps): JSX.Element => { + const { services } = useKibana(); + const [, dispatchToaster] = useStateToaster(); + const [initLoading, setInitLoading] = useState(true); + const onDispatchToaster = useCallback( + ({ title, color, iconType }) => (): void => { + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title, + color, + iconType, + }, + }); + }, + [dispatchToaster] + ); + const { deleteExceptionItem } = useApi(services.http); + const [ + { + endpointList, + detectionsList, + exceptions, + filterOptions, + pagination, + loadingItemIds, + isModalOpen, + }, + dispatch, + ] = useReducer(allExceptionItemsReducer(), initialState); - useEffect((): void => { - const formattedEntries = getFormattedEntries(exceptionItem.entries); - setEntryItems(formattedEntries); - }, [exceptionItem.entries]); + // TODO: Update icky typing once api updated + const setExceptions = useCallback( + ({ + lists: newLists, + exceptions: newExceptions, + pagination: newPagination, + }: SetExceptionsProps) => { + dispatch({ + type: 'setExceptions', + lists: newLists, + exceptions: (newExceptions as unknown) as ExceptionListItemSchema[], + pagination: newPagination, + }); + }, + [dispatch] + ); + const [loadingList, , , , fetchList] = useExceptionList({ + http: services.http, + lists: exceptionListsMeta, + filterOptions, + pagination: { + page: pagination.pageIndex + 1, + perPage: pagination.pageSize, + total: pagination.totalItemCount, + }, + dispatchListsInReducer: setExceptions, + onError: onDispatchToaster({ + color: 'danger', + title: i18n.FETCH_LIST_ERROR, + iconType: 'alert', + }), + }); - const onDelete = useCallback((): void => { - handleDelete({ id: exceptionItem.id }); - }, [handleDelete, exceptionItem]); + const setIsModalOpen = useCallback( + (isOpen: boolean): void => { + dispatch({ + type: 'updateModalOpen', + isOpen, + }); + }, + [dispatch] + ); + + const onFetchList = useCallback((): void => { + if (fetchList != null) { + fetchList(); + } + }, [fetchList]); + + const onFiltersChange = useCallback( + ({ filter, pagination: pag }: Filter): void => { + dispatch({ + type: 'updateFilterOptions', + filterOptions: filter, + pagination: pag, + }); + }, + [dispatch] + ); + + const onAddException = useCallback( + (type: ExceptionListType): void => { + setIsModalOpen(true); + }, + [setIsModalOpen] + ); + + const onEditExceptionItem = useCallback( + (exception: ExceptionListItemSchema): void => { + // TODO: Added this just for testing. Update + // modal state logic as needed once ready + dispatch({ + type: 'updateExceptionToEdit', + exception, + }); - const onEdit = useCallback((): void => { - handleEdit(exceptionItem); - }, [handleEdit, exceptionItem]); + setIsModalOpen(true); + }, + [setIsModalOpen] + ); - const onCommentsClick = useCallback((): void => { - setShowComments(!showComments); - }, [setShowComments, showComments]); + const onCloseExceptionModal = useCallback( + ({ actionType, listId }): void => { + setIsModalOpen(false); - const formattedComments = useMemo((): EuiCommentProps[] => { - return getFormattedComments(exceptionItem.comments); - }, [exceptionItem]); + // TODO: This callback along with fetchList can probably get + // passed to the modal for it to call itself maybe + if (actionType === ModalAction.CREATE && listId != null && onAssociateList != null) { + onAssociateList(listId); + } + + onFetchList(); + }, + [setIsModalOpen, onFetchList, onAssociateList] + ); + + const setLoadingItemIds = useCallback( + (items: ApiProps[]): void => { + dispatch({ + type: 'updateLoadingItemIds', + items, + }); + }, + [dispatch] + ); + + const onDeleteException = useCallback( + ({ id, namespaceType }: ApiProps) => { + deleteExceptionItem({ + id, + namespaceType, + onSuccess: () => { + setLoadingItemIds(loadingItemIds.filter((t) => t.id !== id)); + onFetchList(); + }, + onError: () => { + const dispatchToasterError = onDispatchToaster({ + color: 'danger', + title: i18n.DELETE_EXCEPTION_ERROR, + iconType: 'alert', + }); + + dispatchToasterError(); + setLoadingItemIds(loadingItemIds.filter((t) => t.id !== id)); + }, + }); + }, + [setLoadingItemIds, deleteExceptionItem, loadingItemIds, onFetchList, onDispatchToaster] + ); + + // Logic for initial render + useEffect((): void => { + if (initLoading && !loadingList && (exceptions.length === 0 || exceptions != null)) { + setInitLoading(false); + } + }, [initLoading, exceptions, loadingList]); + + const ruleSettingsUrl = useMemo((): string => { + return services.application.getUrlForApp( + `security#/detections/rules/id/${encodeURI(ruleId)}/edit` + ); + }, [ruleId, services.application]); + + const exceptionsSubtext = useMemo((): JSX.Element => { + if (filterOptions.showEndpointList) { + return ( + + + + ), + }} + /> + ); + } else if (filterOptions.showDetectionsList) { + return ( + + + + ), + }} + /> + ); + } else { + return <>; + } + }, [filterOptions.showEndpointList, filterOptions.showDetectionsList, ruleSettingsUrl]); + + const showEmpty = useMemo((): boolean => { + return !initLoading && !loadingList && exceptions.length === 0; + }, [initLoading, exceptions.length, loadingList]); return ( - - - - - + {isModalOpen && ( + + + + + {`Modal goes here`} + + + + + )} + + + {initLoading && } + + + + {(filterOptions.showEndpointList || filterOptions.showDetectionsList) && ( + <> + + {exceptionsSubtext} + + )} + + + + + + + + {i18n.SHOWING_EXCEPTIONS(pagination.totalItemCount ?? 0)} + + + + + + {i18n.REFRESH} + + + + + + + + + {showEmpty && ( + {i18n.EXCEPTION_EMPTY_PROMPT_TITLE}} + body={

{i18n.EXCEPTION_EMPTY_PROMPT_BODY}

} + data-test-subj="exceptionsEmptyPrompt" /> - + )} + + + + + {!initLoading && + exceptions.length > 0 && + exceptions.map((exception, index) => ( + + {index !== 0 && ( + <> + + + + )} + + + ))} -
- - - - - -
-
+ + + + ); }; -ExceptionItemComponent.displayName = 'ExceptionItemComponent'; +ExceptionsViewerComponent.displayName = 'ExceptionsViewerComponent'; -export const ExceptionItem = React.memo(ExceptionItemComponent); +export const ExceptionsViewer = React.memo(ExceptionsViewerComponent); -ExceptionItem.displayName = 'ExceptionItem'; +ExceptionsViewer.displayName = 'ExceptionsViewer'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts new file mode 100644 index 0000000000000..40d5bb5f0a297 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { + ApiProps, + FilterOptions, + ExceptionsPagination, + ExceptionListItemSchema, + Pagination, +} from '../types'; +import { ExceptionList } from '../../../../../public/lists_plugin_deps'; + +export interface State { + filterOptions: FilterOptions; + pagination: ExceptionsPagination; + endpointList: ExceptionList | null; + detectionsList: ExceptionList | null; + allExceptions: ExceptionListItemSchema[]; + exceptions: ExceptionListItemSchema[]; + exceptionToEdit: ExceptionListItemSchema | null; + loadingItemIds: ApiProps[]; + isModalOpen: boolean; +} + +export type Action = + | { + type: 'setExceptions'; + lists: ExceptionList[]; + exceptions: ExceptionListItemSchema[]; + pagination: Pagination; + } + | { + type: 'updateFilterOptions'; + filterOptions: Partial; + pagination: Partial; + } + | { type: 'updateModalOpen'; isOpen: boolean } + | { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema } + | { type: 'updateLoadingItemIds'; items: ApiProps[] }; + +export const allExceptionItemsReducer = () => (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptions': { + const endpointList = action.lists.filter((t) => t.type === 'endpoint'); + const detectionsList = action.lists.filter((t) => t.type === 'detection'); + + return { + ...state, + endpointList: state.filterOptions.showDetectionsList + ? state.endpointList + : endpointList[0] ?? null, + detectionsList: state.filterOptions.showEndpointList + ? state.detectionsList + : detectionsList[0] ?? null, + pagination: { + ...state.pagination, + pageIndex: action.pagination.page - 1, + pageSize: action.pagination.perPage, + totalItemCount: action.pagination.total, + }, + allExceptions: action.exceptions, + exceptions: action.exceptions, + }; + } + case 'updateFilterOptions': { + const returnState = { + ...state, + filterOptions: { + ...state.filterOptions, + ...action.filterOptions, + }, + pagination: { + ...state.pagination, + ...action.pagination, + }, + }; + + if (action.filterOptions.showEndpointList) { + const exceptions = state.allExceptions.filter((t) => t._tags.includes('endpoint')); + + return { + ...returnState, + exceptions, + }; + } else if (action.filterOptions.showDetectionsList) { + const exceptions = state.allExceptions.filter((t) => t._tags.includes('detection')); + + return { + ...returnState, + exceptions, + }; + } else { + return { + ...returnState, + exceptions: state.allExceptions, + }; + } + } + case 'updateLoadingItemIds': { + return { + ...state, + loadingItemIds: [...state.loadingItemIds, ...action.items], + }; + } + case 'updateExceptionToEdit': { + return { + ...state, + exceptionToEdit: action.exception, + }; + } + case 'updateModalOpen': { + return { + ...state, + isModalOpen: action.isOpen, + }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap deleted file mode 100644 index c0e0988168384..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"
"`; - -exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `"
"`; diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index b45207ab47c7a..52a97e26550ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -82,7 +82,6 @@ describe('Matrix Histogram Component', () => { }); describe('on initial load', () => { test('it renders MatrixLoader', () => { - expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.find('MatrixLoader').exists()).toBe(true); }); }); @@ -117,7 +116,6 @@ describe('Matrix Histogram Component', () => { wrapper.update(); }); test('it renders no MatrixLoader', () => { - expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.find(`MatrixLoader`).exists()).toBe(false); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index a2c7e72e51a9c..26775608637c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -115,32 +115,28 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiCardSpacing": "16px", "euiCheckBoxSize": "16px", - "euiCodeBlockAdditionBackgroundColor": "#144212", - "euiCodeBlockAdditionColor": "#e6e1dc", - "euiCodeBlockAttributeColor": "#80cbbf", + "euiCodeBlockAdditionColor": "#54b399", + "euiCodeBlockAttributeColor": "inherit", "euiCodeBlockBackgroundColor": "#25262e", - "euiCodeBlockBuiltInColor": "#0086b3", "euiCodeBlockColor": "#dfe5ef", - "euiCodeBlockCommentColor": "#656565", - "euiCodeBlockDeletionBackgroundColor": "#660000", - "euiCodeBlockDeletionColor": "#e6e1dc", - "euiCodeBlockFunctionTitleColor": "#75a5ff", - "euiCodeBlockKeywordColor": "#c792ea", - "euiCodeBlockMetaColor": "#75a5ff", - "euiCodeBlockNameColor": "#e06c75", - "euiCodeBlockNumberColor": "#f77669", - "euiCodeBlockParamsColor": "#eefff7", - "euiCodeBlockRegexpColor": "#009926", - "euiCodeBlockSectionColor": "#ffc66d", + "euiCodeBlockCommentColor": "#8d919a", + "euiCodeBlockDeletionColor": "#ff6666", + "euiCodeBlockFunctionTitleColor": "inherit", + "euiCodeBlockKeywordColor": "#a184c2", + "euiCodeBlockMetaColor": "#8d919a", + "euiCodeBlockNameColor": "#6092c0", + "euiCodeBlockNumberColor": "#54b399", + "euiCodeBlockParamsColor": "inherit", + "euiCodeBlockSectionColor": "#e7664c", "euiCodeBlockSelectedBackgroundColor": "inherit", - "euiCodeBlockSelectorClassColor": "#ffcb68", - "euiCodeBlockSelectorIdColor": "#f77669", - "euiCodeBlockSelectorTagColor": "#c792ea", - "euiCodeBlockStringColor": "#c3e88d", - "euiCodeBlockSymbolColor": "#c792ea", - "euiCodeBlockTagColor": "#abb2bf", - "euiCodeBlockTitleColor": "#75a5ff", - "euiCodeBlockTypeColor": "#da4939", + "euiCodeBlockSelectorClassColor": "inherit", + "euiCodeBlockSelectorIdColor": "inherit", + "euiCodeBlockSelectorTagColor": "inherit", + "euiCodeBlockStringColor": "#d77092", + "euiCodeBlockSymbolColor": "#e7664c", + "euiCodeBlockTagColor": "#6092c0", + "euiCodeBlockTitleColor": "#da8b45", + "euiCodeBlockTypeColor": "#6092c0", "euiCodeFontFamily": "'Roboto Mono', 'Consolas', 'Menlo', 'Courier', monospace", "euiCollapsibleNavGroupDarkBackgroundColor": "#131317", "euiCollapsibleNavGroupDarkHighContrastColor": "#1ba9f5", @@ -230,6 +226,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "warning": "#ffce7a", }, "euiFilePickerTallHeight": "128px", + "euiFlyoutBorder": "1px solid #343741", "euiFocusBackgroundColor": "#232635", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 0d006d18cc49b..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,958 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Stat Items Component disable charts it renders the default widget 1`] = ` - - - - - -
- -
- -
- -
- -
- -
- HOSTS -
-
-
-
- -
- - - - - - - - -
-
-
-
- -
- - -
- -
- - -
- - -

- - - — - - - -

-
-
-
-
-
-
-
-
-
-
-
-
- -
- -
- -
- -
- - - - - -`; - -exports[`Stat Items Component disable charts it renders the default widget 2`] = ` - - - - - -
- -
- -
- -
- -
- -
- HOSTS -
-
-
-
- -
- - - - - - - - -
-
-
-
- -
- - -
- -
- 0 - - -
- - -

- - - — - - - -

-
-
-
-
-
-
-
-
-
-
-
-
- -
- -
- -
- -
- - - - - -`; - -exports[`Stat Items Component rendering kpis with charts it renders the default widget 1`] = ` - - - - -
- -
- -
- -
- -
- -
- UNIQUE_PRIVATE_IPS -
-
-
-
- -
- - - - - - - - -
-
-
-
- -
- - -
- -
- - -
- -
- -
- - - - -
- - -

- 1,714 - - Source -

-
-
-
-
-
-
- -
- - - - -
- -
- - -
- -
- -
- - - - -
- - -

- 2,359 - - Dest. -

-
-
-
-
-
-
- -
- - -
-
- -
-
- -
- - -
- -
- -
- - - - -
- -
- -
- - -
- -
- -
- -
-
-
- - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx index f46697834d0e3..d81d23438bfd2 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx @@ -91,10 +91,6 @@ describe('Stat Items Component', () => { ), ], ])('disable charts', (wrapper) => { - test('it renders the default widget', () => { - expect(wrapper).toMatchSnapshot(); - }); - test('should render titles', () => { expect(wrapper.find('[data-test-subj="stat-title"]')).toBeTruthy(); }); @@ -180,9 +176,6 @@ describe('Stat Items Component', () => { ); }); - test('it renders the default widget', () => { - expect(wrapper).toMatchSnapshot(); - }); test('should handle multiple titles', () => { expect(wrapper.find('[data-test-subj="stat-title"]')).toHaveLength(2); diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 4af39ade70d25..3e84e4035e15e 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -229,6 +229,7 @@ export const mockGlobalState: State = { status: TimelineStatus.active, }, }, + insertTimeline: null, }, /** * These state's are wrapped in `Immutable`, but for compatibility with the overall app architecture, diff --git a/x-pack/plugins/security_solution/public/common/mock/index.ts b/x-pack/plugins/security_solution/public/common/mock/index.ts index bdad0ab1712ab..30eb4c63f40b8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/index.ts +++ b/x-pack/plugins/security_solution/public/common/mock/index.ts @@ -15,3 +15,4 @@ export * from './test_providers'; export * from './utils'; export * from './mock_ecs'; export * from './timeline_results'; +export * from './kibana_react'; diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index 350b53ef52f4e..113bfaa860f00 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -5,10 +5,18 @@ */ export { + useApi, useExceptionList, usePersistExceptionItem, usePersistExceptionList, + ExceptionIdentifiers, + ExceptionList, mockNewExceptionItem, mockNewExceptionList, } from '../../lists/public'; -export { ExceptionListItemSchema, Entries } from '../../lists/common/schemas'; +export { + ExceptionListSchema, + ExceptionListItemSchema, + Entries, + NamespaceType, +} from '../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx index ebcb46c3fb5b7..db5196bfc4eb4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx @@ -12,30 +12,21 @@ import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app import { CustomConfigureDatasourceContent, CustomConfigureDatasourceProps, - NewDatasource, } from '../../../../../../../ingest_manager/public'; import { getManagementUrl } from '../../../..'; -type DatasourceWithId = NewDatasource & { id: string }; - /** * Exports Endpoint-specific datasource configuration instructions * for use in the Ingest app create / edit datasource config */ export const ConfigureEndpointDatasource = memo( - ({ - from, - datasource, - }: { - from: string; - datasource: CustomConfigureDatasourceProps['datasource']; - }) => { + ({ from, datasourceId }: CustomConfigureDatasourceProps) => { const { services } = useKibana(); let policyUrl = ''; - if (from === 'edit') { + if (from === 'edit' && datasourceId) { policyUrl = getManagementUrl({ name: 'policyDetails', - policyId: (datasource as DatasourceWithId).id, + policyId: datasourceId, }); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index f4c4b36ce153f..d1f7da91bd6fa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -68,11 +68,13 @@ export const PolicyDetails = React.memo(() => { } ), body: ( - + + + ), }); } else { @@ -116,7 +118,7 @@ export const PolicyDetails = React.memo(() => { ) : policyApiError ? ( - {policyApiError?.message} + {policyApiError?.message} ) : null} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx index e5f3b2c7e8b7e..9ceade5d0264c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx @@ -5,23 +5,25 @@ */ import React, { useCallback, useMemo } from 'react'; -import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; +import { EuiCheckbox, EuiCheckboxProps, htmlIdGenerator } from '@elastic/eui'; import { useDispatch } from 'react-redux'; - import { usePolicyDetailsSelector } from '../../policy_hooks'; import { policyConfig } from '../../../store/policy_details/selectors'; import { PolicyDetailsAction } from '../../../store/policy_details'; import { UIPolicyConfig } from '../../../../../../../common/endpoint/types'; +type EventsCheckboxProps = Omit & { + name: string; + setter: (config: UIPolicyConfig, checked: boolean) => UIPolicyConfig; + getter: (config: UIPolicyConfig) => boolean; +}; + export const EventsCheckbox = React.memo(function ({ name, setter, getter, -}: { - name: string; - setter: (config: UIPolicyConfig, checked: boolean) => UIPolicyConfig; - getter: (config: UIPolicyConfig) => boolean; -}) { + ...otherProps +}: EventsCheckboxProps) { const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const selected = getter(policyDetailsConfig); const dispatch = useDispatch<(action: PolicyDetailsAction) => void>(); @@ -44,6 +46,7 @@ export const EventsCheckbox = React.memo(function ({ label={name} checked={selected} onChange={handleCheckboxChange} + {...otherProps} /> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx index d0ddd5cb6fe2f..d7bae0d2e6bad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -73,6 +73,7 @@ export const LinuxEvents = React.memo(() => { setIn(config)(item.os)('events')(item.protectionField)(checked) } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx index e2d6b70d33415..37709ff608857 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -73,6 +73,7 @@ export const MacEvents = React.memo(() => { setIn(config)(item.os)('events')(item.protectionField)(checked) } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx index 23f33cb6fd86f..3c7ecae0d9b4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -113,6 +113,7 @@ export const WindowsEvents = React.memo(() => { setIn(config)(item.os)('events')(item.protectionField)(checked) } diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index ab8a24889e9bf..8ad32d6e2cad0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -33,14 +33,14 @@ const StatefulFlyoutHeader = React.memo( associateNote, createTimeline, description, - isFavorite, isDataInTimeline, isDatepickerLocked, - title, + isFavorite, noteIds, notesById, status, timelineId, + title, toggleLock, updateDescription, updateIsFavorite, @@ -61,15 +61,15 @@ const StatefulFlyoutHeader = React.memo( isDataInTimeline={isDataInTimeline} isDatepickerLocked={isDatepickerLocked} isFavorite={isFavorite} - title={title} noteIds={noteIds} status={status} timelineId={timelineId} + title={title} toggleLock={toggleLock} updateDescription={updateDescription} updateIsFavorite={updateIsFavorite} - updateTitle={updateTitle} updateNote={updateNote} + updateTitle={updateTitle} usersViewing={usersViewing} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx index 9a52e9cf4e538..e5fc8b68b1cb7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx @@ -10,6 +10,27 @@ import React from 'react'; import { TestProviders } from '../../../../common/mock'; import { FlyoutHeaderWithCloseButton } from '.'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), +})); +jest.mock('../../../../common/lib/kibana', () => ({ + ...jest.requireActual('../../../../common/lib/kibana'), + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }), + useUiSetting$: jest.fn().mockReturnValue([]), + useGetUserSavedObjectPermissions: jest.fn(), +})); + describe('FlyoutHeaderWithCloseButton', () => { test('renders correctly against snapshot', () => { const EmptyComponent = shallow( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 391aaba17ae3a..22f89ffc6927e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -115,32 +115,28 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` }, "euiCardSpacing": "16px", "euiCheckBoxSize": "16px", - "euiCodeBlockAdditionBackgroundColor": "#144212", - "euiCodeBlockAdditionColor": "#e6e1dc", - "euiCodeBlockAttributeColor": "#80cbbf", + "euiCodeBlockAdditionColor": "#54b399", + "euiCodeBlockAttributeColor": "inherit", "euiCodeBlockBackgroundColor": "#25262e", - "euiCodeBlockBuiltInColor": "#0086b3", "euiCodeBlockColor": "#dfe5ef", - "euiCodeBlockCommentColor": "#656565", - "euiCodeBlockDeletionBackgroundColor": "#660000", - "euiCodeBlockDeletionColor": "#e6e1dc", - "euiCodeBlockFunctionTitleColor": "#75a5ff", - "euiCodeBlockKeywordColor": "#c792ea", - "euiCodeBlockMetaColor": "#75a5ff", - "euiCodeBlockNameColor": "#e06c75", - "euiCodeBlockNumberColor": "#f77669", - "euiCodeBlockParamsColor": "#eefff7", - "euiCodeBlockRegexpColor": "#009926", - "euiCodeBlockSectionColor": "#ffc66d", + "euiCodeBlockCommentColor": "#8d919a", + "euiCodeBlockDeletionColor": "#ff6666", + "euiCodeBlockFunctionTitleColor": "inherit", + "euiCodeBlockKeywordColor": "#a184c2", + "euiCodeBlockMetaColor": "#8d919a", + "euiCodeBlockNameColor": "#6092c0", + "euiCodeBlockNumberColor": "#54b399", + "euiCodeBlockParamsColor": "inherit", + "euiCodeBlockSectionColor": "#e7664c", "euiCodeBlockSelectedBackgroundColor": "inherit", - "euiCodeBlockSelectorClassColor": "#ffcb68", - "euiCodeBlockSelectorIdColor": "#f77669", - "euiCodeBlockSelectorTagColor": "#c792ea", - "euiCodeBlockStringColor": "#c3e88d", - "euiCodeBlockSymbolColor": "#c792ea", - "euiCodeBlockTagColor": "#abb2bf", - "euiCodeBlockTitleColor": "#75a5ff", - "euiCodeBlockTypeColor": "#da4939", + "euiCodeBlockSelectorClassColor": "inherit", + "euiCodeBlockSelectorIdColor": "inherit", + "euiCodeBlockSelectorTagColor": "inherit", + "euiCodeBlockStringColor": "#d77092", + "euiCodeBlockSymbolColor": "#e7664c", + "euiCodeBlockTagColor": "#6092c0", + "euiCodeBlockTitleColor": "#da8b45", + "euiCodeBlockTypeColor": "#6092c0", "euiCodeFontFamily": "'Roboto Mono', 'Consolas', 'Menlo', 'Courier', monospace", "euiCollapsibleNavGroupDarkBackgroundColor": "#131317", "euiCollapsibleNavGroupDarkHighContrastColor": "#1ba9f5", @@ -230,6 +226,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "warning": "#ffce7a", }, "euiFilePickerTallHeight": "128px", + "euiFlyoutBorder": "1px solid #343741", "euiFocusBackgroundColor": "#232635", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 92a8fc9338877..344f10dfdb35e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -42,6 +42,7 @@ import { } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { useTimelineTypes } from './use_timeline_types'; +import { disableTemplate } from '../../../../common/constants'; interface OwnProps { apolloClient: ApolloClient; @@ -52,12 +53,6 @@ interface OwnProps { onOpenTimeline?: (timeline: TimelineModel) => void; } -/** - * CreateTemplateTimelineBtn - * Remove the comment here to enable template timeline - */ -export const disableTemplate = true; - export type OpenTimelineOwnProps = OwnProps & Pick< OpenTimelineProps, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 4ed0b52fc0f14..4e6cce618880b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -606,19 +606,40 @@ exports[`Timeline rendering renders correctly against snapshot 1`] = ` }, "filters": Array [], "uiSettings": Object { - "get": [Function], - "get$": [MockFunction], - "getAll": [MockFunction], - "getSaved$": [MockFunction], - "getUpdate$": [MockFunction], - "getUpdateErrors$": [MockFunction], - "isCustom": [MockFunction], - "isDeclared": [MockFunction], - "isDefault": [MockFunction], - "isOverridden": [MockFunction], - "overrideLocalDefault": [MockFunction], - "remove": [MockFunction], - "set": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "query:allowLeadingWildcards", + ], + Array [ + "query:queryString:options", + ], + Array [ + "courier:ignoreFilterIfFieldNotInIndex", + ], + Array [ + "dateFormat:tz", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, }, "updated$": Subject { "_isScalar": false, @@ -826,7 +847,7 @@ exports[`Timeline rendering renders correctly against snapshot 1`] = ` } inputId="timeline" /> - - + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 581fa125d21e2..3110129867628 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -26,7 +26,13 @@ import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { StatefulTimeline, Props as StatefulTimelineProps } from './index'; import { Timeline } from './timeline'; -jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; jest.mock('use-resize-observer/polyfilled'); @@ -34,7 +40,11 @@ mockUseResizeObserver.mockImplementation(() => ({})); const mockUseSignalIndex: jest.Mock = useSignalIndex as jest.Mock; jest.mock('../../../alerts/containers/detection_engine/alerts/use_signal_index'); - +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), +})); +jest.mock('../flyout/header_with_close_button'); describe('StatefulTimeline', () => { let props = {} as StatefulTimelineProps; const sort: Sort = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx index 0a70413b7ea29..2ffbae1f7eb5c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx @@ -17,6 +17,14 @@ jest.mock('react-redux', () => { return { ...reactRedux, useDispatch: () => mockDispatch, + useSelector: jest + .fn() + .mockReturnValueOnce({ + timelineId: 'timeline-id', + timelineSavedObjectId: '34578-3497-5893-47589-34759', + timelineTitle: 'Timeline title', + }) + .mockReturnValue(null), }; }); const mockLocation = { @@ -25,17 +33,6 @@ const mockLocation = { search: '', state: '', }; -const mockLocationWithState = { - ...mockLocation, - state: { - insertTimeline: { - timelineId: 'timeline-id', - timelineSavedObjectId: '34578-3497-5893-47589-34759', - timelineTitle: 'Timeline title', - }, - }, -}; - const onTimelineChange = jest.fn(); const defaultProps = { isDisabled: false, @@ -43,18 +40,21 @@ const defaultProps = { }; describe('Insert timeline popover ', () => { - beforeEach(() => { - jest.resetAllMocks(); + afterEach(() => { + jest.clearAllMocks(); }); it('should insert a timeline when passed in the router state', () => { - jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocationWithState); mount(); - expect(mockDispatch).toBeCalledWith({ + expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { id: 'timeline-id', show: false }, type: 'x-pack/security_solution/local/timeline/SHOW_TIMELINE', }); expect(onTimelineChange).toBeCalledWith('Timeline title', '34578-3497-5893-47589-34759'); + expect(mockDispatch.mock.calls[1][0]).toEqual({ + payload: null, + type: 'x-pack/security_solution/local/timeline/SET_INSERT_TIMELINE', + }); }); it('should do nothing when router state', () => { jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx index ed4d742bb8b4d..de199d9a1cc2e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx @@ -6,14 +6,15 @@ import { EuiButtonIcon, EuiPopover, EuiSelectableOption, EuiToolTip } from '@elastic/eui'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { OpenTimelineResult } from '../../open_timeline/types'; import { SelectableTimeline } from '../selectable_timeline'; import * as i18n from '../translations'; -import { timelineActions } from '../../../../timelines/store/timeline'; +import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { TimelineType } from '../../../../../common/types/timeline'; +import { State } from '../../../../common/store'; +import { setInsertTimeline } from '../../../store/timeline/actions'; interface InsertTimelinePopoverProps { isDisabled: boolean; @@ -21,14 +22,6 @@ interface InsertTimelinePopoverProps { onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; } -interface RouterState { - insertTimeline: { - timelineId: string; - timelineSavedObjectId: string; - timelineTitle: string; - }; -} - type Props = InsertTimelinePopoverProps; export const InsertTimelinePopoverComponent: React.FC = ({ @@ -38,22 +31,18 @@ export const InsertTimelinePopoverComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const { state } = useLocation(); - const [routerState, setRouterState] = useState(state ?? null); + const insertTimeline = useSelector((state: State) => { + return timelineSelectors.selectInsertTimeline(state); + }); useEffect(() => { - if (routerState && routerState.insertTimeline) { - dispatch( - timelineActions.showTimeline({ id: routerState.insertTimeline.timelineId, show: false }) - ); - onTimelineChange( - routerState.insertTimeline.timelineTitle, - routerState.insertTimeline.timelineSavedObjectId - ); - setRouterState(null); + if (insertTimeline != null) { + dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false })); + onTimelineChange(insertTimeline.timelineTitle, insertTimeline.timelineSavedObjectId); + dispatch(setInsertTimeline(null)); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [routerState]); + }, [insertTimeline, dispatch]); const handleClosePopover = useCallback(() => { setIsPopoverOpen(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx index 0f9e64082a603..6269bc1b4a1a3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx @@ -17,7 +17,7 @@ export const useInsertTimeline = (form: FormHook, fieldNa }); const handleOnTimelineChange = useCallback( (title: string, id: string | null) => { - const builtLink = `${basePath}/app/siem#/timelines?timeline=(id:'${id}',isOpen:!t)`; + const builtLink = `${basePath}/app/security#/timelines?timeline=(id:'${id}',isOpen:!t)`; const currentValue = form.getFormData()[fieldName]; const newValue: string = [ currentValue.slice(0, cursorPosition.start), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx new file mode 100644 index 0000000000000..fb91bbd5a1124 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { NewTimeline, NewTimelineProps } from './helpers'; +import { useCreateTimelineButton } from './use_create_timeline'; + +jest.mock('./use_create_timeline', () => ({ + useCreateTimelineButton: jest.fn(), +})); + +jest.mock('../../../../common/lib/kibana', () => { + return { + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }), + }; +}); + +describe('NewTimeline', () => { + const mockGetButton = jest.fn(); + + const props: NewTimelineProps = { + closeGearMenu: jest.fn(), + timelineId: 'mockTimelineId', + title: 'mockTitle', + }; + + describe('render', () => { + describe('default', () => { + beforeAll(() => { + (useCreateTimelineButton as jest.Mock).mockReturnValue({ getButton: mockGetButton }); + shallow(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('it should not render outline', () => { + expect(mockGetButton.mock.calls[0][0].outline).toEqual(false); + }); + + test('it should render title', () => { + expect(mockGetButton.mock.calls[0][0].title).toEqual(props.title); + }); + }); + + describe('show outline', () => { + beforeAll(() => { + (useCreateTimelineButton as jest.Mock).mockReturnValue({ getButton: mockGetButton }); + + const enableOutline = { + ...props, + outline: true, + }; + mount(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('it should render outline', () => { + expect(mockGetButton.mock.calls[0][0].outline).toEqual(true); + }); + + test('it should render title', () => { + expect(mockGetButton.mock.calls[0][0].title).toEqual(props.title); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 38a85a7a92631..00a0e57324841 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -21,18 +21,28 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; import { useHistory } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { + TimelineTypeLiteral, + TimelineStatus, + TimelineType, +} from '../../../../../common/types/timeline'; + +import { SiemPageName } from '../../../../app/types'; +import { timelineSelectors } from '../../../../timelines/store/timeline'; +import { State } from '../../../../common/store'; +import { useKibana } from '../../../../common/lib/kibana'; import { Note } from '../../../../common/lib/note'; + import { Notes } from '../../notes'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; + import { NOTES_PANEL_WIDTH } from './notes_size'; import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles'; import * as i18n from './translations'; -import { SiemPageName } from '../../../../app/types'; -import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { State } from '../../../../common/store'; +import { setInsertTimeline } from '../../../store/timeline/actions'; +import { useCreateTimelineButton } from './use_create_timeline'; export const historyToolTip = 'The chronological history of actions related to this timeline'; export const streamLiveToolTip = 'Update the Timeline as new data arrives'; @@ -44,7 +54,15 @@ const NotesCountBadge = (styled(EuiBadge)` NotesCountBadge.displayName = 'NotesCountBadge'; -type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void; +type CreateTimeline = ({ + id, + show, + timelineType, +}: { + id: string; + show?: boolean; + timelineType?: TimelineTypeLiteral; +}) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; @@ -127,23 +145,25 @@ interface NewCaseProps { export const NewCase = React.memo( ({ onClosePopover, timelineId, timelineStatus, timelineTitle }) => { const history = useHistory(); + const dispatch = useDispatch(); const { savedObjectId } = useSelector((state: State) => timelineSelectors.selectTimeline(state, timelineId) ); + const handleClick = useCallback(() => { onClosePopover(); history.push({ pathname: `/${SiemPageName.case}/create`, - state: { - insertTimeline: { - timelineId, - timelineSavedObjectId: savedObjectId, - timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, - }, - }, }); + dispatch( + setInsertTimeline({ + timelineId, + timelineSavedObjectId: savedObjectId, + timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onClosePopover, history, timelineId, timelineTitle]); + }, [dispatch, onClosePopover, history, timelineId, timelineTitle]); return ( ( ); NewCase.displayName = 'NewCase'; -interface NewTimelineProps { - createTimeline: CreateTimeline; +interface ExistingCaseProps { onClosePopover: () => void; - timelineId: string; + onOpenCaseModal: () => void; + timelineStatus: TimelineStatus; } - -export const NewTimeline = React.memo( - ({ createTimeline, onClosePopover, timelineId }) => { +export const ExistingCase = React.memo( + ({ onClosePopover, onOpenCaseModal, timelineStatus }) => { const handleClick = useCallback(() => { - createTimeline({ id: timelineId, show: true }); onClosePopover(); - }, [createTimeline, timelineId, onClosePopover]); + onOpenCaseModal(); + }, [onOpenCaseModal, onClosePopover]); return ( - - {i18n.NEW_TIMELINE} - + <> + + {i18n.ATTACH_TIMELINE_TO_EXISTING_CASE} + + ); } ); +ExistingCase.displayName = 'ExistingCase'; + +export interface NewTimelineProps { + createTimeline?: CreateTimeline; + closeGearMenu?: () => void; + outline?: boolean; + timelineId: string; + title?: string; +} + +export const NewTimeline = React.memo( + ({ closeGearMenu, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => { + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud; + + const { getButton } = useCreateTimelineButton({ + timelineId, + timelineType: TimelineType.default, + closeGearMenu, + }); + const button = getButton({ outline, title }); + + return capabilitiesCanUserCRUD ? button : null; + } +); NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 8bdec78ec8da2..505d0b8cba854 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -6,25 +6,48 @@ import { mount } from 'enzyme'; import React from 'react'; -import { Provider as ReduxStoreProvider } from 'react-redux'; - import { TimelineStatus } from '../../../../../common/types/timeline'; import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + TestProviders, } from '../../../../common/mock'; import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Properties, showDescriptionThreshold, showNotesThreshold } from '.'; +import { SiemPageName } from '../../../../app/types'; +import { setInsertTimeline } from '../../../store/timeline/actions'; +export { nextTick } from '../../../../../../../test_utils'; -jest.mock('../../../../common/lib/kibana'); +import { act } from 'react-dom/test-utils'; -let mockedWidth = 1000; -jest.mock('../../../../common/components/utils'); -(useThrottledResizeObserver as jest.Mock).mockImplementation(() => ({ - width: mockedWidth, -})); +jest.mock('../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }), + useUiSetting$: jest.fn().mockReturnValue([]), + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +const mockDispatch = jest.fn(); +jest.mock('../../../../common/components/utils', () => { + return { + useThrottledResizeObserver: jest.fn(), + }; +}); jest.mock('react-redux', () => { const originalModule = jest.requireActual('react-redux'); @@ -35,50 +58,59 @@ jest.mock('react-redux', () => { }; }); -jest.mock('react-router-dom', () => { - const originalModule = jest.requireActual('react-router-dom'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, + useSelector: jest.fn().mockReturnValue({ savedObjectId: '1', urlState: {} }), +})); +const mockHistoryPush = jest.fn(); - return { - ...originalModule, - useHistory: jest.fn(), - }; -}); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); +jest.mock('./use_create_timeline', () => ({ + useCreateTimelineButton: jest.fn().mockReturnValue({ getButton: jest.fn() }), +})); +const usersViewing = ['elastic']; +const defaultProps = { + associateNote: jest.fn(), + createTimeline: jest.fn(), + isDataInTimeline: false, + isDatepickerLocked: false, + isFavorite: false, + title: '', + description: '', + getNotesByIds: jest.fn(), + noteIds: [], + status: TimelineStatus.active, + timelineId: 'abc', + toggleLock: jest.fn(), + updateDescription: jest.fn(), + updateIsFavorite: jest.fn(), + updateTitle: jest.fn(), + updateNote: jest.fn(), + usersViewing, +}; describe('Properties', () => { - const usersViewing = ['elastic']; - const state: State = mockGlobalState; + let mockedWidth = 1000; let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable); beforeEach(() => { jest.clearAllMocks(); store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable); - mockedWidth = 1000; + (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth }); }); test('renders correctly', () => { const wrapper = mount( - - - + + + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); @@ -87,31 +119,16 @@ describe('Properties', () => { expect(wrapper.find('button[data-test-subj="attach-timeline-case"]').prop('disabled')).toEqual( false ); + expect( + wrapper.find('button[data-test-subj="attach-timeline-existing-case"]').prop('disabled') + ).toEqual(false); }); test('renders correctly draft timeline', () => { const wrapper = mount( - - - + + + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); @@ -119,31 +136,16 @@ describe('Properties', () => { expect(wrapper.find('button[data-test-subj="attach-timeline-case"]').prop('disabled')).toEqual( true ); + expect( + wrapper.find('button[data-test-subj="attach-timeline-existing-case"]').prop('disabled') + ).toEqual(true); }); test('it renders an empty star icon when it is NOT a favorite', () => { const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="timeline-favorite-empty-star"]').exists()).toEqual(true); @@ -151,27 +153,9 @@ describe('Properties', () => { test('it renders a filled star icon when it is a favorite', () => { const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="timeline-favorite-filled-star"]').exists()).toEqual(true); @@ -181,27 +165,9 @@ describe('Properties', () => { const title = 'foozle'; const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="timeline-title"]').first().props().value).toEqual(title); @@ -209,27 +175,9 @@ describe('Properties', () => { test('it renders the date picker with the lock icon', () => { const wrapper = mount( - - - + + + ); expect( @@ -242,27 +190,9 @@ describe('Properties', () => { test('it renders the lock icon when isDatepickerLocked is true', () => { const wrapper = mount( - - - + + + ); expect( wrapper @@ -274,27 +204,9 @@ describe('Properties', () => { test('it renders the unlock icon when isDatepickerLocked is false', () => { const wrapper = mount( - - - + + + ); expect( wrapper @@ -306,30 +218,14 @@ describe('Properties', () => { test('it renders a description on the left when the width is at least as wide as the threshold', () => { const description = 'strange'; - mockedWidth = showDescriptionThreshold; + + (useThrottledResizeObserver as jest.Mock).mockReset(); + (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold }); const wrapper = mount( - - - + + + ); expect( @@ -343,30 +239,16 @@ describe('Properties', () => { test('it does NOT render a description on the left when the width is less than the threshold', () => { const description = 'strange'; - mockedWidth = showDescriptionThreshold - 1; + + (useThrottledResizeObserver as jest.Mock).mockReset(); + (useThrottledResizeObserver as jest.Mock).mockReturnValue({ + width: showDescriptionThreshold - 1, + }); const wrapper = mount( - - - + + + ); expect( @@ -381,27 +263,9 @@ describe('Properties', () => { mockedWidth = showNotesThreshold; const wrapper = mount( - - - + + + ); expect( @@ -413,30 +277,15 @@ describe('Properties', () => { }); test('it does NOT render a a notes button on the left when the width is less than the threshold', () => { - mockedWidth = showNotesThreshold - 1; + (useThrottledResizeObserver as jest.Mock).mockReset(); + (useThrottledResizeObserver as jest.Mock).mockReturnValue({ + width: showNotesThreshold - 1, + }); const wrapper = mount( - - - + + + ); expect( @@ -449,27 +298,9 @@ describe('Properties', () => { test('it renders a settings icon', () => { const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toEqual(true); @@ -479,27 +310,9 @@ describe('Properties', () => { const title = 'port scan'; const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="avatar"]').exists()).toEqual(true); @@ -507,29 +320,45 @@ describe('Properties', () => { test('it does NOT render an avatar for the current user viewing the timeline when it does NOT have a title', () => { const wrapper = mount( - - - + + + ); expect(wrapper.find('[data-test-subj="avatar"]').exists()).toEqual(false); }); + + test('insert timeline - new case', () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); + wrapper.find('[data-test-subj="attach-timeline-case"]').first().simulate('click'); + + expect(mockHistoryPush).toBeCalledWith({ pathname: `/${SiemPageName.case}/create` }); + expect(mockDispatch).toBeCalledWith( + setInsertTimeline({ + timelineId: defaultProps.timelineId, + timelineSavedObjectId: '1', + timelineTitle: 'coolness', + }) + ); + }); + + test('insert timeline - existing case', async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); + wrapper.find('[data-test-subj="attach-timeline-existing-case"]').first().simulate('click'); + + await act(async () => { + await Promise.resolve({}); + }); + expect(wrapper.find('[data-test-subj="all-cases-modal"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 1532a64e4083e..be79c0773bf88 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -6,17 +6,34 @@ import React, { useState, useCallback, useMemo } from 'react'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Note } from '../../../../common/lib/note'; import { InputsModelId } from '../../../../common/store/inputs/constants'; + import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { TimelineProperties } from './styles'; import { PropertiesRight } from './properties_right'; import { PropertiesLeft } from './properties_left'; +import { AllCasesModal } from '../../../../cases/components/all_cases_modal'; +import { SiemPageName } from '../../../../app/types'; +import * as i18n from './translations'; +import { State } from '../../../../common/store'; +import { timelineSelectors } from '../../../store/timeline'; +import { setInsertTimeline } from '../../../store/timeline/actions'; -type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void; +type CreateTimeline = ({ + id, + show, + timelineType, +}: { + id: string; + show?: boolean; + timelineType?: TimelineTypeLiteral; +}) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; @@ -78,6 +95,7 @@ export const Properties = React.memo( const [showActions, setShowActions] = useState(false); const [showNotes, setShowNotes] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); + const dispatch = useDispatch(); const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); @@ -89,6 +107,30 @@ export const Properties = React.memo( setShowTimelineModal(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [showCaseModal, setShowCaseModal] = useState(false); + const onCloseCaseModal = useCallback(() => setShowCaseModal(false), []); + const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); + const history = useHistory(); + const currentTimeline = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); + + const onRowClick = useCallback( + (id: string) => { + onCloseCaseModal(); + history.push({ + pathname: `/${SiemPageName.case}/${id}`, + }); + dispatch( + setInsertTimeline({ + timelineId, + timelineSavedObjectId: currentTimeline.savedObjectId, + timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, + }) + ); + }, + [onCloseCaseModal, currentTimeline, dispatch, history, timelineId, title] + ); const datePickerWidth = useMemo( () => @@ -128,7 +170,6 @@ export const Properties = React.memo( /> ( onButtonClick={onButtonClick} onClosePopover={onClosePopover} onCloseTimelineModal={onCloseTimelineModal} + onOpenCaseModal={onOpenCaseModal} onOpenTimelineModal={onOpenTimelineModal} onToggleShowNotes={onToggleShowNotes} showActions={showActions} @@ -151,6 +193,11 @@ export const Properties = React.memo( updateNote={updateNote} usersViewing={usersViewing} /> + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx new file mode 100644 index 0000000000000..b9b6dadf6f9d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { Provider as ReduxStoreProvider } from 'react-redux'; + +import { + mockGlobalState, + apolloClientObservable, + SUB_PLUGINS_REDUCER, +} from '../../../../common/mock'; +import { createStore, State } from '../../../../common/store'; +import { useKibana } from '../../../../common/lib/kibana'; +import { NewTemplateTimeline } from './new_template_timeline'; + +jest.mock('../../../../common/lib/kibana', () => { + return { + useKibana: jest.fn(), + }; +}); + +describe('NewTemplateTimeline', () => { + const state: State = mockGlobalState; + const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable); + const mockClosePopover = jest.fn(); + const mockTitle = 'NEW_TIMELINE'; + let wrapper: ReactWrapper; + + describe('render if CRUD', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + wrapper = mount( + + + + ); + }); + + test('render with iconType', () => { + expect( + wrapper + .find('[data-test-subj="template-timeline-new-with-border"]') + .first() + .prop('iconType') + ).toEqual('plusInCircle'); + }); + + test('render with onClick', () => { + expect( + wrapper.find('[data-test-subj="template-timeline-new-with-border"]').first().prop('onClick') + ).toBeTruthy(); + }); + }); + + describe('If no CRUD', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: false, + }, + }, + }, + }, + }); + + wrapper = mount( + + + + ); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('no render', () => { + expect( + wrapper.find('[data-test-subj="template-timeline-new-with-border"]').exists() + ).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx new file mode 100644 index 0000000000000..45b2ce62fb85b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { TimelineType } from '../../../../../common/types/timeline'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { useCreateTimelineButton } from './use_create_timeline'; + +interface OwnProps { + closeGearMenu?: () => void; + outline?: boolean; + title?: string; + timelineId?: string; +} + +export const NewTemplateTimelineComponent: React.FC = ({ + closeGearMenu, + outline, + title, + timelineId = 'timeline-1', +}) => { + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud; + + const { getButton } = useCreateTimelineButton({ + timelineId, + timelineType: TimelineType.template, + closeGearMenu, + }); + + const button = getButton({ outline, title }); + + return capabilitiesCanUserCRUD ? button : null; +}; + +export const NewTemplateTimeline = React.memo(NewTemplateTimelineComponent); +NewTemplateTimeline.displayName = 'NewTemplateTimeline'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx new file mode 100644 index 0000000000000..e297a3cc595d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; + +import { PropertiesRight } from './properties_right'; +import { useKibana } from '../../../../common/lib/kibana'; +import { TimelineStatus } from '../../../../../common/types/timeline'; +import { disableTemplate } from '../../../../../common/constants'; + +jest.mock('../../../../common/lib/kibana', () => { + return { + useKibana: jest.fn(), + useUiSetting$: jest.fn().mockReturnValue([]), + }; +}); + +jest.mock('./new_template_timeline', () => { + return { + NewTemplateTimeline: jest.fn(() =>
), + }; +}); + +jest.mock('./helpers', () => { + return { + Description: jest.fn().mockReturnValue(
), + ExistingCase: jest.fn().mockReturnValue(
), + NewCase: jest.fn().mockReturnValue(
), + NewTimeline: jest.fn().mockReturnValue(
), + NotesButton: jest.fn().mockReturnValue(
), + }; +}); + +jest.mock('../../../../common/components/inspect', () => { + return { + InspectButton: jest.fn().mockReturnValue(
), + InspectButtonContainer: jest.fn(({ children }) =>
{children}
), + }; +}); + +describe('Properties Right', () => { + let wrapper: ReactWrapper; + const props = { + onButtonClick: jest.fn(), + onClosePopover: jest.fn(), + showActions: true, + createTimeline: jest.fn(), + timelineId: 'timelineId', + isDataInTimeline: false, + showNotes: false, + showNotesFromWidth: false, + showDescription: false, + showUsersView: false, + usersViewing: [], + description: 'desc', + updateDescription: jest.fn(), + associateNote: jest.fn(), + getNotesByIds: jest.fn(), + noteIds: [], + onToggleShowNotes: jest.fn(), + onCloseTimelineModal: jest.fn(), + onOpenCaseModal: jest.fn(), + onOpenTimelineModal: jest.fn(), + status: TimelineStatus.active, + showTimelineModal: false, + title: 'title', + updateNote: jest.fn(), + }; + + describe('with crud', () => { + describe('render', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }); + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders settings-gear', () => { + expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); + }); + + test('it renders create timelin btn', () => { + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); + }); + + /* + * CreateTemplateTimelineBtn + * Remove the comment here to enable CreateTemplateTimelineBtn + */ + test('it renders no create template timelin btn', () => { + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( + !disableTemplate + ); + }); + + test('it renders create attach timeline to a case btn', () => { + expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy(); + }); + + test('it renders no NotesButton', () => { + expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy(); + }); + + test('it renders no Description', () => { + expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy(); + }); + }); + + describe('render with notes button', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }); + const propsWithshowNotes = { + ...props, + showNotesFromWidth: true, + }; + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders NotesButton', () => { + expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy(); + }); + }); + + describe('render with description', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: true, + }, + }, + }, + }, + }); + const propsWithshowDescription = { + ...props, + showDescription: true, + }; + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders Description', () => { + expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy(); + }); + }); + }); + + describe('with no crud', () => { + describe('render', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: false, + }, + }, + }, + }, + }); + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders settings-gear', () => { + expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy(); + }); + + test('it renders no create timelin btn', () => { + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).not.toBeTruthy(); + }); + + test('it renders create template timelin btn if it is enabled', () => { + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( + !disableTemplate + ); + }); + + test('it renders create attach timeline to a case btn', () => { + expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy(); + }); + + test('it renders no NotesButton', () => { + expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy(); + }); + + test('it renders no Description', () => { + expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy(); + }); + }); + + describe('render with notes button', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: false, + }, + }, + }, + }, + }); + const propsWithshowNotes = { + ...props, + showNotesFromWidth: true, + }; + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders NotesButton', () => { + expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy(); + }); + }); + + describe('render with description', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + securitySolution: { + crud: false, + }, + }, + }, + }, + }); + const propsWithshowDescription = { + ...props, + showDescription: true, + }; + wrapper = mount(); + }); + + afterAll(() => { + (useKibana as jest.Mock).mockReset(); + }); + + test('it renders Description', () => { + expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index 963b67838e811..a9baf73676ffb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -14,15 +14,21 @@ import { EuiToolTip, EuiAvatar, } from '@elastic/eui'; -import { NewTimeline, Description, NotesButton, NewCase } from './helpers'; +import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; + +import { disableTemplate } from '../../../../../common/constants'; +import { TimelineStatus } from '../../../../../common/types/timeline'; + +import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; +import { useKibana } from '../../../../common/lib/kibana'; +import { Note } from '../../../../common/lib/note'; + +import { AssociateNote } from '../../notes/helpers'; import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; -import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import * as i18n from './translations'; -import { AssociateNote } from '../../notes/helpers'; -import { Note } from '../../../../common/lib/note'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { NewTemplateTimeline } from './new_template_timeline'; export const PropertiesRightStyle = styled(EuiFlexGroup)` margin-right: 5px; @@ -55,161 +61,187 @@ const Avatar = styled(EuiAvatar)` Avatar.displayName = 'Avatar'; -type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void; type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; export type UpdateNote = (note: Note) => void; -interface Props { - onButtonClick: () => void; - onClosePopover: () => void; - showActions: boolean; - createTimeline: CreateTimeline; - timelineId: string; - isDataInTimeline: boolean; - showNotes: boolean; - showNotesFromWidth: boolean; - showDescription: boolean; - showUsersView: boolean; - usersViewing: string[]; - description: string; - updateDescription: UpdateDescription; +interface PropertiesRightComponentProps { associateNote: AssociateNote; + description: string; getNotesByIds: (noteIds: string[]) => Note[]; + isDataInTimeline: boolean; noteIds: string[]; - onToggleShowNotes: () => void; + onButtonClick: () => void; + onClosePopover: () => void; onCloseTimelineModal: () => void; + onOpenCaseModal: () => void; onOpenTimelineModal: () => void; + onToggleShowNotes: () => void; + showActions: boolean; + showDescription: boolean; + showNotes: boolean; + showNotesFromWidth: boolean; showTimelineModal: boolean; + showUsersView: boolean; status: TimelineStatus; + timelineId: string; title: string; + updateDescription: UpdateDescription; updateNote: UpdateNote; + usersViewing: string[]; } -const PropertiesRightComponent: React.FC = ({ - onButtonClick, - showActions, - onClosePopover, - createTimeline, - timelineId, - isDataInTimeline, - showNotesFromWidth, - showNotes, - showDescription, - showUsersView, - usersViewing, - description, - updateDescription, +const PropertiesRightComponent: React.FC = ({ associateNote, + description, getNotesByIds, + isDataInTimeline, noteIds, - onToggleShowNotes, - updateNote, - showTimelineModal, + onButtonClick, + onClosePopover, onCloseTimelineModal, + onOpenCaseModal, onOpenTimelineModal, - title, + onToggleShowNotes, + showActions, + showDescription, + showNotes, + showNotesFromWidth, + showTimelineModal, + showUsersView, status, -}) => ( - - - - - } - id="timelineSettingsPopover" - isOpen={showActions} - closePopover={onClosePopover} - > - - - - - - - - - - - - - - - { + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud; + return ( + + + + - + } + id="timelineSettingsPopover" + isOpen={showActions} + closePopover={onClosePopover} + > + + {capabilitiesCanUserCRUD && ( + + + + )} + + {/* + * CreateTemplateTimelineBtn + * Remove the comment here to enable CreateTemplateTimelineBtn + */} + {!disableTemplate && ( + + + + )} - {showNotesFromWidth ? ( - + + + + + + + - ) : null} - {showDescription ? ( - - - + - ) : null} - - - - - - {showUsersView - ? usersViewing.map((user) => ( - // Hide the hard-coded elastic user avatar as the 7.2 release does not implement - // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 - - - - - - )) - : null} - - {showTimelineModal ? : null} - -); + + {showNotesFromWidth ? ( + + + + ) : null} + + {showDescription ? ( + + + + + + ) : null} + + + + + + {showUsersView + ? usersViewing.map((user) => ( + // Hide the hard-coded elastic user avatar as the 7.2 release does not implement + // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 + + + + + + )) + : null} + + {showTimelineModal ? : null} + + ); +}; export const PropertiesRight = React.memo(PropertiesRightComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 88cbd3b1503f6..1621afc91cdbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -109,6 +109,13 @@ export const NEW_TIMELINE = i18n.translate( } ); +export const NEW_TEMPLATE_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel', + { + defaultMessage: 'Create template timeline', + } +); + export const ATTACH_TIMELINE_TO_NEW_CASE = i18n.translate( 'xpack.securitySolution.timeline.properties.newCaseButtonLabel', { @@ -116,6 +123,13 @@ export const ATTACH_TIMELINE_TO_NEW_CASE = i18n.translate( } ); +export const ATTACH_TIMELINE_TO_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.timeline.properties.existingCaseButtonLabel', + { + defaultMessage: 'Attach timeline to existing case', + } +); + export const STREAM_LIVE = i18n.translate( 'xpack.securitySolution.timeline.properties.streamLiveButtonLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx new file mode 100644 index 0000000000000..68a3362b721d8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { renderHook, act } from '@testing-library/react-hooks'; +import { shallow } from 'enzyme'; + +import { TimelineType } from '../../../../../common/types/timeline'; +import { useCreateTimelineButton } from './use_create_timeline'; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: jest.fn(), + }; +}); + +describe('useCreateTimelineButton', () => { + const mockId = 'mockId'; + const timelineType = TimelineType.default; + + test('return getButton', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useCreateTimelineButton({ timelineId: mockId, timelineType }) + ); + await waitForNextUpdate(); + + expect(result.current.getButton).toBeTruthy(); + }); + }); + + test('getButton renders correct outline - EuiButton', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useCreateTimelineButton({ timelineId: mockId, timelineType }) + ); + await waitForNextUpdate(); + + const button = result.current.getButton({ outline: true, title: 'mock title' }); + const wrapper = shallow(button); + expect(wrapper.find('[data-test-subj="timeline-new-with-border"]').exists()).toBeTruthy(); + }); + }); + + test('getButton renders correct outline - EuiButtonEmpty', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useCreateTimelineButton({ timelineId: mockId, timelineType }) + ); + await waitForNextUpdate(); + + const button = result.current.getButton({ outline: false, title: 'mock title' }); + const wrapper = shallow(button); + expect(wrapper.find('[data-test-subj="timeline-new"]').exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx new file mode 100644 index 0000000000000..fb05b056cdf82 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import { timelineActions } from '../../../store/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; + +export const useCreateTimelineButton = ({ + timelineId, + timelineType, + closeGearMenu, +}: { + timelineId?: string; + timelineType: TimelineTypeLiteral; + closeGearMenu?: () => void; +}) => { + const dispatch = useDispatch(); + + const createTimeline = useCallback( + ({ id, show }) => + dispatch( + timelineActions.createTimeline({ + id, + columns: defaultHeaders, + show, + timelineType, + }) + ), + [dispatch, timelineType] + ); + + const handleButtonClick = useCallback(() => { + createTimeline({ id: timelineId, show: true, timelineType }); + if (typeof closeGearMenu === 'function') { + closeGearMenu(); + } + }, [createTimeline, timelineId, timelineType, closeGearMenu]); + + const getButton = useCallback( + ({ outline, title }: { outline?: boolean; title?: string }) => { + const buttonProps = { + iconType: 'plusInCircle', + onClick: handleButtonClick, + }; + const dataTestSubjPrefix = + timelineType === TimelineType.template ? `template-timeline-new` : `timeline-new`; + return outline ? ( + + {title} + + ) : ( + + {title} + + ); + }, + [handleButtonClick, timelineType] + ); + + return { getButton }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index b07be4a471a70..2447ebf47463f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -26,11 +26,36 @@ import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; jest.mock('../../../common/lib/kibana'); - +jest.mock('./properties/properties_right'); const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; jest.mock('use-resize-observer/polyfilled'); + mockUseResizeObserver.mockImplementation(() => ({})); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + ...originalModule, + useHistory: jest.fn(), + }; +}); +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest.fn().mockReturnValue({ + services: { + uiSettings: { + get: jest.fn(), + }, + savedObjects: { + client: {}, + }, + }, + }), + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); describe('Timeline', () => { let props = {} as TimelineComponentProps; const sort: Sort = { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts index cdbf3e768581b..60d000fe78184 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts @@ -56,6 +56,7 @@ export const allTimelinesQuery = gql` } noteIds pinnedEventIds + status title timelineType templateTimelineId diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts new file mode 100644 index 0000000000000..26373fa1a825d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -0,0 +1,503 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as api from './api'; +import { KibanaServices } from '../../common/lib/kibana'; +import { TimelineType, TimelineStatus } from '../../../common/types/timeline'; +import { TIMELINE_DRAFT_URL, TIMELINE_URL } from '../../../common/constants'; + +jest.mock('../../common/lib/kibana', () => { + return { + KibanaServices: { get: jest.fn() }, + }; +}); + +describe('persistTimeline', () => { + describe('create draft timeline', () => { + const timelineId = null; + const initialDraftTimeline = { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + }, + ], + dataProviders: [], + description: 'x', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + }, + title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { + start: 1590998565409, + end: 1591084965409, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.draft, + }; + const mockDraftResponse = { + data: { + persistTimeline: { + timeline: { + savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a', + version: 'WzMzMiwxXQ==', + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + timelineType: 'default', + kqlQuery: { filterQuery: null }, + title: '', + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', + created: 1591091394733, + createdBy: 'angela', + updated: 1591091394733, + updatedBy: 'angela', + templateTimelineId: null, + templateTimelineVersion: null, + dateRange: { start: 1590998565409, end: 1591084965409 }, + savedQueryId: null, + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + }, + }, + }; + const mockPatchTimelineResponse = { + data: { + persistTimeline: { + code: 200, + message: 'success', + timeline: { + savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a', + version: 'WzM0NSwxXQ==', + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: 'x', + eventType: 'all', + filters: [], + kqlMode: 'filter', + timelineType: 'default', + kqlQuery: { filterQuery: null }, + title: '', + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', + created: 1591092702804, + createdBy: 'angela', + updated: 1591092705206, + updatedBy: 'angela', + templateTimelineId: null, + templateTimelineVersion: null, + dateRange: { start: 1590998565409, end: 1591084965409 }, + savedQueryId: null, + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + }, + }, + }; + const version = null; + const fetchMock = jest.fn(); + const postMock = jest.fn(); + const patchMock = jest.fn(); + + beforeAll(() => { + jest.resetAllMocks(); + + (KibanaServices.get as jest.Mock).mockReturnValue({ + http: { + fetch: fetchMock, + post: postMock.mockReturnValue(mockDraftResponse), + patch: patchMock.mockReturnValue(mockPatchTimelineResponse), + }, + }); + api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version }); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + test('it should create a draft timeline if given status is draft and timelineId is null', () => { + expect(postMock).toHaveBeenCalledWith(TIMELINE_DRAFT_URL, { + body: JSON.stringify({ + timelineType: initialDraftTimeline.timelineType, + }), + }); + }); + + test('it should update timeline', () => { + expect(patchMock.mock.calls[0][0]).toEqual(TIMELINE_URL); + }); + + test('it should update timeline with patch', () => { + expect(patchMock.mock.calls[0][1].method).toEqual('PATCH'); + }); + + test("it should update timeline from clean draft timeline's response", () => { + expect(JSON.parse(patchMock.mock.calls[0][1].body)).toEqual({ + timelineId: mockDraftResponse.data.persistTimeline.timeline.savedObjectId, + timeline: { + ...initialDraftTimeline, + templateTimelineId: mockDraftResponse.data.persistTimeline.timeline.templateTimelineId, + templateTimelineVersion: + mockDraftResponse.data.persistTimeline.timeline.templateTimelineVersion, + }, + version: mockDraftResponse.data.persistTimeline.timeline.version ?? '', + }); + }); + }); + + describe('create active timeline (import)', () => { + const timelineId = null; + const importTimeline = { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + }, + ], + dataProviders: [], + description: 'x', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + }, + title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { + start: 1590998565409, + end: 1591084965409, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.active, + }; + const mockPostTimelineResponse = { + data: { + persistTimeline: { + timeline: { + savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a', + version: 'WzMzMiwxXQ==', + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + timelineType: 'default', + kqlQuery: { filterQuery: null }, + title: '', + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', + created: 1591091394733, + createdBy: 'angela', + updated: 1591091394733, + updatedBy: 'angela', + templateTimelineId: null, + templateTimelineVersion: null, + dateRange: { start: 1590998565409, end: 1591084965409 }, + savedQueryId: null, + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + }, + }, + }; + + const version = null; + const fetchMock = jest.fn(); + const postMock = jest.fn(); + const patchMock = jest.fn(); + + beforeAll(() => { + jest.resetAllMocks(); + + (KibanaServices.get as jest.Mock).mockReturnValue({ + http: { + fetch: fetchMock, + post: postMock.mockReturnValue(mockPostTimelineResponse), + patch: patchMock, + }, + }); + api.persistTimeline({ timelineId, timeline: importTimeline, version }); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + test('it should update timeline', () => { + expect(postMock.mock.calls[0][0]).toEqual(TIMELINE_URL); + }); + + test('it should update timeline with patch', () => { + expect(postMock.mock.calls[0][1].method).toEqual('POST'); + }); + + test('should call create timeline', () => { + expect(JSON.parse(postMock.mock.calls[0][1].body)).toEqual({ timeline: importTimeline }); + }); + }); + + describe('update active timeline', () => { + const timelineId = '9d5693e0-a42a-11ea-b8f4-c5434162742a'; + const inputTimeline = { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + }, + ], + dataProviders: [], + description: 'x', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + }, + title: '', + timelineType: TimelineType.default, + templateTimelineVersion: null, + templateTimelineId: null, + dateRange: { + start: 1590998565409, + end: 1591084965409, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + status: TimelineStatus.active, + }; + const mockPatchTimelineResponse = { + data: { + persistTimeline: { + timeline: { + savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a', + version: 'WzMzMiwxXQ==', + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp' }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'event.category' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + ], + dataProviders: [], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + timelineType: 'default', + kqlQuery: { filterQuery: null }, + title: '', + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + status: 'draft', + created: 1591091394733, + createdBy: 'angela', + updated: 1591091394733, + updatedBy: 'angela', + templateTimelineId: null, + templateTimelineVersion: null, + dateRange: { start: 1590998565409, end: 1591084965409 }, + savedQueryId: null, + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + }, + }, + }; + + const version = 'initial version'; + const fetchMock = jest.fn(); + const postMock = jest.fn(); + const patchMock = jest.fn(); + + beforeAll(() => { + jest.resetAllMocks(); + + (KibanaServices.get as jest.Mock).mockReturnValue({ + http: { + fetch: fetchMock, + post: postMock, + patch: patchMock.mockReturnValue(mockPatchTimelineResponse), + }, + }); + api.persistTimeline({ timelineId, timeline: inputTimeline, version }); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + test('it should update timeline', () => { + expect(patchMock.mock.calls[0][0]).toEqual(TIMELINE_URL); + }); + + test('it should update timeline with patch', () => { + expect(patchMock.mock.calls[0][1].method).toEqual('PATCH'); + }); + + test('should call patch timeline', () => { + expect(JSON.parse(patchMock.mock.calls[0][1].body)).toEqual({ + timeline: inputTimeline, + timelineId, + version, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index 9f5e65e0fc5af..10893feccfed4 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -77,9 +77,15 @@ export const persistTimeline = async ({ }: RequestPersistTimeline): Promise => { if (timelineId == null && timeline.status === TimelineStatus.draft) { const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! }); + return patchTimeline({ timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId, - timeline, + timeline: { + ...timeline, + templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId, + templateTimelineVersion: + draftTimeline.data.persistTimeline.timeline.templateTimelineVersion, + }, version: draftTimeline.data.persistTimeline.timeline.version ?? '', }); } diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx index 1fc3a33fbca08..e7b66c4e8addb 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { useKibana } from '../../common/lib/kibana'; import { TimelinesPageComponent } from './timelines_page'; +import { disableTemplate } from '../../../common/constants'; jest.mock('../../overview/components/events_by_dataset'); @@ -18,6 +19,7 @@ jest.mock('../../common/lib/kibana', () => { useKibana: jest.fn(), }; }); + describe('TimelinesPageComponent', () => { const mockAppollloClient = {} as ApolloClient; let wrapper: ShallowWrapper; @@ -58,6 +60,20 @@ describe('TimelinesPageComponent', () => { wrapper.find('[data-test-subj="stateful-open-timeline"]').prop('importDataModalToggle') ).toEqual(true); }); + + test('it renders create timelin btn', () => { + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); + }); + + /* + * CreateTemplateTimelineBtn + * Remove the comment here to enable CreateTemplateTimelineBtn + */ + test('it renders no create template timelin btn', () => { + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual( + !disableTemplate + ); + }); }); describe('If the user is not authorised', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index fd734d10ecba0..2c692d850cd16 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -4,15 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import ApolloClient from 'apollo-client'; import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; + +import { disableTemplate } from '../../../common/constants'; + import { HeaderPage } from '../../common/components/header_page'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; + import { StatefulOpenTimeline } from '../components/open_timeline'; +import { NEW_TEMPLATE_TIMELINE } from '../components/timeline/properties/translations'; +import { NewTemplateTimeline } from '../components/timeline/properties/new_template_timeline'; +import { NewTimeline } from '../components/timeline/properties/helpers'; + import * as i18n from './translations'; const TimelinesContainer = styled.div` @@ -34,24 +42,47 @@ export const TimelinesPageComponent: React.FC = ({ apolloClient }) => }, [setImportDataModalToggle]); const uiCapabilities = useKibana().services.application.capabilities; - const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities.securitySolution.crud === 'boolean' - ? uiCapabilities.securitySolution.crud - : false; + const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud; return ( <> - {capabilitiesCanUserCRUD && ( - - {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} - - )} + + + {capabilitiesCanUserCRUD && ( + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + )} + + + {capabilitiesCanUserCRUD && ( + + )} + + {/** + * CreateTemplateTimelineBtn + * Remove the comment here to enable CreateTemplateTimelineBtn + */} + {!disableTemplate && ( + + + + )} + diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index e11d1bcc72e09..c5df017604b0c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -16,6 +16,8 @@ import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/t import { EventType, KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../graphql/types'; +import { TimelineTypeLiteral } from '../../../../common/types/timeline'; +import { InsertTimeline } from './types'; const actionCreator = actionCreatorFactory('x-pack/security_solution/local/timeline'); @@ -67,6 +69,7 @@ export const createTimeline = actionCreator<{ sort?: Sort; showCheckboxes?: boolean; showRowRenderers?: boolean; + timelineType?: TimelineTypeLiteral; }>('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); @@ -96,6 +99,8 @@ export const addTimeline = actionCreator<{ timeline: TimelineModel; }>('ADD_TIMELINE'); +export const setInsertTimeline = actionCreator('SET_INSERT_TIMELINE'); + export const startTimelineSaving = actionCreator<{ id: string; }>('START_TIMELINE_SAVING'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index b2472cbe89a50..03e9ca176ee82 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -7,6 +7,9 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import { Filter } from '../../../../../../../src/plugins/data/public'; + +import { disableTemplate } from '../../../../common/constants'; + import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; import { @@ -15,11 +18,12 @@ import { QueryMatch, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; +import { TimelineNonEcsData } from '../../../graphql/types'; +import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model'; import { TimelineById, TimelineState } from './types'; -import { TimelineNonEcsData } from '../../../graphql/types'; const EMPTY_TIMELINE_BY_ID: TimelineById = {}; // stable reference @@ -32,6 +36,7 @@ export const initialTimelineState: TimelineState = { newTimelineModel: null, }, showCallOutUnauthorizedMsg: false, + insertTimeline: null, }; interface AddTimelineHistoryParams { @@ -147,6 +152,7 @@ interface AddNewTimelineParams { showCheckboxes?: boolean; showRowRenderers?: boolean; timelineById: TimelineById; + timelineType: TimelineTypeLiteral; } /** Adds a new `Timeline` to the provided collection of `TimelineById` */ @@ -163,6 +169,7 @@ export const addNewTimeline = ({ showCheckboxes = false, showRowRenderers = true, timelineById, + timelineType, }: AddNewTimelineParams): TimelineById => ({ ...timelineById, [id]: { @@ -182,6 +189,7 @@ export const addNewTimeline = ({ isLoading: false, showCheckboxes, showRowRenderers, + timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index d17bc1f20e1e8..3bdb16be79939 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -139,6 +139,7 @@ describe('Timeline', () => { id: 'bar', columns: defaultHeaders, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).not.toBe(timelineByIdMock); }); @@ -148,6 +149,7 @@ describe('Timeline', () => { id: 'bar', columns: timelineDefaults.columns, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).toEqual({ foo: timelineByIdMock.foo, @@ -163,6 +165,7 @@ describe('Timeline', () => { id: 'bar', columns: defaultHeaders, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).toEqual({ foo: timelineByIdMock.foo, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index a8ae39527cdbf..5e314f1597451 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -6,14 +6,17 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers'; import { - addTimeline, addHistory, addNote, addNoteToEvent, addProvider, + addTimeline, applyDeltaToColumnWidth, applyDeltaToWidth, applyKqlFilterQuery, + clearEventsDeleted, + clearEventsLoading, + clearSelected, createTimeline, dataProviderEdited, endTimelineSaving, @@ -21,12 +24,12 @@ import { removeColumn, removeProvider, setEventsDeleted, - clearEventsDeleted, setEventsLoading, - clearEventsLoading, + setFilters, + setInsertTimeline, setKqlFilterQueryDraft, + setSavedQueryId, setSelected, - clearSelected, showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, @@ -37,9 +40,11 @@ import { updateDataProviderExcluded, updateDataProviderKqlQuery, updateDescription, + updateEventType, updateHighlightedDropAndProviderId, updateIsFavorite, updateIsLive, + updateIsLoading, updateItemsPerPage, updateItemsPerPageOptions, updateKqlMode, @@ -50,10 +55,6 @@ import { updateTimeline, updateTitle, upsertColumn, - updateIsLoading, - setSavedQueryId, - setFilters, - updateEventType, } from './actions'; import { addNewTimeline, @@ -98,6 +99,7 @@ import { } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; +import { TimelineType } from '../../../../common/types/timeline'; export const initialTimelineState: TimelineState = { timelineById: EMPTY_TIMELINE_BY_ID, @@ -106,6 +108,7 @@ export const initialTimelineState: TimelineState = { newTimelineModel: null, }, showCallOutUnauthorizedMsg: false, + insertTimeline: null, }; /** The reducer for all timeline actions */ @@ -129,6 +132,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) sort, showCheckboxes, showRowRenderers, + timelineType = TimelineType.default, filters, } ) => ({ @@ -146,6 +150,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) showCheckboxes, showRowRenderers, timelineById: state.timelineById, + timelineType, }), }) ) @@ -483,4 +488,8 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) + .case(setInsertTimeline, (state, insertTimeline) => ({ + ...state, + insertTimeline, + })) .build(); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts index af7ac075468c3..a80a28660e28b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts @@ -10,7 +10,7 @@ import { isFromKueryExpressionValid } from '../../../common/lib/keury'; import { State } from '../../../common/store/types'; import { TimelineModel } from './model'; -import { AutoSavedWarningMsg, TimelineById } from './types'; +import { AutoSavedWarningMsg, InsertTimeline, TimelineById } from './types'; const selectTimelineById = (state: State): TimelineById => state.timeline.timelineById; @@ -22,6 +22,9 @@ const selectCallOutUnauthorizedMsg = (state: State): boolean => export const selectTimeline = (state: State, timelineId: string): TimelineModel => state.timeline.timelineById[timelineId]; +export const selectInsertTimeline = (state: State): InsertTimeline | null => + state.timeline.insertTimeline; + export const autoSaveMsgSelector = createSelector(selectAutoSaveMsg, (autoSaveMsg) => autoSaveMsg); export const timelineByIdSelector = createSelector( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 1cc4517d2c964..aa6c308614287 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -16,6 +16,12 @@ export interface TimelineById { [id: string]: TimelineModel; } +export interface InsertTimeline { + timelineId: string; + timelineSavedObjectId: string | null; + timelineTitle: string; +} + export const EMPTY_TIMELINE_BY_ID: TimelineById = {}; // stable reference /** The state of all timelines is stored here */ @@ -23,6 +29,7 @@ export interface TimelineState { timelineById: TimelineById; autoSavedWarningMsg: AutoSavedWarningMsg; showCallOutUnauthorizedMsg: boolean; + insertTimeline: InsertTimeline | null; } export interface ActionTimeline extends Action { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts index 40c568ecda741..281726d488abe 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts @@ -39,17 +39,15 @@ export const pickSavedTimeline = ( savedTimeline.templateTimelineVersion = savedTimeline.templateTimelineVersion + 1; } } - } else if (savedTimeline.status === TimelineStatus.draft) { - savedTimeline.status = !isEmpty(savedTimeline.title) - ? TimelineStatus.active - : TimelineStatus.draft; - savedTimeline.templateTimelineId = null; - savedTimeline.templateTimelineVersion = null; } else { savedTimeline.timelineType = savedTimeline.timelineType ?? TimelineType.default; savedTimeline.templateTimelineId = null; savedTimeline.templateTimelineVersion = null; } + if (!isEmpty(savedTimeline.title) && savedTimeline.status === TimelineStatus.draft) { + savedTimeline.status = TimelineStatus.active; + } + return savedTimeline; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts index 9dc957604d4df..0e53cee0bf8ac 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts @@ -17,6 +17,7 @@ import { cleanDraftTimelinesRequest, createTimelineWithTimelineId, } from './__mocks__/request_responses'; +import { draftTimelineDefaults } from '../default_timeline'; describe('clean draft timelines', () => { let server: ReturnType; @@ -81,7 +82,12 @@ describe('clean draft timelines', () => { }); const response = await server.inject(cleanDraftTimelinesRequest(TimelineType.default), context); + const req = cleanDraftTimelinesRequest(TimelineType.default); expect(mockPersistTimeline).toHaveBeenCalled(); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...draftTimelineDefaults, + timelineType: req.body.timelineType, + }); expect(response.status).toEqual(200); expect(response.body).toEqual({ data: { @@ -100,8 +106,13 @@ describe('clean draft timelines', () => { mockGetTimeline.mockResolvedValue({ ...mockGetDraftTimelineValue }); const response = await server.inject(cleanDraftTimelinesRequest(TimelineType.default), context); + const req = cleanDraftTimelinesRequest(TimelineType.default); + expect(mockPersistTimeline).not.toHaveBeenCalled(); expect(mockResetTimeline).toHaveBeenCalled(); + expect(mockResetTimeline.mock.calls[0][1]).toEqual([mockGetDraftTimelineValue.savedObjectId]); + expect(mockResetTimeline.mock.calls[0][2]).toEqual(req.body.timelineType); + expect(mockGetTimeline).toHaveBeenCalled(); expect(response.status).toEqual(200); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts index 30225da6b42cc..9ad50b8f2266c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts @@ -40,7 +40,11 @@ export const cleanDraftTimelinesRoute = ( } = await getDraftTimeline(frameworkRequest, request.body.timelineType); if (draftTimeline?.savedObjectId) { - await resetTimeline(frameworkRequest, [draftTimeline.savedObjectId]); + await resetTimeline( + frameworkRequest, + [draftTimeline.savedObjectId], + request.body.timelineType + ); const cleanedDraftTimeline = await getTimeline( frameworkRequest, draftTimeline.savedObjectId @@ -57,12 +61,10 @@ export const cleanDraftTimelinesRoute = ( }); } - const newTimelineResponse = await persistTimeline( - frameworkRequest, - null, - null, - draftTimelineDefaults - ); + const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { + ...draftTimelineDefaults, + timelineType: request.body.timelineType, + }); if (newTimelineResponse.code === 200) { return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts index e9bceb2c66806..5447da8ef49d2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts @@ -17,6 +17,7 @@ import { getDraftTimelinesRequest, createTimelineWithTimelineId, } from './__mocks__/request_responses'; +import { draftTimelineDefaults } from '../default_timeline'; describe('get draft timelines', () => { let server: ReturnType; @@ -80,12 +81,14 @@ describe('get draft timelines', () => { mockGetDraftTimeline.mockResolvedValue({ timeline: [], }); - - const response = await server.inject( - getDraftTimelinesRequest(TimelineType.default), - context - ); + const req = getDraftTimelinesRequest(TimelineType.default); + const response = await server.inject(req, context); expect(mockPersistTimeline).toHaveBeenCalled(); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...draftTimelineDefaults, + timelineType: req.query.timelineType, + }); + expect(response.status).toEqual(200); expect(response.body).toEqual({ data: { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts index 7b379741fc217..4db434ec816aa 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts @@ -51,12 +51,10 @@ export const getDraftTimelinesRoute = ( }); } - const newTimelineResponse = await persistTimeline( - frameworkRequest, - null, - null, - draftTimelineDefaults - ); + const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { + ...draftTimelineDefaults, + timelineType: request.query.timelineType, + }); if (newTimelineResponse.code === 200) { return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts index a479c818cb01d..d5ecd408a6ef4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts @@ -52,6 +52,7 @@ export const updateTimelinesRoute = ( templateTimelineId != null ? await getTemplateTimeline(frameworkRequest, templateTimelineId) : null; + const errorObj = checkIsFailureCases( isHandlingTemplateTimeline, version, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index dfbf273cbdec2..bbb11cd642c4c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -7,7 +7,7 @@ import { getOr } from 'lodash/fp'; import { SavedObjectsFindOptions } from '../../../../../../src/core/server'; -import { UNAUTHENTICATED_USER } from '../../../common/constants'; +import { UNAUTHENTICATED_USER, disableTemplate } from '../../../common/constants'; import { NoteSavedObject } from '../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event'; import { @@ -122,7 +122,6 @@ const getTimelineTypeFilter = ( const draftFilter = includeDraft ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; - return `${typeFilter} and ${draftFilter}`; }; @@ -147,7 +146,7 @@ export const getAllTimeline = async ( * Remove the comment here to enable template timeline and apply the change below * filter: getTimelineTypeFilter(timelineType, false) */ - filter: getTimelineTypeFilter(TimelineType.default, false), + filter: getTimelineTypeFilter(disableTemplate ? TimelineType.default : timelineType, false), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; @@ -321,7 +320,11 @@ const updatePartialSavedTimeline = async ( ); }; -export const resetTimeline = async (request: FrameworkRequest, timelineIds: string[]) => { +export const resetTimeline = async ( + request: FrameworkRequest, + timelineIds: string[], + timelineType: TimelineType +) => { if (!timelineIds.length) { return Promise.reject(new Error('timelineIds is empty')); } @@ -337,7 +340,7 @@ export const resetTimeline = async (request: FrameworkRequest, timelineIds: stri const response = await Promise.all( timelineIds.map((timelineId) => - updatePartialSavedTimeline(request, timelineId, draftTimelineDefaults) + updatePartialSavedTimeline(request, timelineId, { ...draftTimelineDefaults, timelineType }) ) ); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts index ab00211313d62..9e748068742f3 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -44,4 +44,29 @@ describe('getAggConfigFromEsAgg', () => { }, }); }); + + test('should resolve sub-aggregations', () => { + const esConfig = { + filter: { + term: { region: 'sa-west-1' }, + }, + aggs: { + test_avg: { + avg: { + field: 'test_field', + }, + }, + }, + }; + + const result = getAggConfigFromEsAgg(esConfig, 'test_3'); + + expect(result.subAggs!.test_avg).toEqual({ + agg: 'avg', + aggName: 'test_avg', + dropDownName: 'test_avg', + field: 'test_field', + parentAgg: result, + }); + }); }); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index d6b3fb974783d..54dfd9ecda7b1 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -79,17 +79,32 @@ export type PivotAggDict = { [key in AggName]: PivotAgg; }; +/** + * The maximum level of sub-aggregations + */ +export const MAX_NESTING_SUB_AGGS = 10; + // The internal representation of an aggregation definition. export interface PivotAggsConfigBase { agg: PivotSupportedAggs; aggName: AggName; dropDownName: string; + /** Indicates if aggregation supports sub-aggregations */ + isSubAggsSupported?: boolean; + /** Dictionary of the sub-aggregations */ + subAggs?: PivotAggsConfigDict; + /** Reference to the parent aggregation */ + parentAgg?: PivotAggsConfig; } /** * Resolves agg UI config from provided ES agg definition */ -export function getAggConfigFromEsAgg(esAggDefinition: Record, aggName: string) { +export function getAggConfigFromEsAgg( + esAggDefinition: Record, + aggName: string, + parentRef?: PivotAggsConfig +) { const aggKeys = Object.keys(esAggDefinition); // Find the main aggregation key @@ -108,12 +123,21 @@ export function getAggConfigFromEsAgg(esAggDefinition: Record, aggN const config = getAggFormConfig(agg, commonConfig); + if (parentRef) { + config.parentAgg = parentRef; + } + if (isPivotAggsWithExtendedForm(config)) { config.setUiConfigFromEs(esAggDefinition[agg]); } if (aggKeys.includes('aggs')) { - // TODO process sub-aggregation + config.subAggs = {}; + for (const [subAggName, subAggConfigs] of Object.entries( + esAggDefinition.aggs as Record + )) { + config.subAggs[subAggName] = getAggConfigFromEsAgg(subAggConfigs, subAggName, config); + } } return config; @@ -199,6 +223,7 @@ export function getEsAggFromAggConfig( delete esAgg.agg; delete esAgg.aggName; delete esAgg.dropDownName; + delete esAgg.parentAgg; if (isPivotAggsWithExtendedForm(pivotAggsConfig)) { esAgg = pivotAggsConfig.getEsAggConfig(); @@ -208,7 +233,20 @@ export function getEsAggFromAggConfig( } } - return { + const result = { [pivotAggsConfig.agg]: esAgg, }; + + if ( + isPivotAggsConfigWithUiSupport(pivotAggsConfig) && + pivotAggsConfig.subAggs !== undefined && + Object.keys(pivotAggsConfig.subAggs).length > 0 + ) { + result.aggs = {}; + for (const subAggConfig of Object.values(pivotAggsConfig.subAggs)) { + result.aggs[subAggConfig.aggName] = getEsAggFromAggConfig(subAggConfig); + } + } + + return result; } diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 6266defc01e16..13544b80ed1b2 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -31,12 +31,25 @@ import { PivotGroupByConfig, PivotQuery, PreviewMappings, + PivotAggsConfig, } from '../common'; import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; import { isPivotAggsWithExtendedForm } from '../common/pivot_aggs'; +/** + * Checks if the aggregations collection is invalid. + */ +function isConfigInvalid(aggsArray: PivotAggsConfig[]): boolean { + return aggsArray.some((agg) => { + return ( + (isPivotAggsWithExtendedForm(agg) && !agg.isValid()) || + (agg.subAggs && isConfigInvalid(Object.values(agg.subAggs))) + ); + }); +} + function sortColumns(groupByArr: PivotGroupByConfig[]) { return (a: string, b: string) => { // make sure groupBy fields are always most left columns @@ -62,7 +75,7 @@ export const usePivotData = ( const [previewMappings, setPreviewMappings] = useState({ properties: {} }); const api = useApi(); - const aggsArr = dictionaryToArray(aggs); + const aggsArr = useMemo(() => dictionaryToArray(aggs), [aggs]); const groupByArr = dictionaryToArray(groupBy); // Filters mapping properties of type `object`, which get returned for nested field parents. @@ -136,11 +149,7 @@ export const usePivotData = ( return; } - const isConfigInvalid = aggsArr.some( - (agg) => isPivotAggsWithExtendedForm(agg) && !agg.isValid() - ); - - if (isConfigInvalid) { + if (isConfigInvalid(aggsArr)) { return; } @@ -185,7 +194,7 @@ export const usePivotData = ( /* eslint-disable react-hooks/exhaustive-deps */ }, [ indexPatternTitle, - JSON.stringify(aggsArr), + aggsArr, JSON.stringify(groupByArr), JSON.stringify(query), /* eslint-enable react-hooks/exhaustive-deps */ diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index e5381f09713b5..c28588f727e97 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -13,6 +13,7 @@ interface Props { placeholder?: string; changeHandler(d: EuiComboBoxOptionOption[]): void; testSubj?: string; + isDisabled?: boolean; } export const DropDown: React.FC = ({ @@ -20,6 +21,7 @@ export const DropDown: React.FC = ({ options, placeholder = 'Search ...', testSubj, + isDisabled, }) => { return ( = ({ onChange={changeHandler} isClearable={false} data-test-subj={testSubj} + isDisabled={isDisabled} /> ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap index ed32fb3d6ad5f..10258f53aa25b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/agg_label_form.test.tsx.snap @@ -1,70 +1,72 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Date histogram aggregation 1`] = ` - - + - - the-group-by-agg-name - - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="transformFormPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" + + the-group-by-agg-name + + + - } - onChange={[Function]} - options={Object {}} - otherAggNames={Array []} + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="transformFormPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + + + + - - - - - - + + + `; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap index 3134af4c8b21d..89b54e6d0a22f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/list_form.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Transform: Minimal initialization 1`] = ` = ({ const helperText = isPivotAggsWithExtendedForm(item) && item.helperText && item.helperText(); + const isSubAggSupported = + isPivotAggsConfigWithUiSupport(item) && + item.isSubAggsSupported && + (isPivotAggsWithExtendedForm(item) ? item.isValid() : true); + return ( - - - - {item.aggName} - - - {helperText && ( - - - {helperText} - + <> + + + + {item.aggName} + - )} - - setPopoverVisibility(!isPopoverVisible)} - data-test-subj="transformAggregationEntryEditButton" + {helperText && ( + + + {helperText} + + + )} + + setPopoverVisibility(!isPopoverVisible)} + data-test-subj="transformAggregationEntryEditButton" + /> + } + isOpen={isPopoverVisible} + closePopover={() => setPopoverVisibility(false)} + > + - } - isOpen={isPopoverVisible} - closePopover={() => setPopoverVisibility(false)} - > - + + + deleteHandler(item.aggName)} + data-test-subj="transformAggregationEntryDeleteButton" /> - - - - deleteHandler(item.aggName)} - data-test-subj="transformAggregationEntryDeleteButton" - /> - - + + + + {isSubAggSupported && } + ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx index cbcb6c668b58a..a02f4455250d7 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/list_form.tsx @@ -32,7 +32,7 @@ export const AggListForm: React.FC = ({ deleteHandler, list, onCha const otherAggNames = listKeys.filter((k) => k !== aggName); return ( - + = ({ item }) => { + const { state, actions } = useContext(PivotConfigurationContext)!; + + const addSubAggHandler = useCallback( + (d: EuiComboBoxOptionOption[]) => { + actions.addSubAggregation(item, d); + }, + [actions, item] + ); + + const updateSubAggHandler = useCallback( + (prevSubItemName: string, subItem: PivotAggsConfig) => { + actions.updateSubAggregation(prevSubItemName, subItem); + }, + [actions] + ); + + const deleteSubAggHandler = useCallback( + (subAggName: string) => { + actions.deleteSubAggregation(item, subAggName); + }, + [actions, item] + ); + + const isNewSubAggAllowed: boolean = useMemo(() => { + let nestingLevel = 1; + let parentItem = item.parentAgg; + while (parentItem !== undefined) { + nestingLevel++; + parentItem = parentItem.parentAgg; + } + return nestingLevel <= MAX_NESTING_SUB_AGGS; + }, [item]); + + const dropdown = ( + + ); + + return ( + <> + + {item.subAggs && ( + + )} + {isNewSubAggAllowed ? ( + dropdown + ) : ( + + } + > + {dropdown} + + )} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx index 5de35a683a376..a3a2e7c4eadfa 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash'; -import React, { memo, FC } from 'react'; +import React, { memo, FC, createContext } from 'react'; import { EuiFormRow } from '@elastic/eui'; @@ -16,20 +15,32 @@ import { DropDown } from '../aggregation_dropdown'; import { GroupByListForm } from '../group_by_list'; import { StepDefineFormHook } from '../step_define'; +export const PivotConfigurationContext = createContext< + StepDefineFormHook['pivotConfig'] | undefined +>(undefined); + export const PivotConfiguration: FC = memo( - ({ - actions: { + ({ actions, state }) => { + const { addAggregation, addGroupBy, deleteAggregation, deleteGroupBy, updateAggregation, updateGroupBy, - }, - state: { aggList, aggOptions, aggOptionsData, groupByList, groupByOptions, groupByOptionsData }, - }) => { + } = actions; + + const { + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + } = state; + return ( - <> + = memo( /> - + ); - }, - (prevProps, nextProps) => { - return isEqual(prevProps.state, nextProps.state); } ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx index 7e23e799ae32e..35f9734a59482 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -42,7 +42,7 @@ describe('FilterAggForm', () => { ); - expect(getByLabelText('Filter agg')).toBeInTheDocument(); + expect(getByLabelText('Filter query')).toBeInTheDocument(); const { options } = (await findByTestId('transformFilterAggTypeSelector')) as HTMLSelectElement; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx index 3e67a16e3c1ed..ac6e93d3ed5eb 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx @@ -62,7 +62,7 @@ export const FilterAggForm: PivotAggsConfigFilter['AggFormComponent'] = ({ label={ } > diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx index cfc6bb27c88a1..7f6c23dddb9fc 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_range_form.tsx @@ -60,7 +60,8 @@ export const FilterRangeForm: FilterAggConfigRange['aggTypeConfig']['FilterAggFo onChange={(e) => { updateConfig({ from: e.target.value === '' ? undefined : Number(e.target.value) }); }} - step={0.1} + // @ts-ignore + step="any" prepend={ { updateConfig({ to: e.target.value === '' ? undefined : Number(e.target.value) }); }} - step={0.1} + // @ts-ignore + step="any" append={ { // Simulate initial load. onSearchChange(''); + return () => { + // make sure the ongoing request is canceled + fetchOptions.cancel(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -115,7 +117,6 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm value: undefined, }, }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedField]); const selectedOptions = config?.value ? [{ label: config.value }] : undefined; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts index 8602a82db8f2f..d8b37b25f50d1 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/config.ts @@ -30,6 +30,7 @@ export function getFilterAggConfig( ): PivotAggsConfigFilter { return { ...commonConfig, + isSubAggsSupported: true, field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', AggFormComponent: FilterAggForm, aggConfig: {}, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 72bfbe369757b..d35d567fc8469 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -4,12 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { dictionaryToArray } from '../../../../../../../common/types/common'; import { useToastNotifications } from '../../../../../app_dependencies'; -import { AggName, DropDownLabel, PivotAggsConfig, PivotGroupByConfig } from '../../../../../common'; +import { + AggName, + DropDownLabel, + PivotAggsConfig, + PivotAggsConfigDict, + PivotGroupByConfig, +} from '../../../../../common'; import { getAggNameConflictToastMessages, @@ -18,136 +24,297 @@ import { } from '../common'; import { StepDefineFormProps } from '../step_define_form'; +/** + * Clones aggregation configuration and updates parent references + * for the sub-aggregations. + */ +function cloneAggItem(item: PivotAggsConfig, parentRef?: PivotAggsConfig) { + const newItem = { ...item }; + if (parentRef !== undefined) { + newItem.parentAgg = parentRef; + } + if (newItem.subAggs !== undefined) { + const newSubAggs: PivotAggsConfigDict = {}; + for (const [key, subItem] of Object.entries(newItem.subAggs)) { + newSubAggs[key] = cloneAggItem(subItem, newItem); + } + newItem.subAggs = newSubAggs; + } + return newItem; +} + +/** + * Returns a root aggregation configuration + * for provided aggregation item. + */ +function getRootAggregation(item: PivotAggsConfig) { + let rootItem = item; + while (rootItem.parentAgg !== undefined) { + rootItem = rootItem.parentAgg; + } + return rootItem; +} + export const usePivotConfig = ( defaults: StepDefineExposedState, indexPattern: StepDefineFormProps['searchItems']['indexPattern'] ) => { const toastNotifications = useToastNotifications(); - const { - aggOptions, - aggOptionsData, - groupByOptions, - groupByOptionsData, - } = getPivotDropdownOptions(indexPattern); + const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData } = useMemo( + () => getPivotDropdownOptions(indexPattern), + [indexPattern] + ); + // The list of selected aggregations + const [aggList, setAggList] = useState(defaults.aggList); // The list of selected group by fields const [groupByList, setGroupByList] = useState(defaults.groupByList); - const addGroupBy = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotGroupByConfig = groupByOptionsData[label]; - const aggName: AggName = config.aggName; + const addGroupBy = useCallback( + (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotGroupByConfig = groupByOptionsData[label]; + const aggName: AggName = config.aggName; - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const aggNameConflictMessages = getAggNameConflictToastMessages( + aggName, + aggList, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - groupByList[aggName] = config; - setGroupByList({ ...groupByList }); - }; - - const updateGroupBy = (previousAggName: AggName, item: PivotGroupByConfig) => { - const groupByListWithoutPrevious = { ...groupByList }; - delete groupByListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggList, - groupByListWithoutPrevious - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + groupByList[aggName] = config; + setGroupByList({ ...groupByList }); + }, + [aggList, groupByList, groupByOptionsData, toastNotifications] + ); - groupByListWithoutPrevious[item.aggName] = item; - setGroupByList(groupByListWithoutPrevious); - }; + const updateGroupBy = useCallback( + (previousAggName: AggName, item: PivotGroupByConfig) => { + const groupByListWithoutPrevious = { ...groupByList }; + delete groupByListWithoutPrevious[previousAggName]; - const deleteGroupBy = (aggName: AggName) => { - delete groupByList[aggName]; - setGroupByList({ ...groupByList }); - }; + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggList, + groupByListWithoutPrevious + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - // The list of selected aggregations - const [aggList, setAggList] = useState(defaults.aggList); + groupByListWithoutPrevious[item.aggName] = item; + setGroupByList(groupByListWithoutPrevious); + }, + [aggList, groupByList, toastNotifications] + ); + + const deleteGroupBy = useCallback( + (aggName: AggName) => { + delete groupByList[aggName]; + setGroupByList({ ...groupByList }); + }, + [groupByList] + ); /** * Adds an aggregation to the list. */ - const addAggregation = (d: DropDownLabel[]) => { - const label: AggName = d[0].label; - const config: PivotAggsConfig = aggOptionsData[label]; - const aggName: AggName = config.aggName; - - const aggNameConflictMessages = getAggNameConflictToastMessages(aggName, aggList, groupByList); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const addAggregation = useCallback( + (d: DropDownLabel[]) => { + const label: AggName = d[0].label; + const config: PivotAggsConfig = aggOptionsData[label]; + const aggName: AggName = config.aggName; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + aggName, + aggList, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } - aggList[aggName] = config; - setAggList({ ...aggList }); - }; + aggList[aggName] = config; + setAggList({ ...aggList }); + }, + [aggList, aggOptionsData, groupByList, toastNotifications] + ); /** * Adds updated aggregation to the list */ - const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => { - const aggListWithoutPrevious = { ...aggList }; - delete aggListWithoutPrevious[previousAggName]; - - const aggNameConflictMessages = getAggNameConflictToastMessages( - item.aggName, - aggListWithoutPrevious, - groupByList - ); - if (aggNameConflictMessages.length > 0) { - aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); - return; - } + const updateAggregation = useCallback( + (previousAggName: AggName, item: PivotAggsConfig) => { + const aggListWithoutPrevious = { ...aggList }; + delete aggListWithoutPrevious[previousAggName]; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + item.aggName, + aggListWithoutPrevious, + groupByList + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + aggListWithoutPrevious[item.aggName] = item; + setAggList(aggListWithoutPrevious); + }, + [aggList, groupByList, toastNotifications] + ); - aggListWithoutPrevious[item.aggName] = item; - setAggList(aggListWithoutPrevious); - }; + /** + * Adds sub-aggregation to the aggregation item + */ + const addSubAggregation = useCallback( + (item: PivotAggsConfig, d: DropDownLabel[]) => { + if (!item.isSubAggsSupported) { + throw new Error(`Aggregation "${item.agg}" does not support sub-aggregations`); + } + const label: AggName = d[0].label; + const config: PivotAggsConfig = aggOptionsData[label]; + + item.subAggs = item.subAggs ?? {}; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + config.aggName, + item.subAggs, + {} + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + + item.subAggs[config.aggName] = config; + + const newRootItem = cloneAggItem(getRootAggregation(item)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [aggOptionsData, toastNotifications, updateAggregation] + ); + + /** + * Updates sub-aggregation of the aggregation item + */ + const updateSubAggregation = useCallback( + (prevSubItemName: AggName, subItem: PivotAggsConfig) => { + const parent = subItem.parentAgg; + if (!parent || !parent.subAggs) { + throw new Error('No parent aggregation reference found'); + } + + const { [prevSubItemName]: deleted, ...newSubAgg } = parent.subAggs; + + const aggNameConflictMessages = getAggNameConflictToastMessages( + subItem.aggName, + newSubAgg, + {} + ); + if (aggNameConflictMessages.length > 0) { + aggNameConflictMessages.forEach((m) => toastNotifications.addDanger(m)); + return; + } + + parent.subAggs = { + ...newSubAgg, + [subItem.aggName]: subItem, + }; + const newRootItem = cloneAggItem(getRootAggregation(subItem)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [toastNotifications, updateAggregation] + ); + + /** + * Deletes sub-aggregation of the aggregation item + */ + const deleteSubAggregation = useCallback( + (item: PivotAggsConfig, subAggName: string) => { + if (!item.subAggs || !item.subAggs[subAggName]) { + throw new Error('Unable to delete a sub-agg'); + } + delete item.subAggs[subAggName]; + const newRootItem = cloneAggItem(getRootAggregation(item)); + updateAggregation(newRootItem.aggName, newRootItem); + }, + [updateAggregation] + ); /** * Deletes aggregation from the list */ - const deleteAggregation = (aggName: AggName) => { - delete aggList[aggName]; - setAggList({ ...aggList }); - }; + const deleteAggregation = useCallback( + (aggName: AggName) => { + delete aggList[aggName]; + setAggList({ ...aggList }); + }, + [aggList] + ); - const pivotAggsArr = dictionaryToArray(aggList); - const pivotGroupByArr = dictionaryToArray(groupByList); + const pivotAggsArr = useMemo(() => dictionaryToArray(aggList), [aggList]); + const pivotGroupByArr = useMemo(() => dictionaryToArray(groupByList), [groupByList]); const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0; - return { - actions: { + const actions = useMemo(() => { + return { addAggregation, addGroupBy, + addSubAggregation, + updateSubAggregation, + deleteSubAggregation, deleteAggregation, deleteGroupBy, setAggList, setGroupByList, updateAggregation, updateGroupBy, - }, - state: { - aggList, - aggOptions, - aggOptionsData, - groupByList, - groupByOptions, - groupByOptionsData, - pivotAggsArr, - pivotGroupByArr, - valid, - }, - }; + }; + }, [ + addAggregation, + addGroupBy, + addSubAggregation, + deleteAggregation, + deleteGroupBy, + deleteSubAggregation, + updateAggregation, + updateGroupBy, + updateSubAggregation, + ]); + + return useMemo(() => { + return { + actions, + state: { + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + pivotAggsArr, + pivotGroupByArr, + valid, + }, + }; + }, [ + actions, + aggList, + aggOptions, + aggOptionsData, + groupByList, + groupByOptions, + groupByOptionsData, + pivotAggsArr, + pivotGroupByArr, + valid, + ]); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index fc47a9e3d3477..f5980ae2243d3 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -73,7 +73,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi }, [ JSON.stringify(advancedPivotEditor.state), JSON.stringify(advancedSourceEditor.state), - JSON.stringify(pivotConfig.state), + pivotConfig.state, JSON.stringify(searchBar.state), /* eslint-enable react-hooks/exhaustive-deps */ ]); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5be46ce4bcd2d..76636779001e7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9654,7 +9654,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.showActions": "アクションを表示", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "すべての列を表示", "xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle": "分類ジョブID {jobId}のデスティネーションインデックス", - "xpack.ml.dataframe.analytics.clone.flyoutHeaderTitle": "{job_id} からのジョブのクローンを作成", "xpack.ml.dataframe.analytics.create.advancedEditor.codeEditorAriaLabel": "高度な分析ジョブエディター", "xpack.ml.dataframe.analytics.create.advancedEditor.configRequestBody": "構成リクエスト本文", "xpack.ml.dataframe.analytics.create.advancedEditor.jobIdExistsError": "このIDの分析ジョブが既に存在します。", @@ -9687,22 +9686,12 @@ "xpack.ml.dataframe.analytics.create.destinationIndexLabel": "デスティネーションインデックス", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessage": "Kibanaインデックスパターンの作成中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。", - "xpack.ml.dataframe.analytics.create.enableAdvancedEditorHelpText": "高度なエディターからこのフォームには戻れません。", - "xpack.ml.dataframe.analytics.create.enableAdvancedEditorSwitch": "詳細エディターを有効にする", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameIndexNames": "既存のインデックス名の取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "データフレーム分析ジョブの開始中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.create.excludedFieldsHelpText": "分析から除外するフィールドを選択してください。他のすべてのサポートされるフィールドが含まれます。", "xpack.ml.dataframe.analytics.create.excludedFieldsLabel": "除外されたフィールド", - "xpack.ml.dataframe.analytics.create.excludesInputAriaLabel": "任意。除外するフィールドを入力または選択してください。", - "xpack.ml.dataframe.analytics.create.excludesOptionsNoSupportedFields": "このインデックスパターンのサポートされている分析フィールドが見つかりませんでした。", - "xpack.ml.dataframe.analytics.create.flyoutCancelButton": "キャンセル", - "xpack.ml.dataframe.analytics.create.flyoutCloseButton": "閉じる", - "xpack.ml.dataframe.analytics.create.flyoutCreateButton": "作成", - "xpack.ml.dataframe.analytics.create.flyoutHeaderTitle": "分析ジョブの作成", - "xpack.ml.dataframe.analytics.create.flyoutStartButton": "開始", "xpack.ml.dataframe.analytics.create.indexPatternAlreadyExistsError": "このタイトルのインデックスパターンが既に存在します。", "xpack.ml.dataframe.analytics.create.indexPatternExistsError": "このタイトルのインデックスパターンが既に存在します。", "xpack.ml.dataframe.analytics.create.jobDescription.helpText": "オプションの説明テキストです", @@ -9723,12 +9712,6 @@ "xpack.ml.dataframe.analytics.create.numTopFeatureImportanceValuesLabel": "機能重要度値", "xpack.ml.dataframe.analytics.create.outlierDetectionHelpText": "外れ値検出ジョブは、表に示すようなデータ構造でマッピングされたソースインデックスを必要とし、数字とブール値フィールドのみを分析します。カスタムオプションを構成に追加するには、詳細エディターを使用します。", "xpack.ml.dataframe.analytics.create.outlierRegressionHelpText": "リグレッションジョブは数値フィールドのみを分析します。予測フィールド名などのカスタムオプションを適用するには、詳細エディターを使用します。", - "xpack.ml.dataframe.analytics.create.sourceIndexFieldCheckError": "数値フィールドの確認中に問題が発生しました。ページを更新して再起動してください。", - "xpack.ml.dataframe.analytics.create.sourceIndexHelpText": "このインデックスパターンには数字タイプのフィールドが含まれていません。分析ジョブで外れ値が検出されない可能性があります。", - "xpack.ml.dataframe.analytics.create.sourceIndexInputAriaLabel": "ソースインデックスパターンまたは検索。", - "xpack.ml.dataframe.analytics.create.sourceIndexInvalidError": "無効なソースインデックス名。スペースや{characterList}を含めることはできません", - "xpack.ml.dataframe.analytics.create.sourceIndexLabel": "ソースインデックス", - "xpack.ml.dataframe.analytics.create.sourceIndexPlaceholder": "ソースインデックスパターンを選択してください。", "xpack.ml.dataframe.analytics.create.startDataFrameAnalyticsSuccessMessage": "データフレーム分析 {jobId} の開始リクエストが受け付けられました。", "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "トレーニングパーセンテージ", "xpack.ml.dataframe.analytics.errorCallout.evaluateErrorTitle": "データの読み込み中にエラーが発生しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dff26907b48ed..331e9d67c3897 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9658,7 +9658,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.showActions": "显示操作", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "显示所有列", "xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle": "分类作业 ID {jobId} 的目标索引", - "xpack.ml.dataframe.analytics.clone.flyoutHeaderTitle": "从 {job_id} 克隆作业", "xpack.ml.dataframe.analytics.create.advancedEditor.codeEditorAriaLabel": "高级分析作业编辑器", "xpack.ml.dataframe.analytics.create.advancedEditor.configRequestBody": "配置请求正文", "xpack.ml.dataframe.analytics.create.advancedEditor.jobIdExistsError": "已存在具有此 ID 的分析作业。", @@ -9691,22 +9690,12 @@ "xpack.ml.dataframe.analytics.create.destinationIndexLabel": "目标 IP", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误:", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。", - "xpack.ml.dataframe.analytics.create.enableAdvancedEditorHelpText": "您不能从高级编辑器切回到此表单。", - "xpack.ml.dataframe.analytics.create.enableAdvancedEditorSwitch": "启用高级编辑器", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameIndexNames": "获取现有索引名称时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:", - "xpack.ml.dataframe.analytics.create.excludedFieldsHelpText": "选择要从分析中排除的字段。包括所有其他支持的字段。", "xpack.ml.dataframe.analytics.create.excludedFieldsLabel": "排除的字段", - "xpack.ml.dataframe.analytics.create.excludesInputAriaLabel": "可选。输入或选择要排除的字段。", - "xpack.ml.dataframe.analytics.create.excludesOptionsNoSupportedFields": "没有为此索引模式找到任何支持的分析字段。", - "xpack.ml.dataframe.analytics.create.flyoutCancelButton": "取消", - "xpack.ml.dataframe.analytics.create.flyoutCloseButton": "关闭", - "xpack.ml.dataframe.analytics.create.flyoutCreateButton": "创建", - "xpack.ml.dataframe.analytics.create.flyoutHeaderTitle": "创建分析作业", - "xpack.ml.dataframe.analytics.create.flyoutStartButton": "开始", "xpack.ml.dataframe.analytics.create.indexPatternAlreadyExistsError": "具有此名称的索引模式已存在。", "xpack.ml.dataframe.analytics.create.indexPatternExistsError": "具有此名称的索引模式已存在。", "xpack.ml.dataframe.analytics.create.jobDescription.helpText": "可选的描述文本", @@ -9727,12 +9716,6 @@ "xpack.ml.dataframe.analytics.create.numTopFeatureImportanceValuesLabel": "功能重要性值", "xpack.ml.dataframe.analytics.create.outlierDetectionHelpText": "离群值检测作业需要映射为表状数据结构的源索引,并仅分析数值和布尔值字段。使用高级编辑器将定制选项添加到配置。", "xpack.ml.dataframe.analytics.create.outlierRegressionHelpText": "回归作业仅分析数值字段。使用高级编辑器来应用定制选项,如预测字段名称。", - "xpack.ml.dataframe.analytics.create.sourceIndexFieldCheckError": "检查数值字段时出现问题。请刷新页面并重试。", - "xpack.ml.dataframe.analytics.create.sourceIndexHelpText": "此索引模式不包含任何数值类型字段。分析作业可能无法提供任何离群值。", - "xpack.ml.dataframe.analytics.create.sourceIndexInputAriaLabel": "源索引模式或搜索。", - "xpack.ml.dataframe.analytics.create.sourceIndexInvalidError": "源索引名称无效,其不能包含空格或以下字符:{characterList}", - "xpack.ml.dataframe.analytics.create.sourceIndexLabel": "源索引", - "xpack.ml.dataframe.analytics.create.sourceIndexPlaceholder": "选择源索引模式。", "xpack.ml.dataframe.analytics.create.startDataFrameAnalyticsSuccessMessage": "数据帧分析 {jobId} 启动请求已确认。", "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "训练百分比", "xpack.ml.dataframe.analytics.errorCallout.evaluateErrorTitle": "加载数据时出错。", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 24dead473fb61..cbbbbfaea7ea3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -197,7 +197,7 @@ export const ConnectorEditFlyout = ({ )} @@ -19,7 +24,16 @@ exports[`CertStatus renders expected elements for valid props 1`] = ` class="euiFlexItem euiFlexItem--flexGrowZero" > - OK + OK +
+
+ for 4 months +
+
diff --git a/x-pack/plugins/uptime/public/components/certificates/cert_status.tsx b/x-pack/plugins/uptime/public/components/certificates/cert_status.tsx index e7a86ce98fa3c..ea0a49a4a6c5b 100644 --- a/x-pack/plugins/uptime/public/components/certificates/cert_status.tsx +++ b/x-pack/plugins/uptime/public/components/certificates/cert_status.tsx @@ -5,45 +5,95 @@ */ import React from 'react'; -import { EuiHealth } from '@elastic/eui'; +import moment from 'moment'; +import styled from 'styled-components'; +import { EuiHealth, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; import { Cert } from '../../../common/runtime_types'; import { useCertStatus } from '../../hooks'; import * as labels from './translations'; import { CERT_STATUS } from '../../../common/constants'; +import { selectDynamicSettings } from '../../state/selectors'; interface Props { cert: Cert; } +const DateText = styled(EuiText)` + display: inline-block; + margin-left: 5px; +`; + export const CertStatus: React.FC = ({ cert }) => { const certStatus = useCertStatus(cert?.not_after, cert?.not_before); + const dss = useSelector(selectDynamicSettings); + + const relativeDate = moment(cert?.not_after).fromNow(); + if (certStatus === CERT_STATUS.EXPIRING_SOON) { return ( - {labels.EXPIRES_SOON} + + {labels.EXPIRES_SOON} + {' '} + + {relativeDate} + + ); } if (certStatus === CERT_STATUS.EXPIRED) { return ( - {labels.EXPIRED} + + {labels.EXPIRED} + {' '} + + {relativeDate} + + ); } if (certStatus === CERT_STATUS.TOO_OLD) { + const ageThreshold = dss.settings?.certAgeThreshold; + + const oldRelativeDate = moment(cert?.not_before).add(ageThreshold, 'days').fromNow(); + return ( - {labels.TOO_OLD} + + {labels.TOO_OLD} + + {oldRelativeDate} + + ); } + const okRelativeDate = moment(cert?.not_after).fromNow(true); + return ( - {labels.OK} + + {labels.OK} + {' '} + + + + ); }; diff --git a/x-pack/plugins/uptime/server/lib/requests/search/__tests__/refine_potential_matches.test.ts b/x-pack/plugins/uptime/server/lib/requests/search/__tests__/refine_potential_matches.test.ts new file mode 100644 index 0000000000000..283f5fb8909f6 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/search/__tests__/refine_potential_matches.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fullyMatchingIds } from '../refine_potential_matches'; +import { MonitorLocCheckGroup } from '..'; + +const mockQueryResult = (opts: { latestSummary: any; latestMatching: any }) => { + return { + aggregations: { + monitor: { + buckets: [ + { + key: 'my-monitor', + location: { + buckets: [ + { + key: 'my-location', + summaries: { + latest: { + hits: { + hits: [ + { + _source: opts.latestSummary, + }, + ], + }, + }, + }, + latest_matching: { + top: { + hits: { + hits: [ + { + _source: opts.latestMatching, + }, + ], + }, + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; +}; + +describe('fully matching IDs', () => { + it('should exclude items whose latest result does not match', () => { + const queryRes = mockQueryResult({ + latestSummary: { + '@timestamp': '2020-06-04T12:39:54.698-0500', + monitor: { + check_group: 'latest-summary-check-group', + }, + summary: { + up: 1, + down: 0, + }, + }, + latestMatching: { + '@timestamp': '2019-06-04T12:39:54.698-0500', + summary: { + up: 1, + down: 0, + }, + }, + }); + const res = fullyMatchingIds(queryRes, undefined); + const expected = new Map(); + expect(res).toEqual(expected); + }); + + it('should include items whose latest result does match', () => { + const queryRes = mockQueryResult({ + latestSummary: { + '@timestamp': '2020-06-04T12:39:54.698-0500', + monitor: { + check_group: 'latest-summary-check-group', + }, + summary: { + up: 1, + down: 0, + }, + }, + latestMatching: { + '@timestamp': '2020-06-04T12:39:54.698-0500', + summary: { + up: 1, + down: 0, + }, + }, + }); + const res = fullyMatchingIds(queryRes, undefined); + const expected = new Map(); + expected.set('my-monitor', [ + { + checkGroup: 'latest-summary-check-group', + location: 'my-location', + monitorId: 'my-monitor', + status: 'up', + summaryTimestamp: new Date('2020-06-04T12:39:54.698-0500'), + }, + ]); + expect(res).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/search/fetch_chunk.ts b/x-pack/plugins/uptime/server/lib/requests/search/fetch_chunk.ts index 77676ac9a6373..2a5f1f1261cb3 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/fetch_chunk.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/fetch_chunk.ts @@ -26,12 +26,12 @@ export const fetchChunk: ChunkFetcher = async ( searchAfter: any, size: number ): Promise => { - const { monitorIds, checkGroups, searchAfter: foundSearchAfter } = await findPotentialMatches( + const { monitorIds, searchAfter: foundSearchAfter } = await findPotentialMatches( queryContext, searchAfter, size ); - const matching = await refinePotentialMatches(queryContext, monitorIds, checkGroups); + const matching = await refinePotentialMatches(queryContext, monitorIds); return { monitorGroups: matching, diff --git a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts index a3e7324086073..ac4ff91230b95 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts @@ -8,12 +8,8 @@ import { get, set } from 'lodash'; import { CursorDirection } from '../../../../common/runtime_types'; import { QueryContext } from './query_context'; -// This is the first phase of the query. In it, we find the most recent check groups that matched the given query. -// Note that these check groups may not be the most recent groups for the matching monitor ID! We'll filter those /** - * This is the first phase of the query. In it, we find the most recent check groups that matched the given query. - * Note that these check groups may not be the most recent groups for the matching monitor ID. They'll be filtered - * out in the next phase. + * This is the first phase of the query. In it, we find all monitor IDs that have ever matched the given filters. * @param queryContext the data and resources needed to perform the query * @param searchAfter indicates where Elasticsearch should continue querying on subsequent requests, if at all * @param size the minimum size of the matches to chunk @@ -24,29 +20,14 @@ export const findPotentialMatches = async ( size: number ) => { const queryResult = await query(queryContext, searchAfter, size); - const checkGroups = new Set(); const monitorIds: string[] = []; get(queryResult, 'aggregations.monitors.buckets', []).forEach((b: any) => { const monitorId = b.key.monitor_id; monitorIds.push(monitorId); - - // Doc count can be zero if status filter optimization does not match - if (b.doc_count > 0) { - // Here we grab the most recent 2 check groups per location and add them to the list. - // Why 2? Because the most recent one may be a partial result from mode: all, and hence not match a summary doc. - b.locations.buckets.forEach((lb: any) => { - lb.ips.buckets.forEach((ib: any) => { - ib.top.hits.hits.forEach((h: any) => { - checkGroups.add(h._source.monitor.check_group); - }); - }); - }); - } }); return { monitorIds, - checkGroups, searchAfter: queryResult.aggregations?.monitors?.after_key, }; }; @@ -89,29 +70,6 @@ const queryBody = async (queryContext: QueryContext, searchAfter: any, size: num }, ], }, - aggs: { - // Here we grab the most recent 2 check groups per location. - // Why 2? Because the most recent one may not be for a summary, it may be incomplete. - locations: { - terms: { field: 'observer.geo.name', missing: '__missing__' }, - aggs: { - ips: { - terms: { field: 'monitor.ip', missing: '0.0.0.0' }, - aggs: { - top: { - top_hits: { - sort: [{ '@timestamp': 'desc' }], - _source: { - includes: ['monitor.check_group', '@timestamp'], - }, - size: 2, - }, - }, - }, - }, - }, - }, - }, }, }, }; diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index e5e3de322cbc7..2f54f3f6dd689 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -18,18 +18,14 @@ import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page'; // check groups for their associated monitor IDs. If not, it discards the result. export const refinePotentialMatches = async ( queryContext: QueryContext, - potentialMatchMonitorIDs: string[], - potentialMatchCheckGroups: Set + potentialMatchMonitorIDs: string[] ): Promise => { if (potentialMatchMonitorIDs.length === 0) { return []; } - const recentGroupsMatchingStatus = await fullyMatchingIds( - queryContext, - potentialMatchMonitorIDs, - potentialMatchCheckGroups - ); + const queryResult = await query(queryContext, potentialMatchMonitorIDs); + const recentGroupsMatchingStatus = await fullyMatchingIds(queryResult, queryContext.statusFilter); // Return the monitor groups filtering out potential matches that weren't current const matches: MonitorGroups[] = potentialMatchMonitorIDs @@ -49,27 +45,35 @@ export const refinePotentialMatches = async ( return matches; }; -const fullyMatchingIds = async ( - queryContext: QueryContext, - potentialMatchMonitorIDs: string[], - potentialMatchCheckGroups: Set -) => { - const mostRecentQueryResult = await mostRecentCheckGroups(queryContext, potentialMatchMonitorIDs); - +export const fullyMatchingIds = (queryResult: any, statusFilter?: string) => { const matching = new Map(); - MonitorLoop: for (const monBucket of mostRecentQueryResult.aggregations.monitor.buckets) { + MonitorLoop: for (const monBucket of queryResult.aggregations.monitor.buckets) { const monitorId: string = monBucket.key; const groups: MonitorLocCheckGroup[] = []; + // Did at least one location match? + let matched = false; for (const locBucket of monBucket.location.buckets) { const location = locBucket.key; - const topSource = locBucket.top.hits.hits[0]._source; - const checkGroup = topSource.monitor.check_group; - const status = topSource.summary.down > 0 ? 'down' : 'up'; + const latestSource = locBucket.summaries.latest.hits.hits[0]._source; + const latestStillMatchingSource = locBucket.latest_matching.top.hits.hits[0]?._source; + // If the most recent document still matches the most recent document matching the current filters + // we can include this in the result + // + // We just check if the timestamp is greater. Note this may match an incomplete check group + // that has not yet sent a summary doc + if ( + latestStillMatchingSource && + latestStillMatchingSource['@timestamp'] >= latestSource['@timestamp'] + ) { + matched = true; + } + const checkGroup = latestSource.monitor.check_group; + const status = latestSource.summary.down > 0 ? 'down' : 'up'; // This monitor doesn't match, so just skip ahead and don't add it to the output // Only skip in case of up statusFilter, for a monitor to be up, all checks should be up - if (queryContext?.statusFilter === 'up' && queryContext.statusFilter !== status) { + if (statusFilter === 'up' && statusFilter !== status) { continue MonitorLoop; } @@ -78,12 +82,12 @@ const fullyMatchingIds = async ( location, checkGroup, status, - summaryTimestamp: topSource['@timestamp'], + summaryTimestamp: new Date(latestSource['@timestamp']), }); } - // We only truly match the monitor if one of the most recent check groups was found in the potential matches phase - if (groups.some((g) => potentialMatchCheckGroups.has(g.checkGroup))) { + // If one location matched, include data from all locations in the result set + if (matched) { matching.set(monitorId, groups); } } @@ -91,7 +95,7 @@ const fullyMatchingIds = async ( return matching; }; -export const mostRecentCheckGroups = async ( +export const query = async ( queryContext: QueryContext, potentialMatchMonitorIDs: string[] ): Promise => { @@ -104,8 +108,6 @@ export const mostRecentCheckGroups = async ( filter: [ await queryContext.dateRangeFilter(), { terms: { 'monitor.id': potentialMatchMonitorIDs } }, - // only match summary docs because we only want the latest *complete* check group. - { exists: { field: 'summary' } }, ], }, }, @@ -116,13 +118,39 @@ export const mostRecentCheckGroups = async ( location: { terms: { field: 'observer.geo.name', missing: 'N/A', size: 100 }, aggs: { - top: { - top_hits: { - sort: [{ '@timestamp': 'desc' }], - _source: { - includes: ['monitor.check_group', '@timestamp', 'summary.up', 'summary.down'], + summaries: { + // only match summary docs because we only want the latest *complete* check group. + filter: { exists: { field: 'summary' } }, + aggs: { + latest: { + top_hits: { + sort: [{ '@timestamp': 'desc' }], + _source: { + includes: [ + 'monitor.check_group', + '@timestamp', + 'summary.up', + 'summary.down', + ], + }, + size: 1, + }, + }, + }, + }, + // We want to find the latest check group, even if it's not part of a summary + latest_matching: { + filter: queryContext.filterClause || { match_all: {} }, + aggs: { + top: { + top_hits: { + sort: [{ '@timestamp': 'desc' }], + _source: { + includes: ['monitor.check_group', '@timestamp'], + }, + size: 1, + }, }, - size: 1, }, }, }, diff --git a/x-pack/plugins/watcher/public/plugin.ts b/x-pack/plugins/watcher/public/plugin.ts index 80f8503af67ce..3d907ac0dff3a 100644 --- a/x-pack/plugins/watcher/public/plugin.ts +++ b/x-pack/plugins/watcher/public/plugin.ts @@ -64,8 +64,6 @@ export class WatcherUIPlugin implements Plugin { }, }); - watcherESApp.disable(); - // TODO: Fix the below dependency on `home` plugin inner workings // Because the home feature catalogue does not have enable/disable functionality we pass // the config in but keep a reference for enabling and disabling showing on home based on @@ -85,9 +83,16 @@ export class WatcherUIPlugin implements Plugin { home.featureCatalogue.register(watcherHome); licensing.license$.pipe(first(), map(licenseToLicenseStatus)).subscribe(({ valid }) => { + // NOTE: We enable the plugin by default instead of disabling it by default because this + // creates a race condition that can cause the app nav item to not render in the side nav. + // The race condition still exists, but it will result in the item rendering when it shouldn't + // (e.g. on a license it's not available for), instead of *not* rendering when it *should*, + // which is a less frustrating UX. if (valid) { watcherESApp.enable(); watcherHome.showOnHomePage = true; + } else { + watcherESApp.disable(); } }); } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 4392299a78e72..c120e1f780761 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -6,6 +6,7 @@ const alwaysImportedTests = [ require.resolve('../test/functional/config.js'), + require.resolve('../test/functional_endpoint/config.ts'), require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), require.resolve('../test/functional/config_security_trial.ts'), diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 19aee29e9b36d..bc209e2bb4925 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -88,7 +88,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) 'my-slack1': { actionTypeId: '.slack', name: 'Slack#xyz', - config: { + secrets: { webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz', }, }, diff --git a/x-pack/test/api_integration/apis/endpoint/policy.ts b/x-pack/test/api_integration/apis/endpoint/policy.ts index 792c8440cdea1..c3e95f2db1bc0 100644 --- a/x-pack/test/api_integration/apis/endpoint/policy.ts +++ b/x-pack/test/api_integration/apis/endpoint/policy.ts @@ -10,7 +10,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('Endpoint policy api', () => { + // SKIPPED as it is failing ES PROMOTION: https://github.com/elastic/kibana/issues/68638 + describe.skip('Endpoint policy api', () => { describe('GET /api/endpoint/policy_response', () => { before(async () => await esArchiver.load('endpoint/policy')); diff --git a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts index a33e82ad9f79d..64bf03a043b55 100644 --- a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts +++ b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts @@ -61,7 +61,10 @@ export default function ({ getService }: FtrProviderContext) { expect(testComponentTemplate).to.eql({ name: COMPONENT_NAME, - component_template: COMPONENT, + usedBy: [], + hasSettings: true, + hasMappings: true, + hasAliases: false, }); }); }); @@ -74,8 +77,9 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({ name: COMPONENT_NAME, - component_template: { - ...COMPONENT, + ...COMPONENT, + _kbnMeta: { + usedBy: [], }, }); }); diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts new file mode 100644 index 0000000000000..9a8511b4331ea --- /dev/null +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +// @ts-ignore +import { API_BASE_PATH } from './constants'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + const createDataStream = (name: string) => { + // A data stream requires an index template before it can be created. + return es.dataManagement + .saveComposableIndexTemplate({ + name, + body: { + index_patterns: ['*'], + template: { + settings: {}, + }, + data_stream: { + timestamp_field: '@timestamp', + }, + }, + }) + .then(() => + es.dataManagement.createDataStream({ + name, + }) + ); + }; + + const deleteDataStream = (name: string) => { + return es.dataManagement + .deleteComposableIndexTemplate({ + name, + }) + .then(() => + es.dataManagement.deleteDataStream({ + name, + }) + ); + }; + + describe('Data streams', function () { + const testDataStreamName = 'test-data-stream'; + + describe('Get', () => { + before(async () => await createDataStream(testDataStreamName)); + after(async () => await deleteDataStream(testDataStreamName)); + + describe('all data streams', () => { + it('returns an array of data streams', async () => { + const { body: dataStreams } = await supertest + .get(`${API_BASE_PATH}/data_streams`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + // ES determines these values so we'll just echo them back. + const { name: indexName, uuid } = dataStreams[0].indices[0]; + expect(dataStreams).to.eql([ + { + name: testDataStreamName, + timeStampField: '@timestamp', + indices: [ + { + name: indexName, + uuid, + }, + ], + generation: 1, + }, + ]); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/index_management/index.js b/x-pack/test/api_integration/apis/management/index_management/index.js index fdee325938ff4..93e8a4a8d6130 100644 --- a/x-pack/test/api_integration/apis/management/index_management/index.js +++ b/x-pack/test/api_integration/apis/management/index_management/index.js @@ -10,6 +10,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./mapping')); loadTestFile(require.resolve('./settings')); loadTestFile(require.resolve('./stats')); + loadTestFile(require.resolve('./data_streams')); loadTestFile(require.resolve('./templates')); loadTestFile(require.resolve('./component_templates')); }); diff --git a/x-pack/test/api_integration/apis/management/index_management/lib/elasticsearch.js b/x-pack/test/api_integration/apis/management/index_management/lib/elasticsearch.js index b950a56a913db..1a1517567eaed 100644 --- a/x-pack/test/api_integration/apis/management/index_management/lib/elasticsearch.js +++ b/x-pack/test/api_integration/apis/management/index_management/lib/elasticsearch.js @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { getRandomString } from './random'; /** diff --git a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js index 9f1fab4039397..292aabad85054 100644 --- a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js +++ b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js @@ -7,10 +7,10 @@ import { API_BASE_PATH, INDEX_PATTERNS } from './constants'; export const registerHelpers = ({ supertest }) => { - const getAllTemplates = () => supertest.get(`${API_BASE_PATH}/index-templates`); + const getAllTemplates = () => supertest.get(`${API_BASE_PATH}/index_templates`); const getOneTemplate = (name, isLegacy = true) => - supertest.get(`${API_BASE_PATH}/index-templates/${name}?legacy=${isLegacy}`); + supertest.get(`${API_BASE_PATH}/index_templates/${name}?legacy=${isLegacy}`); const getTemplatePayload = (name, isLegacy = true) => ({ name, @@ -50,17 +50,17 @@ export const registerHelpers = ({ supertest }) => { }); const createTemplate = (payload) => - supertest.post(`${API_BASE_PATH}/index-templates`).set('kbn-xsrf', 'xxx').send(payload); + supertest.post(`${API_BASE_PATH}/index_templates`).set('kbn-xsrf', 'xxx').send(payload); const deleteTemplates = (templates) => supertest - .post(`${API_BASE_PATH}/delete-index-templates`) + .post(`${API_BASE_PATH}/delete_index_templates`) .set('kbn-xsrf', 'xxx') .send({ templates }); const updateTemplate = (payload, templateName) => supertest - .put(`${API_BASE_PATH}/index-templates/${templateName}`) + .put(`${API_BASE_PATH}/index_templates/${templateName}`) .set('kbn-xsrf', 'xxx') .send(payload); diff --git a/x-pack/test/api_integration/apis/ml/job_validation/validate.ts b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts index 55497940190cb..fc8f837744221 100644 --- a/x-pack/test/api_integration/apis/ml/job_validation/validate.ts +++ b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts @@ -226,6 +226,15 @@ export default ({ getService }: FtrProviderContext) => { .send(requestBody) .expect(200); + // The existance and value of maxModelMemoryLimit depends on ES settings + // and may vary between test environments, e.g. cloud vs non-cloud, + // so it should not be part of the validation + body.forEach((element: any) => { + if (element.hasOwnProperty('maxModelMemoryLimit')) { + delete element.maxModelMemoryLimit; + } + }); + expect(body).to.eql([ { id: 'job_id_valid', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index 449168d73f4ef..8847b0c3250ac 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -6,7 +6,10 @@ import expect from '@kbn/expect'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { + DETECTION_ENGINE_RULES_URL, + DETECTION_ENGINE_RULES_STATUS_URL, +} from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { createSignalsIndex, @@ -65,6 +68,46 @@ export default ({ getService }: FtrProviderContext) => { expect(bodyToCompare).to.eql(getSimpleRuleOutput()); }); + /* + This test is to ensure no future regressions introduced by the following scenario + a call to updateApiKey was invalidating the api key used by the + rule while the rule was executing, or even before it executed, + on the first rule run. + this pr https://github.com/elastic/kibana/pull/68184 + fixed this by finding the true source of a bug that required the manual + api key update, and removed the call to that function. + + When the api key is updated before / while the rule is executing, the alert + executor no longer has access to a service to update the rule status + saved object in Elasticsearch. Because of this, we cannot set the rule into + a 'failure' state, so the user ends up seeing 'going to run' as that is the + last status set for the rule before it erupts in an error that cannot be + recorded inside of the executor. + + This adds an e2e test for the backend to catch that in case + this pops up again elsewhere. + */ + it('should create a single rule with a rule_id and validate it ran successfully', async () => { + const simpleRule = getSimpleRule(); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(200); + + // wait for Task Manager to execute the rule and update status + await new Promise((resolve) => setTimeout(resolve, 5000)); + const { body: statusBody } = await supertest + .post(DETECTION_ENGINE_RULES_STATUS_URL) + .set('kbn-xsrf', 'true') + .send({ ids: [body.id] }) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + expect(statusBody[body.id].current_status.status).to.eql('succeeded'); + }); + it('should create a single rule without an input index', async () => { const { index, ...payload } = getSimpleRule(); const { index: _index, ...expected } = getSimpleRuleOutput(); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 6c3b1c45e202e..47b09fd1fe3c7 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -6,7 +6,10 @@ import expect from '@kbn/expect'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { + DETECTION_ENGINE_RULES_URL, + DETECTION_ENGINE_RULES_STATUS_URL, +} from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { createSignalsIndex, @@ -68,6 +71,46 @@ export default ({ getService }: FtrProviderContext): void => { expect(bodyToCompare).to.eql(getSimpleRuleOutput()); }); + /* + This test is to ensure no future regressions introduced by the following scenario + a call to updateApiKey was invalidating the api key used by the + rule while the rule was executing, or even before it executed, + on the first rule run. + this pr https://github.com/elastic/kibana/pull/68184 + fixed this by finding the true source of a bug that required the manual + api key update, and removed the call to that function. + + When the api key is updated before / while the rule is executing, the alert + executor no longer has access to a service to update the rule status + saved object in Elasticsearch. Because of this, we cannot set the rule into + a 'failure' state, so the user ends up seeing 'going to run' as that is the + last status set for the rule before it erupts in an error that cannot be + recorded inside of the executor. + + This adds an e2e test for the backend to catch that in case + this pops up again elsewhere. + */ + it('should create a single rule with a rule_id and validate it ran successfully', async () => { + const simpleRule = getSimpleRule(); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([simpleRule]) + .expect(200); + + // wait for Task Manager to execute the rule and update status + await new Promise((resolve) => setTimeout(resolve, 5000)); + const { body: statusBody } = await supertest + .post(DETECTION_ENGINE_RULES_STATUS_URL) + .set('kbn-xsrf', 'true') + .send({ ids: [body[0].id] }) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + expect(statusBody[body[0].id].current_status.status).to.eql('succeeded'); + }); + it('should create a single rule without a rule_id', async () => { const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts index 6ff5a8c552ab7..f659857b89a97 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_statuses.ts @@ -42,6 +42,25 @@ export default ({ getService }: FtrProviderContext): void => { expect(body).to.eql({}); }); + /* + This test is to ensure no future regressions introduced by the following scenario + a call to updateApiKey was invalidating the api key used by the + rule while the rule was executing, or even before it executed, + on the first rule run. + this pr https://github.com/elastic/kibana/pull/68184 + fixed this by finding the true source of a bug that required the manual + api key update, and removed the call to that function. + + When the api key is updated before / while the rule is executing, the alert + executor no longer has access to a service to update the rule status + saved object in Elasticsearch. Because of this, we cannot set the rule into + a 'failure' state, so the user ends up seeing 'going to run' as that is the + last status set for the rule before it erupts in an error that cannot be + recorded inside of the executor. + + This adds an e2e test for the backend to catch that in case + this pops up again elsewhere. + */ it('should return a single rule status when a single rule is loaded from a find status with defaults added', async () => { // add a single rule const { body: resBody } = await supertest @@ -61,7 +80,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // expected result for status should be 'going to run' or 'succeeded - expect(['succeeded', 'going to run']).to.contain(body[resBody.id].current_status.status); + expect(body[resBody.id].current_status.status).to.eql('succeeded'); }); }); }; diff --git a/x-pack/test/functional/apps/index_management/home_page.ts b/x-pack/test/functional/apps/index_management/home_page.ts index e985e338122e7..90bc3603c1613 100644 --- a/x-pack/test/functional/apps/index_management/home_page.ts +++ b/x-pack/test/functional/apps/index_management/home_page.ts @@ -18,12 +18,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.common.navigateToApp('indexManagement'); }); - it('Loads the app', async () => { + it('Loads the app and renders the indices tab by default', async () => { await log.debug('Checking for section heading to say Index Management.'); const headingText = await pageObjects.indexManagement.sectionHeadingText(); expect(headingText).to.be('Index Management'); + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/indices`); + + // Verify content const indicesList = await testSubjects.exists('indicesList'); expect(indicesList).to.be(true); @@ -31,6 +36,23 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await reloadIndicesButton.isDisplayed()).to.be(true); }); + describe('Data streams', () => { + it('renders the data streams tab', async () => { + // Navigate to the data streams tab + await pageObjects.indexManagement.changeTabs('data_streamsTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/data_streams`); + + // Verify content + const dataStreamList = await testSubjects.exists('dataStreamList'); + expect(dataStreamList).to.be(true); + }); + }); + describe('Index templates', () => { it('renders the index templates tab', async () => { // Navigate to the index templates tab @@ -47,5 +69,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(templateList).to.be(true); }); }); + + describe('Component templates', () => { + it('renders the component templates tab', async () => { + // Navigate to the component templates tab + await pageObjects.indexManagement.changeTabs('component_templatesTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/component_templates`); + + // There should be no component templates by default, so we verify the empty prompt displays + const componentTemplateEmptyPrompt = await testSubjects.exists('emptyList'); + expect(componentTemplateEmptyPrompt).to.be(true); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index c8baa13b56408..b0376b35ebd2f 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -11,8 +11,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // flaky test, see https://github.com/elastic/kibana/issues/68356 - describe.skip('classification creation', function () { + describe('classification creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/bm_classification'); await ml.testResources.createIndexPatternIfNeeded('ft_bank_marketing', '@timestamp'); @@ -66,7 +65,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('selects the source data and loads the job wizard page', async () => { - ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); }); it('selects the job type', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index b5c2b7528437c..2daae60b0ad20 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -63,7 +63,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('selects the source data and loads the job wizard page', async () => { - ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); }); it('selects the job type', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index c818619a18378..35425e47526dd 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -11,8 +11,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // flaky test, see https://github.com/elastic/kibana/issues/68352 - describe.skip('regression creation', function () { + describe('regression creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/egs_regression'); await ml.testResources.createIndexPatternIfNeeded('ft_egs_regression', '@timestamp'); @@ -66,7 +65,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('selects the source data and loads the job wizard page', async () => { - ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); }); it('selects the job type', async () => { diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index a72f1691af647..d6dbcde436dcc 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -57,6 +57,26 @@ export default function ({ getService }: FtrProviderContext) { transformFilterAggTypeSelector: 'term', transformFilterTermValueSelector: 'New York', }, + subAggs: [ + { + identifier: 'max(products.base_price)', + label: 'products.base_price.max', + }, + { + identifier: 'filter(customer_gender)', + label: 'customer_gender.filter', + form: { + transformFilterAggTypeSelector: 'term', + transformFilterTermValueSelector: 'FEMALE', + }, + subAggs: [ + { + identifier: 'avg(taxful_total_price)', + label: 'taxful_total_price.avg', + }, + ], + }, + ], }, ], transformId: `ec_1_${Date.now()}`, @@ -87,12 +107,33 @@ export default function ({ getService }: FtrProviderContext) { field: 'products.base_price', }, }, - 'geoip.city_name.filter': { + 'New York': { filter: { term: { 'geoip.city_name': 'New York', }, }, + aggs: { + 'products.base_price.max': { + max: { + field: 'products.base_price', + }, + }, + FEMALE: { + filter: { + term: { + customer_gender: 'FEMALE', + }, + }, + aggs: { + 'taxful_total_price.avg': { + avg: { + field: 'taxful_total_price', + }, + }, + }, + }, + }, }, }, }, @@ -131,6 +172,12 @@ export default function ({ getService }: FtrProviderContext) { form: { transformFilterAggTypeSelector: 'exists', }, + subAggs: [ + { + identifier: 'max(products.discount_amount)', + label: 'products.discount_amount.max', + }, + ], }, ], transformId: `ec_2_${Date.now()}`, @@ -162,6 +209,13 @@ export default function ({ getService }: FtrProviderContext) { field: 'customer_phone', }, }, + aggs: { + 'products.discount_amount.max': { + max: { + field: 'products.discount_amount', + }, + }, + }, }, }, }, @@ -249,11 +303,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('adds the aggregation entries', async () => { - for (const [index, agg] of testData.aggregationEntries.entries()) { - await transform.wizard.assertAggregationInputExists(); - await transform.wizard.assertAggregationInputValue([]); - await transform.wizard.addAggregationEntry(index, agg.identifier, agg.label, agg.form); - } + await transform.wizard.addAggregationEntries(testData.aggregationEntries); }); it('displays the advanced pivot editor switch', async () => { diff --git a/x-pack/test/functional/page_objects/index_management_page.ts b/x-pack/test/functional/page_objects/index_management_page.ts index d12186f2e2189..5e5d0e7583450 100644 --- a/x-pack/test/functional/page_objects/index_management_page.ts +++ b/x-pack/test/functional/page_objects/index_management_page.ts @@ -44,7 +44,10 @@ export function IndexManagementPageProvider({ getService }: FtrProviderContext) }; }); }, - async changeTabs(tab: 'indicesTab' | 'templatesTab') { + + async changeTabs( + tab: 'indicesTab' | 'data_streamsTab' | 'templatesTab' | 'component_templatesTab' + ) { await testSubjects.click(tab); }, }; diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 03c8b2867b240..8b61e8c895e30 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -242,14 +242,20 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { await this.assertGroupByEntryExists(index, expectedLabel, expectedIntervalLabel); }, - async assertAggregationInputExists() { - await testSubjects.existOrFail('transformAggregationSelection > comboBoxInput'); + getAggComboBoxInputSelector(parentSelector = ''): string { + return `${parentSelector && `${parentSelector} > `}${ + parentSelector ? 'transformSubAggregationSelection' : 'transformAggregationSelection' + } > comboBoxInput`; }, - async assertAggregationInputValue(expectedIdentifier: string[]) { + async assertAggregationInputExists(parentSelector?: string) { + await testSubjects.existOrFail(this.getAggComboBoxInputSelector(parentSelector)); + }, + + async assertAggregationInputValue(expectedIdentifier: string[], parentSelector?: string) { await retry.tryForTime(2000, async () => { const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( - 'transformAggregationSelection > comboBoxInput' + this.getAggComboBoxInputSelector(parentSelector) ); expect(comboBoxSelectedOptions).to.eql( expectedIdentifier, @@ -258,11 +264,14 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { }); }, - async assertAggregationEntryExists(index: number, expectedLabel: string) { - await testSubjects.existOrFail(`transformAggregationEntry ${index}`); + async assertAggregationEntryExists(index: number, expectedLabel: string, parentSelector = '') { + const aggEntryPanelSelector = `${ + parentSelector && `${parentSelector} > ` + }transformAggregationEntry_${index}`; + await testSubjects.existOrFail(aggEntryPanelSelector); const actualLabel = await testSubjects.getVisibleText( - `transformAggregationEntry ${index} > transformAggregationEntryLabel` + `${aggEntryPanelSelector} > transformAggregationEntryLabel` ); expect(actualLabel).to.eql( expectedLabel, @@ -270,15 +279,31 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { ); }, + async addAggregationEntries(aggregationEntries: any[], parentSelector?: string) { + for (const [index, agg] of aggregationEntries.entries()) { + await this.assertAggregationInputExists(parentSelector); + await this.assertAggregationInputValue([], parentSelector); + await this.addAggregationEntry(index, agg.identifier, agg.label, agg.form, parentSelector); + + if (agg.subAggs) { + await this.addAggregationEntries( + agg.subAggs, + `${parentSelector ? `${parentSelector} > ` : ''}transformAggregationEntry_${index}` + ); + } + } + }, + async addAggregationEntry( index: number, identifier: string, expectedLabel: string, - formData?: Record + formData?: Record, + parentSelector = '' ) { - await comboBox.set('transformAggregationSelection > comboBoxInput', identifier); - await this.assertAggregationInputValue([]); - await this.assertAggregationEntryExists(index, expectedLabel); + await comboBox.set(this.getAggComboBoxInputSelector(parentSelector), identifier); + await this.assertAggregationInputValue([], parentSelector); + await this.assertAggregationEntryExists(index, expectedLabel, parentSelector); if (formData !== undefined) { await this.fillPopoverForm(identifier, expectedLabel, formData); diff --git a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts b/x-pack/test/functional_endpoint/apps/endpoint/endpoint_list.ts similarity index 84% rename from x-pack/test/functional_endpoint/apps/endpoint/host_list.ts rename to x-pack/test/functional_endpoint/apps/endpoint/endpoint_list.ts index 029953f113a9c..c5338e2a35765 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/endpoint_list.ts @@ -13,68 +13,71 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); // FLAKY: https://github.com/elastic/kibana/issues/63621 - describe.skip('host list', function () { + describe.skip('endpoint list', function () { this.tags('ciGroup7'); const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); before(async () => { await esArchiver.load('endpoint/metadata/api_feature'); - await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/hosts'); - await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.endpoint.navigateToEndpointList(); }); it('finds title', async () => { - const title = await testSubjects.getVisibleText('hostListTitle'); - expect(title).to.equal('Hosts'); + const title = await testSubjects.getVisibleText('pageViewHeaderLeftTitle'); + expect(title).to.equal('Endpoints'); }); it('displays table data', async () => { const expectedData = [ [ 'Hostname', + 'Host Status', 'Policy', 'Policy Status', 'Alerts', 'Operating System', 'IP Address', - 'Sensor Version', + 'Version', 'Last Active', ], [ 'cadmann-4.example.com', + 'Error', 'Policy Name', 'Policy Status', '0', 'windows 10.0', '10.192.213.130, 10.70.28.129', - 'version', - 'xxxx', + '6.6.1', + 'Jan 24, 2020 @ 16:06:09.541', ], [ 'thurlow-9.example.com', + 'Error', 'Policy Name', 'Policy Status', '0', 'windows 10.0', '10.46.229.234', - 'version', - 'xxxx', + '6.0.0', + 'Jan 24, 2020 @ 16:06:09.541', ], [ 'rezzani-7.example.com', + 'Error', 'Policy Name', 'Policy Status', '0', 'windows 10.0', '10.101.149.26, 2606:a000:ffc0:39:11ef:37b9:3371:578c', - 'version', - 'xxxx', + '6.8.0', + 'Jan 24, 2020 @ 16:06:09.541', ], ]; const tableData = await pageObjects.endpoint.getEndpointAppTableData('hostListTable'); expect(tableData).to.eql(expectedData); }); - it('no details flyout when host page displayed', async () => { + it('no details flyout when endpoint page displayed', async () => { await testSubjects.missingOrFail('hostDetailsFlyout'); }); @@ -108,22 +111,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await (await testSubjects.findAll('hostnameCellLink'))[1].click(); await sleep(500); // give page time to refresh and verify it did not change const hostDetailTitleNew = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); - expect(hostDetailTitleNew).to.eql(hostDetailTitleInitial); + expect(hostDetailTitleNew).to.equal(hostDetailTitleInitial); }); describe('no data', () => { before(async () => { // clear out the data and reload the page await esArchiver.unload('endpoint/metadata/api_feature'); - await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/hosts'); - await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.endpoint.navigateToEndpointList(); }); after(async () => { // reload the data so the other tests continue to pass await esArchiver.load('endpoint/metadata/api_feature'); }); it('displays no items found when empty', async () => { - // get the host list table data and verify message + // get the endpoint list table data and verify message const [, [noItemsFoundMessage]] = await pageObjects.endpoint.getEndpointAppTableData( 'hostListTable' ); @@ -133,12 +135,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('has a url with a host id', () => { before(async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory( - 'endpoint', - '/hosts', + await pageObjects.endpoint.navigateToEndpointList( 'selected_host=fc0ff548-feba-41b6-8367-65e8790d0eaf' ); - await pageObjects.header.waitUntilLoadingHasFinished(); }); it('shows a flyout', async () => { @@ -168,7 +167,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '', '0', '00000000-0000-0000-0000-000000000000', - 'Successful', + 'Unknown', '10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c', 'rezzani-7.example.com', '6.8.0', diff --git a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts b/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts deleted file mode 100644 index 27fabb515757a..0000000000000 --- a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts +++ /dev/null @@ -1,76 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common']); - const spacesService = getService('spaces'); - const testSubjects = getService('testSubjects'); - const appsMenu = getService('appsMenu'); - - describe('spaces', () => { - describe('space with no features disabled', () => { - before(async () => { - await spacesService.create({ - id: 'custom_space', - name: 'custom_space', - disabledFeatures: [], - }); - }); - - after(async () => { - await spacesService.delete('custom_space'); - }); - - it('shows endpoint navlink', async () => { - await pageObjects.common.navigateToApp('home', { - basePath: '/s/custom_space', - }); - const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.contain('Endpoint'); - }); - - it(`endpoint app shows 'Hello World'`, async () => { - await pageObjects.common.navigateToApp('endpoint', { - basePath: '/s/custom_space', - }); - await testSubjects.existOrFail('welcomeTitle'); - }); - - it(`endpoint hosts shows hosts lists page`, async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/hosts', undefined, { - basePath: '/s/custom_space', - ensureCurrentUrl: false, - shouldLoginIfPrompted: false, - }); - await testSubjects.existOrFail('hostPage'); - }); - }); - - describe('space with endpoint disabled', () => { - before(async () => { - await spacesService.create({ - id: 'custom_space', - name: 'custom_space', - disabledFeatures: ['endpoint'], - }); - }); - - after(async () => { - await spacesService.delete('custom_space'); - }); - - it(`doesn't show endpoint navlink`, async () => { - await pageObjects.common.navigateToApp('home', { - basePath: '/s/custom_space', - }); - const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).not.to.contain('Endpoint'); - }); - }); - }); -} diff --git a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/index.ts b/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/index.ts deleted file mode 100644 index da0919d7c39f3..0000000000000 --- a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/index.ts +++ /dev/null @@ -1,13 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('feature controls', function () { - this.tags('skipFirefox'); - loadTestFile(require.resolve('./endpoint_spaces')); - }); -} diff --git a/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts b/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts deleted file mode 100644 index 48cdd6aec5b1a..0000000000000 --- a/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts +++ /dev/null @@ -1,55 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default ({ getPageObjects, getService }: FtrProviderContext) => { - const pageObjects = getPageObjects(['common', 'endpoint']); - const testSubjects = getService('testSubjects'); - - describe('Header nav', function () { - this.tags('ciGroup7'); - before(async () => { - await pageObjects.common.navigateToApp('endpoint'); - }); - - it('renders the tabs when the app loads', async () => { - const homeTabText = await testSubjects.getVisibleText('homeEndpointTab'); - const hostsTabText = await testSubjects.getVisibleText('hostsEndpointTab'); - const alertsTabText = await testSubjects.getVisibleText('alertsEndpointTab'); - const policiesTabText = await testSubjects.getVisibleText('policiesEndpointTab'); - - expect(homeTabText.trim()).to.be('Home'); - expect(hostsTabText.trim()).to.be('Hosts'); - expect(alertsTabText.trim()).to.be('Alerts'); - expect(policiesTabText.trim()).to.be('Policies'); - }); - - it('renders the hosts page when the Hosts tab is selected', async () => { - await (await testSubjects.find('hostsEndpointTab')).click(); - await testSubjects.existOrFail('hostPage'); - }); - - it('renders the alerts page when the Alerts tab is selected', async () => { - await (await testSubjects.find('alertsEndpointTab')).click(); - await testSubjects.existOrFail('alertListPage'); - }); - - it('renders the policy page when Policy tab is selected', async () => { - await (await testSubjects.find('policiesEndpointTab')).click(); - await testSubjects.existOrFail('policyListPage'); - }); - - it('renders the home page when Home tab is selected after selecting another tab', async () => { - await (await testSubjects.find('hostsEndpointTab')).click(); - await testSubjects.existOrFail('hostPage'); - - await (await testSubjects.find('homeEndpointTab')).click(); - await testSubjects.existOrFail('welcomeTitle'); - }); - }); -}; diff --git a/x-pack/test/functional_endpoint/apps/endpoint/index.ts b/x-pack/test/functional_endpoint/apps/endpoint/index.ts index 296ee45ff181c..199d138d1c450 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/index.ts @@ -9,12 +9,11 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('endpoint', function () { this.tags('ciGroup7'); - loadTestFile(require.resolve('./feature_controls')); - loadTestFile(require.resolve('./landing_page')); - loadTestFile(require.resolve('./header_nav')); - loadTestFile(require.resolve('./host_list')); + loadTestFile(require.resolve('./endpoint_list')); loadTestFile(require.resolve('./policy_list')); - loadTestFile(require.resolve('./alerts')); - loadTestFile(require.resolve('./resolver')); + loadTestFile(require.resolve('./policy_details')); + + // loadTestFile(require.resolve('./alerts')); + // loadTestFile(require.resolve('./resolver')); }); } diff --git a/x-pack/test/functional_endpoint/apps/endpoint/landing_page.ts b/x-pack/test/functional_endpoint/apps/endpoint/landing_page.ts deleted file mode 100644 index f2a55df56421a..0000000000000 --- a/x-pack/test/functional_endpoint/apps/endpoint/landing_page.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default ({ getPageObjects, getService }: FtrProviderContext) => { - const pageObjects = getPageObjects(['common', 'endpoint']); - const testSubjects = getService('testSubjects'); - - describe('Endpoint landing page', function () { - this.tags('ciGroup7'); - before(async () => { - await pageObjects.common.navigateToApp('endpoint'); - }); - - it('Loads the endpoint app', async () => { - const welcomeEndpointMessage = await pageObjects.endpoint.welcomeEndpointTitle(); - expect(welcomeEndpointMessage).to.be('Hello World'); - }); - - it('Does not display a toast indicating that the ingest manager failed to initialize', async () => { - await testSubjects.missingOrFail('euiToastHeader'); - }); - }); -}; diff --git a/x-pack/test/functional_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/functional_endpoint/apps/endpoint/policy_details.ts new file mode 100644 index 0000000000000..25fb477b5a99a --- /dev/null +++ b/x-pack/test/functional_endpoint/apps/endpoint/policy_details.ts @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'endpoint', 'policy', 'endpointPageUtils']); + const testSubjects = getService('testSubjects'); + const policyTestResources = getService('policyTestResources'); + + describe('When on the Endpoint Policy Details Page', function () { + this.tags(['ciGroup7']); + + describe('with an invalid policy id', () => { + it('should display an error', async () => { + await pageObjects.policy.navigateToPolicyDetails('invalid-id'); + await testSubjects.existOrFail('policyDetailsIdNotFoundMessage'); + expect(await testSubjects.getVisibleText('policyDetailsIdNotFoundMessage')).to.equal( + 'Saved object [ingest-datasources/invalid-id] not found' + ); + }); + }); + + describe('with a valid policy id', () => { + let policyInfo: PolicyTestResourceInfo; + + before(async () => { + policyInfo = await policyTestResources.createPolicy(); + await pageObjects.policy.navigateToPolicyDetails(policyInfo.datasource.id); + }); + + after(async () => { + if (policyInfo) { + await policyInfo.cleanup(); + } + }); + + it('should display policy view', async () => { + expect(await testSubjects.getVisibleText('pageViewHeaderLeftTitle')).to.equal( + policyInfo.datasource.name + ); + }); + }); + + describe('and the save button is clicked', () => { + let policyInfo: PolicyTestResourceInfo; + + beforeEach(async () => { + policyInfo = await policyTestResources.createPolicy(); + await pageObjects.policy.navigateToPolicyDetails(policyInfo.datasource.id); + }); + + afterEach(async () => { + if (policyInfo) { + await policyInfo.cleanup(); + } + }); + + it('should display success toast on successful save', async () => { + await pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyWindowsEvent_dns'); + await pageObjects.policy.confirmAndSave(); + + await testSubjects.existOrFail('policyDetailsSuccessMessage'); + expect(await testSubjects.getVisibleText('policyDetailsSuccessMessage')).to.equal( + `Policy ${policyInfo.datasource.name} has been updated.` + ); + }); + it('should persist update on the screen', async () => { + await pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyWindowsEvent_process'); + await pageObjects.policy.confirmAndSave(); + + await testSubjects.existOrFail('policyDetailsSuccessMessage'); + await pageObjects.policy.navigateToPolicyList(); + await pageObjects.policy.navigateToPolicyDetails(policyInfo.datasource.id); + + expect(await (await testSubjects.find('policyWindowsEvent_process')).isSelected()).to.equal( + false + ); + }); + it('should have updated policy data in overall agent configuration', async () => { + // This test ensures that updates made to the Endpoint Policy are carried all the way through + // to the generated Agent Configuration that is dispatch down to the Elastic Agent. + + await Promise.all([ + pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyWindowsEvent_file'), + pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyLinuxEvent_file'), + pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyMacEvent_file'), + ]); + await pageObjects.policy.confirmAndSave(); + await testSubjects.existOrFail('policyDetailsSuccessMessage'); + + const agentFullConfig = await policyTestResources.getFullAgentConfig( + policyInfo.agentConfig.id + ); + + expect(agentFullConfig).to.eql({ + datasources: [ + { + enabled: true, + id: policyInfo.datasource.id, + inputs: [ + { + enabled: true, + policy: { + linux: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + events: { + file: false, + network: true, + process: true, + }, + logging: { + file: 'info', + stdout: 'debug', + }, + }, + mac: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + events: { + file: false, + network: true, + process: true, + }, + logging: { + file: 'info', + stdout: 'debug', + }, + malware: { + mode: 'detect', + }, + }, + windows: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + events: { + dll_and_driver_load: true, + dns: true, + file: false, + network: true, + process: true, + registry: true, + security: true, + }, + logging: { + file: 'info', + stdout: 'debug', + }, + malware: { + mode: 'prevent', + }, + }, + }, + streams: [], + type: 'endpoint', + }, + ], + name: 'Protect East Coast', + namespace: 'default', + package: { + name: 'endpoint', + version: policyInfo.packageInfo.version, + }, + use_output: 'default', + }, + ], + id: policyInfo.agentConfig.id, + outputs: { + default: { + hosts: ['http://localhost:9200'], + type: 'elasticsearch', + }, + }, + revision: 3, + settings: { + monitoring: { + enabled: false, + logs: false, + metrics: false, + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/functional_endpoint/apps/endpoint/policy_list.ts index 11b1b8e718ff7..9f87f884b327e 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/policy_list.ts @@ -8,15 +8,15 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common', 'endpoint']); + const pageObjects = getPageObjects(['common', 'endpoint', 'policy']); const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); + const RELATIVE_DATE_FORMAT = /\d (?:seconds|minutes) ago/i; - // FLAKY: https://github.com/elastic/kibana/issues/66579 - describe.skip('When on the Endpoint Policy List', function () { + describe('When on the Endpoint Policy List', function () { this.tags(['ciGroup7']); before(async () => { - await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/policy'); + await pageObjects.policy.navigateToPolicyList(); }); it('loads the Policy List Page', async () => { @@ -34,10 +34,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const allHeaderCells = await pageObjects.endpoint.tableHeaderVisibleText('policyTable'); expect(allHeaderCells).to.eql([ 'Policy Name', - 'Revision', + 'Created By', + 'Created Date', + 'Last Updated By', + 'Last Updated', 'Version', - 'Description', - 'Agent Configuration', + 'Actions', ]); }); it('should show empty table results message', async () => { @@ -47,13 +49,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(noItemsFoundMessage).to.equal('No items found'); }); - xdescribe('and policies exists', () => { + describe('and policies exists', () => { let policyInfo: PolicyTestResourceInfo; before(async () => { // load/create a policy and then navigate back to the policy view so that the list is refreshed policyInfo = await policyTestResources.createPolicy(); - await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/policy'); + await pageObjects.policy.navigateToPolicyList(); await pageObjects.endpoint.waitForTableToHaveData('policyTable'); }); after(async () => { @@ -64,26 +66,24 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('should show policy on the list', async () => { const [, policyRow] = await pageObjects.endpoint.getEndpointAppTableData('policyTable'); - expect(policyRow).to.eql([ - 'Protect East Coast', - '1', - 'Elastic Endpoint v1.0.0', - 'Protect the worlds data - but in the East Coast', - policyInfo.agentConfig.id, + // Validate row data with the exception of the Date columns - since those are initially + // shown as relative. + expect([policyRow[0], policyRow[1], policyRow[3], policyRow[5], policyRow[6]]).to.eql([ + 'Protect East Coastrev. 1', + 'elastic', + 'elastic', + `${policyInfo.datasource.package?.title} v${policyInfo.datasource.package?.version}`, + '', ]); + [policyRow[2], policyRow[4]].forEach((relativeDate) => { + expect(relativeDate).to.match(RELATIVE_DATE_FORMAT); + }); }); it('should show policy name as link', async () => { const policyNameLink = await testSubjects.find('policyNameLink'); expect(await policyNameLink.getTagName()).to.equal('a'); expect(await policyNameLink.getAttribute('href')).to.match( - new RegExp(`\/endpoint\/policy\/${policyInfo.datasource.id}$`) - ); - }); - it('should show agent configuration as link', async () => { - const agentConfigLink = await testSubjects.find('agentConfigLink'); - expect(await agentConfigLink.getTagName()).to.equal('a'); - expect(await agentConfigLink.getAttribute('href')).to.match( - new RegExp(`\/app\/ingestManager\#\/configs\/${policyInfo.datasource.config_id}$`) + new RegExp(`\/management\/policy\/${policyInfo.datasource.id}$`) ); }); }); diff --git a/x-pack/test/functional_endpoint/page_objects/endpoint_page.ts b/x-pack/test/functional_endpoint/page_objects/endpoint_page.ts index 7f78bd6b804f7..3234169e7265e 100644 --- a/x-pack/test/functional_endpoint/page_objects/endpoint_page.ts +++ b/x-pack/test/functional_endpoint/page_objects/endpoint_page.ts @@ -7,11 +7,22 @@ import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../ftr_provider_context'; -export function EndpointPageProvider({ getService }: FtrProviderContext) { +export function EndpointPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'header']); const retry = getService('retry'); return { + /** + * Navigate to the Endpoints list page + */ + async navigateToEndpointList(searchParams?: string) { + await pageObjects.common.navigateToApp('securitySolution', { + hash: `/management/endpoints${searchParams ? `?${searchParams}` : ''}`, + }); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + /** * Finds the Table with the given `selector` (test subject) and returns * back an array containing the table's header column text @@ -31,10 +42,6 @@ export function EndpointPageProvider({ getService }: FtrProviderContext) { ); }, - async welcomeEndpointTitle() { - return await testSubjects.getVisibleText('welcomeTitle'); - }, - /** * Finds a table and returns the data in a nested array with row 0 is the headers if they exist. * It uses euiTableCellContent to avoid poluting the array data with the euiTableRowCell__mobileHeader data. diff --git a/x-pack/test/functional_endpoint/page_objects/index.ts b/x-pack/test/functional_endpoint/page_objects/index.ts index 8138ce2eeccb3..5b550bea5b55d 100644 --- a/x-pack/test/functional_endpoint/page_objects/index.ts +++ b/x-pack/test/functional_endpoint/page_objects/index.ts @@ -7,9 +7,13 @@ import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects'; import { EndpointPageProvider } from './endpoint_page'; import { EndpointAlertsPageProvider } from './endpoint_alerts_page'; +import { EndpointPolicyPageProvider } from './policy_page'; +import { EndpointPageUtils } from './page_utils'; export const pageObjects = { ...xpackFunctionalPageObjects, endpoint: EndpointPageProvider, + policy: EndpointPolicyPageProvider, + endpointPageUtils: EndpointPageUtils, endpointAlerts: EndpointAlertsPageProvider, }; diff --git a/x-pack/test/functional_endpoint/page_objects/page_utils.ts b/x-pack/test/functional_endpoint/page_objects/page_utils.ts new file mode 100644 index 0000000000000..daf66464f7e1e --- /dev/null +++ b/x-pack/test/functional_endpoint/page_objects/page_utils.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function EndpointPageUtils({ getService }: FtrProviderContext) { + const find = getService('find'); + + return { + /** + * Finds a given EuiCheckbox by test subject and clicks on it + * + * @param euiCheckBoxTestId + */ + async clickOnEuiCheckbox(euiCheckBoxTestId: string) { + // This utility is needed because EuiCheckbox forwards the test subject on to + // the actual `` which is not actually visible/accessible on the page. + // In order to actually cause the state of the checkbox to change, the `