From f69c7496c49f1f117ed732280e9bf2e281a9ba0e Mon Sep 17 00:00:00 2001
From: Aleh Zasypkin
Date: Mon, 12 Feb 2024 17:24:31 +0200
Subject: [PATCH 01/83] chore: bump `socks` transitive dependency from `2.7.1`
to `2.7.3` (#176693)
## Summary
Bump `socks` transitive dependency from `2.7.1` to `2.7.3` to reduce a
number of packages that depend on outdated `ip` package.
---
yarn.lock | 26 ++++++++++++++++++++++----
1 file changed, 22 insertions(+), 4 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 7222c93888236..fbd3e8ca5979a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19274,6 +19274,14 @@ io-ts@^2.0.5:
resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.0.5.tgz#e6e3db9df8b047f9cbd6b69e7d2ad3e6437a0b13"
integrity sha512-pL7uUptryanI5Glv+GUv7xh+aLBjxGEDmLwmEYNSx0yOD3djK0Nw5Bt0N6BAkv9LadOUU7QKpRsLcqnTh3UlLA==
+ip-address@^9.0.5:
+ version "9.0.5"
+ resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
+ integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==
+ dependencies:
+ jsbn "1.1.0"
+ sprintf-js "^1.1.3"
+
ip-regex@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5"
@@ -20763,6 +20771,11 @@ js-yaml@^3.13.1, js-yaml@^3.14.1:
argparse "^1.0.7"
esprima "^4.0.0"
+jsbn@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
+ integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==
+
jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
@@ -28153,11 +28166,11 @@ socks-proxy-agent@^8.0.1, socks-proxy-agent@^8.0.2:
socks "^2.7.1"
socks@^2.7.1:
- version "2.7.1"
- resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.1.tgz#d8e651247178fde79c0663043e07240196857d55"
- integrity sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==
+ version "2.7.3"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.3.tgz#7d8a75d7ce845c0a96f710917174dba0d543a785"
+ integrity sha512-vfuYK48HXCTFD03G/1/zkIls3Ebr2YNa4qU9gHDZdblHLiqhJrJGkY3+0Nx0JpN9qBhJbVObc1CNciT1bIZJxw==
dependencies:
- ip "^2.0.0"
+ ip-address "^9.0.5"
smart-buffer "^4.2.0"
sonic-boom@^1.0.2:
@@ -28443,6 +28456,11 @@ split2@^4.0.0:
resolved "https://registry.yarnpkg.com/split2/-/split2-4.1.0.tgz#101907a24370f85bb782f08adaabe4e281ecf809"
integrity sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==
+sprintf-js@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
+ integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
From 459dff178d8d50e99b45497bc09aa1f1bf72f907 Mon Sep 17 00:00:00 2001
From: Samiul Monir <150824886+Samiul-TheSoccerFan@users.noreply.github.com>
Date: Mon, 12 Feb 2024 10:24:58 -0500
Subject: [PATCH 02/83] Make Indices discoverable in Kibana Global Search
(#175473)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Added a search provider to return only visible indices and land in the
overview page once clicked or selected.
### No indices available
### Matched index shows up
### Shows all matched index
### Checklist
Delete any items that are not applicable to this PR.
- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### Risk Matrix
Delete this section if it is not applicable to this PR.
Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.
When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:
| Risk | Probability | Severity | Mitigation/Notes |
|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../public/assets/source_icons/index.svg | 3 +
.../enterprise_search/server/plugin.ts | 2 +
.../indices_search_result_provider.test.ts | 212 ++++++++++++++++++
.../utils/indices_search_result_provider.ts | 68 ++++++
x-pack/plugins/global_search/common/types.ts | 6 +
.../global_search/public/services/types.ts | 6 +
.../global_search/server/routes/find.ts | 3 +-
.../routes/integration_tests/find.test.ts | 1 +
x-pack/plugins/global_search/server/types.ts | 6 +
.../public/lib/result_to_option.test.ts | 9 +
.../public/lib/result_to_option.tsx | 3 +-
11 files changed, 317 insertions(+), 2 deletions(-)
create mode 100644 x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg
create mode 100644 x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts
create mode 100644 x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts
diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg
new file mode 100644
index 0000000000000..6af8b7e92afe9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/assets/source_icons/index.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts
index 7a764e9ef6fb5..8d493c0a4d59b 100644
--- a/x-pack/plugins/enterprise_search/server/plugin.ts
+++ b/x-pack/plugins/enterprise_search/server/plugin.ts
@@ -80,6 +80,7 @@ import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/t
import { GlobalConfigService } from './services/global_config_service';
import { uiSettings as enterpriseSearchUISettings } from './ui_settings';
+import { getIndicesSearchResultProvider } from './utils/indices_search_result_provider';
import { getSearchResultProvider } from './utils/search_result_provider';
import { ConfigType } from '.';
@@ -343,6 +344,7 @@ export class EnterpriseSearchPlugin implements Plugin {
if (globalSearch) {
globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config));
+ globalSearch.registerResultProvider(getIndicesSearchResultProvider(http.basePath));
}
}
diff --git a/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts
new file mode 100644
index 0000000000000..facae46be72a4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.test.ts
@@ -0,0 +1,212 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { NEVER, lastValueFrom } from 'rxjs';
+
+import { IScopedClusterClient } from '@kbn/core/server';
+
+import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants';
+
+import { getIndicesSearchResultProvider } from './indices_search_result_provider';
+
+describe('Enterprise Search - indices search provider', () => {
+ const basePathMock = {
+ prepend: (input: string) => `/kbn${input}`,
+ } as any;
+
+ const indicesSearchResultProvider = getIndicesSearchResultProvider(basePathMock);
+
+ const regularIndexResponse = {
+ 'search-github-api': {
+ aliases: {},
+ },
+ 'search-msft-sql-index': {
+ aliases: {},
+ },
+ };
+
+ const mockClient = {
+ asCurrentUser: {
+ count: jest.fn().mockReturnValue({ count: 100 }),
+ indices: {
+ get: jest.fn(),
+ stats: jest.fn(),
+ },
+ security: {
+ hasPrivileges: jest.fn(),
+ },
+ },
+ asInternalUser: {},
+ };
+ const client = mockClient as unknown as IScopedClusterClient;
+ mockClient.asCurrentUser.indices.get.mockResolvedValue(regularIndexResponse);
+
+ const githubIndex = {
+ id: 'search-github-api',
+ score: 75,
+ title: 'search-github-api',
+ type: 'Index',
+ url: {
+ path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/search-github-api`,
+ prependBasePath: true,
+ },
+ icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/index.svg',
+ };
+
+ const msftIndex = {
+ id: 'search-msft-sql-index',
+ score: 75,
+ title: 'search-msft-sql-index',
+ type: 'Index',
+ url: {
+ path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/search-msft-sql-index`,
+ prependBasePath: true,
+ },
+ icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/index.svg',
+ };
+
+ beforeEach(() => {});
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('find', () => {
+ it('returns formatted results', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'search-github-api' },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([{ ...githubIndex, score: 100 }]);
+ });
+
+ it('returns all matched results', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'search' },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([
+ { ...githubIndex, score: 90 },
+ { ...msftIndex, score: 90 },
+ ]);
+ });
+
+ it('returns all indices on empty string', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: '' },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toHaveLength(0);
+ });
+
+ it('respect maximum results', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'search' },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 1,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([{ ...githubIndex, score: 90 }]);
+ });
+
+ describe('returns empty results', () => {
+ it('when term does not match with created indices', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'sample' },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([]);
+ });
+
+ it('if client is undefined', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'sample' },
+ {
+ aborted$: NEVER,
+ client: undefined,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([]);
+ });
+
+ it('if tag is specified', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'search', tags: ['tag'] },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([]);
+ });
+
+ it('if unknown type is specified', async () => {
+ const results = await lastValueFrom(
+ indicesSearchResultProvider.find(
+ { term: 'search', types: ['tag'] },
+ {
+ aborted$: NEVER,
+ client,
+ maxResults: 100,
+ preference: '',
+ },
+ {} as any
+ )
+ );
+ expect(results).toEqual([]);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts
new file mode 100644
index 0000000000000..c826be87fcdfc
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/utils/indices_search_result_provider.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { from, takeUntil } from 'rxjs';
+
+import { IBasePath } from '@kbn/core-http-server';
+import {
+ GlobalSearchProviderResult,
+ GlobalSearchResultProvider,
+} from '@kbn/global-search-plugin/server';
+import { i18n } from '@kbn/i18n';
+
+import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants';
+
+import { getIndexData } from '../lib/indices/utils/get_index_data';
+
+export function getIndicesSearchResultProvider(basePath: IBasePath): GlobalSearchResultProvider {
+ return {
+ find: ({ term, types, tags }, { aborted$, client, maxResults }) => {
+ if (!client || !term || tags || (types && !types.includes('indices'))) {
+ return from([[]]);
+ }
+ const fetchIndices = async (): Promise => {
+ const { indexNames } = await getIndexData(client, false, false, term);
+
+ const searchResults: GlobalSearchProviderResult[] = indexNames
+ .map((indexName) => {
+ let score = 0;
+ const searchTerm = (term || '').toLowerCase();
+ const searchName = indexName.toLowerCase();
+ if (!searchTerm) {
+ score = 80;
+ } else if (searchName === searchTerm) {
+ score = 100;
+ } else if (searchName.startsWith(searchTerm)) {
+ score = 90;
+ } else if (searchName.includes(searchTerm)) {
+ score = 75;
+ }
+
+ return {
+ id: indexName,
+ title: indexName,
+ icon: basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/index.svg'),
+ type: i18n.translate('xpack.enterpriseSearch.searchIndexProvider.type.name', {
+ defaultMessage: 'Index',
+ }),
+ url: {
+ path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/${indexName}`,
+ prependBasePath: true,
+ },
+ score,
+ };
+ })
+ .filter(({ score }) => score > 0)
+ .slice(0, maxResults);
+ return searchResults;
+ };
+ return from(fetchIndices()).pipe(takeUntil(aborted$));
+ },
+ getSearchableTypes: () => ['indices'],
+ id: 'enterpriseSearchIndices',
+ };
+}
diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts
index fabae7ea01e8f..676cb421c4a15 100644
--- a/x-pack/plugins/global_search/common/types.ts
+++ b/x-pack/plugins/global_search/common/types.ts
@@ -7,6 +7,7 @@
import { Observable } from 'rxjs';
import { Serializable } from '@kbn/utility-types';
+import { IScopedClusterClient } from '@kbn/core/server';
/**
* Options provided to {@link GlobalSearchResultProvider | a result provider}'s `find` method.
@@ -26,6 +27,11 @@ export interface GlobalSearchProviderFindOptions {
* this can (and should) be used to cancel any pending asynchronous task and complete the result observable from within the provider.
*/
aborted$: Observable;
+ /**
+ * A ES client of type IScopedClusterClient is passed to the `find` call.
+ * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster.
+ */
+ client?: IScopedClusterClient;
/**
* The total maximum number of results (including all batches, not per emission) that should be returned by the provider for a given `find` request.
* Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer.
diff --git a/x-pack/plugins/global_search/public/services/types.ts b/x-pack/plugins/global_search/public/services/types.ts
index 169b1538d13d9..7cc622b82fba8 100644
--- a/x-pack/plugins/global_search/public/services/types.ts
+++ b/x-pack/plugins/global_search/public/services/types.ts
@@ -6,6 +6,7 @@
*/
import { Observable } from 'rxjs';
+import { IScopedClusterClient } from '@kbn/core/server';
/**
* Options for the server-side {@link GlobalSearchPluginStart.find | find API}
@@ -25,4 +26,9 @@ export interface GlobalSearchFindOptions {
* If/when provided and emitting, the result observable will be completed and no further result emission will be performed.
*/
aborted$?: Observable;
+ /**
+ * A ES client of type IScopedClusterClient is passed to the `find` call.
+ * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster.
+ */
+ client?: IScopedClusterClient;
}
diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts
index ec491454f7a88..64aa76ee64a70 100644
--- a/x-pack/plugins/global_search/server/routes/find.ts
+++ b/x-pack/plugins/global_search/server/routes/find.ts
@@ -33,8 +33,9 @@ export const registerInternalFindRoute = (router: GlobalSearchRouter) => {
const { params, options } = req.body;
try {
const globalSearch = await ctx.globalSearch;
+ const { client } = (await ctx.core).elasticsearch;
const allResults = await globalSearch
- .find(params, { ...options, aborted$: req.events.aborted$ })
+ .find(params, { ...options, aborted$: req.events.aborted$, client })
.pipe(
map((batch) => batch.results),
reduce((acc, results) => [...acc, ...results])
diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts
index fc53c8d01ebfa..0551e4338c1d3 100644
--- a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts
+++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts
@@ -76,6 +76,7 @@ describe('POST /internal/global_search/find', () => {
{
preference: 'custom-pref',
aborted$: expect.any(Object),
+ client: expect.any(Object),
}
);
});
diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts
index 10a7bafe850dd..21de10af6a72f 100644
--- a/x-pack/plugins/global_search/server/types.ts
+++ b/x-pack/plugins/global_search/server/types.ts
@@ -13,6 +13,7 @@ import type {
Capabilities,
IRouter,
CustomRequestHandlerContext,
+ IScopedClusterClient,
} from '@kbn/core/server';
import {
GlobalSearchBatchedResults,
@@ -92,6 +93,11 @@ export interface GlobalSearchFindOptions {
* If/when provided and emitting, no further result emission will be performed and the result observable will be completed.
*/
aborted$?: Observable;
+ /**
+ * A ES client of type IScopedClusterClient is passed to the `find` call.
+ * When performing calls to ES, the interested provider can utilize this parameter to identify the specific cluster.
+ */
+ client?: IScopedClusterClient;
}
/**
diff --git a/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts b/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts
index d60453ca37d85..92589202cd16a 100644
--- a/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts
+++ b/x-pack/plugins/global_search_bar/public/lib/result_to_option.test.ts
@@ -52,6 +52,15 @@ describe('resultToOption', () => {
);
});
+ it('uses icon for `index` type', () => {
+ const input = createSearchResult({ type: 'index', icon: 'index-icon' });
+ expect(resultToOption(input, [])).toEqual(
+ expect.objectContaining({
+ icon: { type: 'index-icon' },
+ })
+ );
+ });
+
it('does not use icon for other types', () => {
const input = createSearchResult({ type: 'dashboard', icon: 'dash-icon' });
expect(resultToOption(input, [])).toEqual(
diff --git a/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx b/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx
index 88fa86edbd395..555434a85404f 100644
--- a/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx
+++ b/x-pack/plugins/global_search_bar/public/lib/result_to_option.tsx
@@ -25,7 +25,8 @@ export const resultToOption = (
type === 'application' ||
type === 'integration' ||
type.toLowerCase() === 'enterprise search' ||
- type.toLowerCase() === 'search';
+ type.toLowerCase() === 'search' ||
+ type.toLowerCase() === 'index';
const option: EuiSelectableTemplateSitewideOption = {
key: id,
label: title,
From 4566ef71eca07bf0b6e386e277d6364bfe8cd4fa Mon Sep 17 00:00:00 2001
From: Walter Rafelsberger
Date: Mon, 12 Feb 2024 16:33:17 +0100
Subject: [PATCH 03/83] [ML] AIOps: Use `ml_standard` tokenizer for log rate
analysis. (#176587)
## Summary
Fixes #176387.
The `standard` analyser for log pattern analysis introduced in #172188
might return patterns that mess with the identifying of significant
patterns across time ranges, for example if a pattern matches different
parts of a date or time. This adds an update that allows to set the
analyser for log rate analysis to `ml_standard` but keep `standard` for
log pattern analysis.
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---
.../create_category_request.ts | 5 +-
.../queries/fetch_categories.test.ts | 55 -------------------
.../queries/fetch_categories.ts | 5 +-
.../apps/aiops/log_rate_analysis.ts | 3 +-
4 files changed, 8 insertions(+), 60 deletions(-)
diff --git a/x-pack/plugins/aiops/common/api/log_categorization/create_category_request.ts b/x-pack/plugins/aiops/common/api/log_categorization/create_category_request.ts
index 1c5a4745064b5..a5a3efae6f450 100644
--- a/x-pack/plugins/aiops/common/api/log_categorization/create_category_request.ts
+++ b/x-pack/plugins/aiops/common/api/log_categorization/create_category_request.ts
@@ -31,7 +31,8 @@ export function createCategoryRequest(
queryIn: QueryDslQueryContainer,
wrap: ReturnType['wrap'],
intervalMs?: number,
- additionalFilter?: CategorizationAdditionalFilter
+ additionalFilter?: CategorizationAdditionalFilter,
+ useStandardTokenizer: boolean = true
) {
const query = createCategorizeQuery(queryIn, timeField, timeRange);
const aggs = {
@@ -39,7 +40,7 @@ export function createCategoryRequest(
categorize_text: {
field,
size: CATEGORY_LIMIT,
- categorization_analyzer: categorizationAnalyzer,
+ ...(useStandardTokenizer ? { categorization_analyzer: categorizationAnalyzer } : {}),
},
aggs: {
examples: {
diff --git a/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.test.ts b/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.test.ts
index e6e79108435e2..280ffd9ed907f 100644
--- a/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.test.ts
+++ b/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.test.ts
@@ -85,61 +85,6 @@ describe('getCategoryRequest', () => {
aggs: {
categories: {
categorize_text: {
- categorization_analyzer: {
- char_filter: ['first_line_with_letters'],
- tokenizer: 'standard',
- filter: [
- {
- type: 'stop',
- stopwords: [
- 'Monday',
- 'Tuesday',
- 'Wednesday',
- 'Thursday',
- 'Friday',
- 'Saturday',
- 'Sunday',
- 'Mon',
- 'Tue',
- 'Wed',
- 'Thu',
- 'Fri',
- 'Sat',
- 'Sun',
- 'January',
- 'February',
- 'March',
- 'April',
- 'May',
- 'June',
- 'July',
- 'August',
- 'September',
- 'October',
- 'November',
- 'December',
- 'Jan',
- 'Feb',
- 'Mar',
- 'Apr',
- 'May',
- 'Jun',
- 'Jul',
- 'Aug',
- 'Sep',
- 'Oct',
- 'Nov',
- 'Dec',
- 'GMT',
- 'UTC',
- ],
- },
- {
- type: 'limit',
- max_token_count: '100',
- },
- ],
- },
field: 'the-field-name',
size: 1000,
},
diff --git a/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.ts b/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.ts
index bbb64bc95cd30..20fd551873b1c 100644
--- a/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.ts
+++ b/x-pack/plugins/aiops/server/routes/log_rate_analysis/queries/fetch_categories.ts
@@ -76,7 +76,10 @@ export const getCategoryRequest = (
timeFieldName,
undefined,
query,
- wrap
+ wrap,
+ undefined,
+ undefined,
+ false
);
// In this case we're only interested in the aggregation which
diff --git a/x-pack/test/functional/apps/aiops/log_rate_analysis.ts b/x-pack/test/functional/apps/aiops/log_rate_analysis.ts
index 9799d4418d729..45fef76fa7170 100644
--- a/x-pack/test/functional/apps/aiops/log_rate_analysis.ts
+++ b/x-pack/test/functional/apps/aiops/log_rate_analysis.ts
@@ -36,8 +36,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await ml.jobSourceSelection.selectSourceForLogRateAnalysis(testData.sourceIndexOrSavedSearch);
});
- // FLAKY: https://github.com/elastic/kibana/issues/176387
- it.skip(`${testData.suiteTitle} displays index details`, async () => {
+ it(`${testData.suiteTitle} displays index details`, async () => {
await ml.testExecution.logTestStep(`${testData.suiteTitle} displays the time range step`);
await aiops.logRateAnalysisPage.assertTimeRangeSelectorSectionExists();
From d89097a59d8d9b29f8ef85538174a3cad1ae4788 Mon Sep 17 00:00:00 2001
From: Alexi Doak <109488926+doakalexi@users.noreply.github.com>
Date: Mon, 12 Feb 2024 08:22:29 -0800
Subject: [PATCH 04/83] [ResponseOps] Add telemetry for the es query rule types
(#176451)
Resolves https://github.com/elastic/kibana/issues/176237
## Summary
Adds new telemetry fields to track the ES Query rule search types.
### Checklist
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
### To verify
- Create a couple of ES Query rules with the different search types
- Create a rule that is not an ES Query rule
- Change alerting telemetry task
[schedule](https://github.com/doakalexi/kibana/blob/main/x-pack/plugins/alerting/server/usage/task.ts#L28)
interval 1 min
- Run [Telemetry usage payload
API](https://docs.elastic.dev/telemetry/collection/snapshot-telemetry#telemetry-usage-payload-api)
in your browser console to verify the new telemetry data under
`count_by_type` and `count_active_by_type`
---
.../lib/get_telemetry_from_kibana.test.ts | 42 ++++++++++++
.../usage/lib/get_telemetry_from_kibana.ts | 31 ++++++++-
.../lib/group_rules_by_search_type.test.ts | 33 ++++++++++
.../usage/lib/group_rules_by_search_type.ts | 18 +++++
.../alerting_and_actions_telemetry.ts | 66 ++++++++++++++++---
5 files changed, 178 insertions(+), 12 deletions(-)
create mode 100644 x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.test.ts
create mode 100644 x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.ts
diff --git a/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.test.ts b/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.test.ts
index 58a449d5f7004..f29602458fd50 100644
--- a/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.test.ts
+++ b/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.test.ts
@@ -139,6 +139,24 @@ describe('kibana index telemetry', () => {
},
],
},
+ by_search_type: {
+ doc_count_error_upper_bound: 0,
+ sum_other_doc_count: 0,
+ buckets: [
+ {
+ key: 'esQuery',
+ doc_count: 0,
+ },
+ {
+ key: 'searchSource',
+ doc_count: 1,
+ },
+ {
+ key: 'esqlQuery',
+ doc_count: 3,
+ },
+ ],
+ },
max_throttle_time: { value: 60 },
min_throttle_time: { value: 0 },
avg_throttle_time: { value: 30 },
@@ -174,6 +192,9 @@ describe('kibana index telemetry', () => {
document__test__: 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
logs__alert__document__count: 1,
+ '__es-query_es_query': 0,
+ '__es-query_esql_query': 3,
+ '__es-query_search_source': 1,
},
count_total: 4,
hasErrors: false,
@@ -328,6 +349,24 @@ describe('kibana index telemetry', () => {
},
],
},
+ by_search_type: {
+ doc_count_error_upper_bound: 0,
+ sum_other_doc_count: 0,
+ buckets: [
+ {
+ key: 'esQuery',
+ doc_count: 0,
+ },
+ {
+ key: 'searchSource',
+ doc_count: 1,
+ },
+ {
+ key: 'esqlQuery',
+ doc_count: 3,
+ },
+ ],
+ },
},
});
@@ -345,6 +384,9 @@ describe('kibana index telemetry', () => {
document__test__: 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
logs__alert__document__count: 1,
+ '__es-query_es_query': 0,
+ '__es-query_esql_query': 3,
+ '__es-query_search_source': 1,
},
countNamespaces: 1,
countTotal: 4,
diff --git a/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.ts b/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.ts
index a1055aa075521..ecaf99ffc44a3 100644
--- a/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.ts
+++ b/x-pack/plugins/alerting/server/usage/lib/get_telemetry_from_kibana.ts
@@ -22,6 +22,7 @@ import { groupRulesByStatus } from './group_rules_by_status';
import { AlertingUsage } from '../types';
import { NUM_ALERTING_RULE_TYPES } from '../alerting_usage_collector';
import { parseSimpleRuleTypeBucket } from './parse_simple_rule_type_bucket';
+import { groupRulesBySearchType } from './group_rules_by_search_type';
interface Opts {
esClient: ElasticsearchClient;
@@ -258,6 +259,11 @@ export async function getTotalCountAggregations({
},
},
},
+ by_search_type: {
+ terms: {
+ field: 'alert.params.searchType',
+ },
+ },
sum_rules_with_tags: { sum: { field: 'rule_with_tags' } },
sum_rules_snoozed: { sum: { field: 'rule_snoozed' } },
sum_rules_muted: { sum: { field: 'rule_muted' } },
@@ -285,6 +291,7 @@ export async function getTotalCountAggregations({
by_execution_status: AggregationsTermsAggregateBase;
by_notify_when: AggregationsTermsAggregateBase;
connector_types_by_consumers: AggregationsTermsAggregateBase;
+ by_search_type: AggregationsTermsAggregateBase;
sum_rules_with_tags: AggregationsSingleMetricAggregateBase;
sum_rules_snoozed: AggregationsSingleMetricAggregateBase;
sum_rules_muted: AggregationsSingleMetricAggregateBase;
@@ -306,10 +313,17 @@ export async function getTotalCountAggregations({
aggregations.connector_types_by_consumers.buckets
);
+ const countRulesBySearchType = groupRulesBySearchType(
+ parseSimpleRuleTypeBucket(aggregations.by_search_type.buckets)
+ );
+
return {
hasErrors: false,
count_total: totalRulesCount ?? 0,
- count_by_type: parseSimpleRuleTypeBucket(aggregations.by_rule_type_id.buckets),
+ count_by_type: {
+ ...parseSimpleRuleTypeBucket(aggregations.by_rule_type_id.buckets),
+ ...countRulesBySearchType,
+ },
count_rules_by_execution_status: countRulesByExecutionStatus,
count_rules_with_tags: aggregations.sum_rules_with_tags.value ?? 0,
count_rules_by_notify_when: countRulesByNotifyWhen,
@@ -422,6 +436,11 @@ export async function getTotalCountInUse({
size: NUM_ALERTING_RULE_TYPES,
},
},
+ by_search_type: {
+ terms: {
+ field: 'alert.params.searchType',
+ },
+ },
},
},
};
@@ -434,15 +453,23 @@ export async function getTotalCountInUse({
const aggregations = results.aggregations as {
by_rule_type_id: AggregationsTermsAggregateBase;
namespaces_count: AggregationsCardinalityAggregate;
+ by_search_type: AggregationsTermsAggregateBase;
};
const totalEnabledRulesCount =
typeof results.hits.total === 'number' ? results.hits.total : results.hits.total?.value;
+ const countRulesBySearchType = groupRulesBySearchType(
+ parseSimpleRuleTypeBucket(aggregations.by_search_type.buckets)
+ );
+
return {
hasErrors: false,
countTotal: totalEnabledRulesCount ?? 0,
- countByType: parseSimpleRuleTypeBucket(aggregations.by_rule_type_id.buckets),
+ countByType: {
+ ...parseSimpleRuleTypeBucket(aggregations.by_rule_type_id.buckets),
+ ...countRulesBySearchType,
+ },
countNamespaces: aggregations.namespaces_count.value ?? 0,
};
} catch (err) {
diff --git a/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.test.ts b/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.test.ts
new file mode 100644
index 0000000000000..b82c8b49d1ba0
--- /dev/null
+++ b/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.test.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { groupRulesBySearchType } from './group_rules_by_search_type';
+
+describe('groupRulesBySearchType', () => {
+ test('should correctly group search types', () => {
+ expect(
+ groupRulesBySearchType({
+ esQuery: 1,
+ searchSource: 2,
+ esqlQuery: 3,
+ foo: 5,
+ })
+ ).toEqual({
+ '__es-query_es_query': 1,
+ '__es-query_search_source': 2,
+ '__es-query_esql_query': 3,
+ });
+ });
+
+ test('should fallback to 0 if any of the expected search types are absent', () => {
+ expect(groupRulesBySearchType({ unknown: 100, bar: 300 })).toEqual({
+ '__es-query_es_query': 0,
+ '__es-query_search_source': 0,
+ '__es-query_esql_query': 0,
+ });
+ });
+});
diff --git a/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.ts b/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.ts
new file mode 100644
index 0000000000000..b97ac049c2374
--- /dev/null
+++ b/x-pack/plugins/alerting/server/usage/lib/group_rules_by_search_type.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { AlertingUsage } from '../types';
+
+export function groupRulesBySearchType(
+ rulesBySearchType: Record
+): AlertingUsage['count_by_type'] {
+ return {
+ '__es-query_es_query': rulesBySearchType.esQuery ?? 0,
+ '__es-query_search_source': rulesBySearchType.searchSource ?? 0,
+ '__es-query_esql_query': rulesBySearchType.esqlQuery ?? 0,
+ };
+}
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts
index 0823665f43f64..afe7275e808b1 100644
--- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/alerting_and_actions_telemetry.ts
@@ -28,7 +28,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
describe('test telemetry', () => {
const objectRemover = new ObjectRemover(supertest);
- const alwaysFiringRuleId: { [key: string]: string } = {};
+ const esQueryRuleId: { [key: string]: string } = {};
beforeEach(async () => {
await esTestIndexTool.destroy();
@@ -90,7 +90,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
connectorTypeId: 'test.excluded',
});
- alwaysFiringRuleId[space.id] = await createRule({
+ await createRule({
space: space.id,
ruleOverwrites: {
rule_type_id: 'test.patternFiring',
@@ -158,6 +158,28 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
actions: [],
},
});
+ // ES query rule
+ esQueryRuleId[space.id] = await createRule({
+ space: space.id,
+ ruleOverwrites: {
+ rule_type_id: '.es-query',
+ schedule: { interval: '1h' },
+ throttle: null,
+ params: {
+ size: 100,
+ timeWindowSize: 5,
+ timeWindowUnit: 'm',
+ thresholdComparator: '>',
+ threshold: [0],
+ searchType: 'esqlQuery',
+ esqlQuery: {
+ esql: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0',
+ },
+ timeField: 'date_epoch_millis',
+ },
+ actions: [],
+ },
+ });
}
}
@@ -220,7 +242,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
function verifyAlertingTelemetry(telemetry: any) {
logger.info(`alerting telemetry - ${JSON.stringify(telemetry)}`);
// total number of enabled rules
- expect(telemetry.count_active_total).to.equal(9);
+ expect(telemetry.count_active_total).to.equal(12);
// total number of disabled rules
expect(telemetry.count_disabled_total).to.equal(3);
@@ -230,18 +252,26 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
expect(telemetry.count_by_type.test__patternFiring).to.equal(3);
expect(telemetry.count_by_type.test__multipleSearches).to.equal(3);
expect(telemetry.count_by_type.test__throw).to.equal(3);
+ expect(telemetry.count_by_type['__es-query']).to.equal(3);
+ expect(telemetry.count_by_type['__es-query_es_query']).to.equal(0);
+ expect(telemetry.count_by_type['__es-query_search_source']).to.equal(0);
+ expect(telemetry.count_by_type['__es-query_esql_query']).to.equal(3);
// total number of enabled rules broken down by rule type
expect(telemetry.count_active_by_type.test__patternFiring).to.equal(3);
expect(telemetry.count_active_by_type.test__multipleSearches).to.equal(3);
expect(telemetry.count_active_by_type.test__throw).to.equal(3);
+ expect(telemetry.count_active_by_type['__es-query']).to.equal(3);
+ expect(telemetry.count_active_by_type['__es-query_es_query']).to.equal(0);
+ expect(telemetry.count_active_by_type['__es-query_search_source']).to.equal(0);
+ expect(telemetry.count_active_by_type['__es-query_esql_query']).to.equal(3);
// throttle time stats
expect(telemetry.throttle_time.min).to.equal('0s');
- expect(telemetry.throttle_time.avg).to.equal('0.4s');
+ expect(telemetry.throttle_time.avg).to.equal('0.3333333333333333s');
expect(telemetry.throttle_time.max).to.equal('1s');
expect(telemetry.throttle_time_number_s.min).to.equal(0);
- expect(telemetry.throttle_time_number_s.avg).to.equal(0.4);
+ expect(telemetry.throttle_time_number_s.avg).to.equal(0.3333333333333333);
expect(telemetry.throttle_time_number_s.max).to.equal(1);
// schedule interval stats
@@ -254,7 +284,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
// attached connectors stats
expect(telemetry.connectors_per_alert.min).to.equal(0);
- expect(telemetry.connectors_per_alert.avg).to.equal(1);
+ expect(telemetry.connectors_per_alert.avg).to.equal(0.8);
expect(telemetry.connectors_per_alert.max).to.equal(3);
// number of spaces with rules
@@ -269,6 +299,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
expect(telemetry.count_by_type.test__noop >= 3).to.be(true);
expect(telemetry.count_by_type.test__multipleSearches >= 3).to.be(true);
expect(telemetry.count_by_type.test__throw >= 3).to.be(true);
+ expect(telemetry.count_by_type['__es-query'] >= 3).to.be(true);
// average execution time - just checking for non-zero as we can't set an exact number
expect(telemetry.avg_execution_time_per_day > 0).to.be(true);
@@ -277,6 +308,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
expect(telemetry.avg_execution_time_by_type_per_day.test__patternFiring > 0).to.be(true);
expect(telemetry.avg_execution_time_by_type_per_day.test__multipleSearches > 0).to.be(true);
expect(telemetry.avg_execution_time_by_type_per_day.test__throw > 0).to.be(true);
+ expect(telemetry.avg_execution_time_by_type_per_day['__es-query'] > 0).to.be(true);
// average es search time - just checking for non-zero as we can't set an exact number
expect(telemetry.avg_es_search_duration_per_day > 0).to.be(true);
@@ -360,6 +392,16 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
telemetry.percentile_num_generated_actions_by_type_per_day.p99.test__multipleSearches
).to.equal(0);
+ expect(telemetry.percentile_num_generated_actions_by_type_per_day.p50['__es-query']).to.equal(
+ 0
+ );
+ expect(telemetry.percentile_num_generated_actions_by_type_per_day.p90['__es-query']).to.equal(
+ 0
+ );
+ expect(telemetry.percentile_num_generated_actions_by_type_per_day.p99['__es-query']).to.equal(
+ 0
+ );
+
// percentile calculations for number of alerts
expect(telemetry.percentile_num_alerts_per_day.p50 >= 0).to.be(true);
expect(telemetry.percentile_num_alerts_per_day.p90 >= 0).to.be(true);
@@ -392,17 +434,21 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
0
);
+ expect(telemetry.percentile_num_alerts_by_type_per_day.p50['__es-query']).to.equal(0);
+ expect(telemetry.percentile_num_alerts_by_type_per_day.p90['__es-query']).to.equal(0);
+ expect(telemetry.percentile_num_alerts_by_type_per_day.p99['__es-query']).to.equal(0);
+
// rules grouped by execution status
expect(telemetry.count_rules_by_execution_status.success > 0).to.be(true);
expect(telemetry.count_rules_by_execution_status.error > 0).to.be(true);
expect(telemetry.count_rules_by_execution_status.warning).to.equal(0);
// number of rules that has tags
- expect(telemetry.count_rules_with_tags).to.equal(12);
+ expect(telemetry.count_rules_with_tags).to.equal(15);
// rules grouped by notify when
expect(telemetry.count_rules_by_notify_when.on_action_group_change).to.equal(0);
expect(telemetry.count_rules_by_notify_when.on_active_alert).to.equal(0);
- expect(telemetry.count_rules_by_notify_when.on_throttle_interval).to.equal(12);
+ expect(telemetry.count_rules_by_notify_when.on_throttle_interval).to.equal(15);
// rules snoozed
expect(telemetry.count_rules_snoozed).to.equal(0);
// rules muted
@@ -427,7 +473,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
getService,
spaceId: Spaces[2].id,
type: 'alert',
- id: alwaysFiringRuleId[Spaces[2].id],
+ id: esQueryRuleId[Spaces[2].id],
provider: 'alerting',
actions: new Map([['execute', { gte: 1 }]]),
});
@@ -474,7 +520,7 @@ export default function createAlertingAndActionsTelemetryTests({ getService }: F
expect(taskState).not.to.be(undefined);
alertingTelemetry = JSON.parse(taskState!);
expect(alertingTelemetry.runs > 0).to.be(true);
- expect(alertingTelemetry.count_total).to.equal(12);
+ expect(alertingTelemetry.count_total).to.equal(15);
});
verifyAlertingTelemetry(alertingTelemetry);
From e542f0adff4e1b8fb95829b401642c149e7e7938 Mon Sep 17 00:00:00 2001
From: Adam Demjen
Date: Mon, 12 Feb 2024 11:48:19 -0500
Subject: [PATCH 05/83] [Search] Show source fields on inference pipeline card
(#176623)
## Summary
Display source fields on inference pipeline cards under the Pipelines
tab. For this we needed to add the `sourceFields` optional property to
the `InferencePipeline` type, as well as some parsing code.
Rendering on small screen:
For reference, here's what the existing pipeline selection list looks
like:
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../common/types/pipelines.ts | 1 +
.../inference_pipeline_card.test.tsx | 21 +++-
.../pipelines/inference_pipeline_card.tsx | 5 +
.../ml_inference/pipeline_select_option.tsx | 4 +-
...t_ml_inference_pipeline_processors.test.ts | 100 +++++++++++++++++-
.../get_ml_inference_pipeline_processors.ts | 21 +++-
6 files changed, 141 insertions(+), 11 deletions(-)
diff --git a/x-pack/plugins/enterprise_search/common/types/pipelines.ts b/x-pack/plugins/enterprise_search/common/types/pipelines.ts
index 6ea8e6c46e3f8..068097611030f 100644
--- a/x-pack/plugins/enterprise_search/common/types/pipelines.ts
+++ b/x-pack/plugins/enterprise_search/common/types/pipelines.ts
@@ -16,6 +16,7 @@ export interface InferencePipeline {
pipelineName: string;
pipelineReferences: string[];
types: string[];
+ sourceFields?: string[];
}
export enum TrainedModelState {
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.test.tsx
index ff234eacb77e4..3c3c118c86cd2 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.test.tsx
@@ -25,6 +25,7 @@ export const DEFAULT_VALUES: InferencePipeline = {
pipelineName: 'Sample Processor',
pipelineReferences: [],
types: ['pytorch', 'ner'],
+ sourceFields: ['title', 'body'],
};
const mockValues = { ...DEFAULT_VALUES };
@@ -52,13 +53,13 @@ describe('InferencePipelineCard', () => {
const wrapper = shallow();
expect(wrapper.find(EuiTitle)).toHaveLength(1);
// does not render subtitle
- expect(wrapper.find(EuiText)).toHaveLength(0);
+ expect(wrapper.find(EuiText)).toHaveLength(1);
const title = wrapper.find(EuiTitle).at(0).children();
expect(title.text()).toBe(DEFAULT_VALUES.pipelineName);
});
it('renders model ID as subtitle', () => {
const wrapper = shallow();
- expect(wrapper.find(EuiText)).toHaveLength(1);
+ expect(wrapper.find(EuiText)).toHaveLength(2);
const subtitle = wrapper.find(EuiText).at(0).children();
expect(subtitle.text()).toBe(DEFAULT_VALUES.modelId);
});
@@ -68,7 +69,7 @@ describe('InferencePipelineCard', () => {
modelId: '',
};
const wrapper = shallow();
- expect(wrapper.find(EuiText)).toHaveLength(1);
+ expect(wrapper.find(EuiText)).toHaveLength(2);
const subtitle = wrapper.find(EuiText).at(0).children();
expect(subtitle.text()).toBe(MODEL_REDACTED_VALUE);
});
@@ -86,6 +87,20 @@ describe('InferencePipelineCard', () => {
const wrapper = shallow();
expect(wrapper.find(MLModelTypeBadge)).toHaveLength(0);
});
+ it('renders source fields', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(EuiText)).toHaveLength(2);
+ const sourceFields = wrapper.find(EuiText).at(1).children();
+ expect(sourceFields.text()).toBe('title, body');
+ });
+ it('does not render source fields if there are none', () => {
+ const values = {
+ ...DEFAULT_VALUES,
+ sourceFields: undefined,
+ };
+ const wrapper = shallow();
+ expect(wrapper.find(EuiText)).toHaveLength(1); // Model ID only
+ });
});
describe('TrainedModelHealthPopover', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx
index 22047858c79da..acba51ed497d2 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx
@@ -222,6 +222,11 @@ export const InferencePipelineCard: React.FC = (pipeline) =>
)}
+ {pipeline.sourceFields && pipeline.sourceFields.length > 0 && (
+
+ {pipeline.sourceFields.join(', ')}
+
+ )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx
index f5fec5f54ac76..a458a2d6c2633 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/pipeline_select_option.tsx
@@ -52,7 +52,9 @@ export const PipelineSelectOption: React.FC = ({ pipe
- {modelIdDisplay}
+
+ {modelIdDisplay}
+
{pipeline.modelType.length > 0 && (
diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.test.ts b/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.test.ts
index faaf9cba8d2f6..020d717417820 100644
--- a/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.test.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.test.ts
@@ -48,6 +48,9 @@ const mockGetPipeline2 = {
{
inference: {
inference_config: { regression: {} },
+ field_map: {
+ title: 'text_field',
+ },
model_id: 'trained-model-id-1',
},
},
@@ -59,6 +62,18 @@ const mockGetPipeline2 = {
{
inference: {
inference_config: { regression: {} },
+ field_map: {
+ title: 'text_field',
+ },
+ model_id: 'trained-model-id-2',
+ },
+ },
+ {
+ inference: {
+ inference_config: { regression: {} },
+ field_map: {
+ body: 'text_field',
+ },
model_id: 'trained-model-id-2',
},
},
@@ -251,6 +266,7 @@ const trainedModelDataObject: Record = {
pipelineName: 'ml-inference-pipeline-1',
pipelineReferences: ['my-index@ml-inference'],
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
'trained-model-id-2': {
modelId: 'trained-model-id-2',
@@ -258,6 +274,7 @@ const trainedModelDataObject: Record = {
pipelineName: 'ml-inference-pipeline-2',
pipelineReferences: ['my-index@ml-inference'],
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
'ml-inference-pipeline-3': {
modelId: 'trained-model-id-1',
@@ -265,6 +282,7 @@ const trainedModelDataObject: Record = {
pipelineName: 'ml-inference-pipeline-3',
pipelineReferences: ['my-index@ml-inference'],
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
};
@@ -305,7 +323,7 @@ describe('fetchMlInferencePipelines lib function', () => {
expect(response).toEqual({});
});
- it('should return an empty object when getPipeline throws an error ', async () => {
+ it('should return an empty object when getPipeline throws an error', async () => {
mockClient.ingest.getPipeline.mockImplementation(() => Promise.reject(notFoundError));
const response = await fetchMlInferencePipelines(mockClient as unknown as ElasticsearchClient);
@@ -365,6 +383,7 @@ describe('fetchPipelineProcessorInferenceData lib function', () => {
pipelineReferences: ['my-index@ml-inference', 'other-index@ml-inference'],
trainedModelName: 'trained-model-id-1',
types: [],
+ sourceFields: ['title'],
},
{
modelId: 'trained-model-id-2',
@@ -373,6 +392,7 @@ describe('fetchPipelineProcessorInferenceData lib function', () => {
pipelineReferences: ['my-index@ml-inference'],
trainedModelName: 'trained-model-id-2',
types: [],
+ sourceFields: ['title', 'body'],
},
];
@@ -390,6 +410,64 @@ describe('fetchPipelineProcessorInferenceData lib function', () => {
});
expect(response).toEqual(expected);
});
+
+ it('should return an empty array for a pipeline without an inference processor', async () => {
+ mockClient.ingest.getPipeline.mockImplementation(() =>
+ Promise.resolve({
+ 'ml-inference-pipeline-1': {
+ id: 'ml-inference-pipeline-1',
+ processors: [
+ {
+ set: {
+ field: 'foo-field',
+ value: 'foo',
+ },
+ },
+ ],
+ },
+ })
+ );
+
+ const response = await fetchPipelineProcessorInferenceData(
+ mockClient as unknown as ElasticsearchClient,
+ ['ml-inference-pipeline-1', 'ml-inference-pipeline-2', 'non-ml-inference-pipeline'],
+ {
+ 'ml-inference-pipeline-1': ['my-index@ml-inference', 'other-index@ml-inference'],
+ 'ml-inference-pipeline-2': ['my-index@ml-inference'],
+ }
+ );
+
+ expect(response).toEqual([]);
+ });
+
+ it('should handle inference processors with no field mapping', async () => {
+ mockClient.ingest.getPipeline.mockImplementation(() =>
+ Promise.resolve({
+ 'ml-inference-pipeline-1': {
+ id: 'ml-inference-pipeline-1',
+ processors: [
+ {
+ inference: {
+ inference_config: { regression: {} },
+ model_id: 'trained-model-id-1',
+ },
+ },
+ ],
+ },
+ })
+ );
+
+ const response = await fetchPipelineProcessorInferenceData(
+ mockClient as unknown as ElasticsearchClient,
+ ['ml-inference-pipeline-1'],
+ {
+ 'ml-inference-pipeline-1': ['my-index@ml-inference'],
+ }
+ );
+
+ expect(response).toHaveLength(1);
+ expect(response[0].sourceFields).toEqual([]);
+ });
});
describe('getMlModelConfigsForModelIds lib function', () => {
@@ -426,6 +504,7 @@ describe('getMlModelConfigsForModelIds lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-1',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
'trained-model-id-2': {
modelId: 'trained-model-id-2',
@@ -434,6 +513,7 @@ describe('getMlModelConfigsForModelIds lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-2',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
};
@@ -464,6 +544,7 @@ describe('getMlModelConfigsForModelIds lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-1',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
'trained-model-id-2': {
modelId: 'trained-model-id-2',
@@ -472,6 +553,7 @@ describe('getMlModelConfigsForModelIds lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-2',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
'trained-model-id-3-in-other-space': {
modelId: undefined, // Redacted
@@ -480,6 +562,7 @@ describe('getMlModelConfigsForModelIds lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-3-in-other-space',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
};
@@ -537,6 +620,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-1',
types: [],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-2',
@@ -545,6 +629,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-2',
types: [],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-3',
@@ -553,6 +638,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-3',
types: [],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-4',
@@ -561,6 +647,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-4',
types: [],
+ sourceFields: [],
},
];
@@ -572,6 +659,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-1',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-2',
@@ -580,6 +668,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-2',
types: ['pytorch', 'ner'],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-3',
@@ -589,6 +678,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-3',
types: ['pytorch', 'text_classification'],
+ sourceFields: [],
},
{
modelId: 'trained-model-id-4',
@@ -597,6 +687,7 @@ describe('fetchAndAddTrainedModelData lib function', () => {
pipelineReferences: [],
trainedModelName: 'trained-model-id-4',
types: ['pytorch', 'fill_mask'],
+ sourceFields: [],
},
];
@@ -713,7 +804,12 @@ describe('fetchMlInferencePipelineProcessors lib function', () => {
Promise.resolve(mockTrainedModelsInCurrentSpace)
);
- const expected = [trainedModelDataObject['trained-model-id-1']] as InferencePipeline[];
+ const expected = [
+ {
+ ...trainedModelDataObject['trained-model-id-1'],
+ sourceFields: ['title'],
+ },
+ ] as InferencePipeline[];
const response = await fetchMlInferencePipelineProcessors(
mockClient as unknown as ElasticsearchClient,
diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.ts b/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.ts
index e92396533e913..e6abf4ccd5464 100644
--- a/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/indices/pipelines/ml_inference/pipeline_processors/get_ml_inference_pipeline_processors.ts
@@ -19,6 +19,7 @@ import { getInferencePipelineNameFromIndexName } from '../../../../../utils/ml_i
export type InferencePipelineData = InferencePipeline & {
trainedModelName: string;
+ sourceFields: string[];
};
export const fetchMlInferencePipelines = async (client: ElasticsearchClient) => {
@@ -84,15 +85,22 @@ export const fetchPipelineProcessorInferenceData = async (
return Object.keys(mlInferencePipelineProcessorConfigs).reduce(
(pipelineProcessorData, pipelineProcessorName) => {
- // Get the processors for the current pipeline processor of the ML Inference Processor.
+ // Get the processors for the current pipeline processor of the ML Inference Processor
const subProcessors =
mlInferencePipelineProcessorConfigs[pipelineProcessorName].processors || [];
- // Find the inference processor, which we can assume there will only be one.
- const inferenceProcessor = subProcessors.find((obj) => obj.hasOwnProperty('inference'));
+ // Get the inference processors; there is one per configured field, but they share the same model ID
+ const inferenceProcessors = subProcessors.filter((processor) =>
+ processor.hasOwnProperty('inference')
+ );
+
+ const trainedModelName = inferenceProcessors[0]?.inference?.model_id;
+ if (trainedModelName) {
+ // Extract source fields from field mappings
+ const sourceFields = inferenceProcessors.flatMap((processor) =>
+ Object.keys(processor.inference?.field_map ?? {})
+ );
- const trainedModelName = inferenceProcessor?.inference?.model_id;
- if (trainedModelName)
pipelineProcessorData.push({
modelId: trainedModelName,
modelState: TrainedModelState.NotDeployed,
@@ -100,7 +108,9 @@ export const fetchPipelineProcessorInferenceData = async (
pipelineReferences: pipelineProcessorsMap?.[pipelineProcessorName] ?? [],
trainedModelName,
types: [],
+ sourceFields,
});
+ }
return pipelineProcessorData;
},
@@ -136,6 +146,7 @@ export const getMlModelConfigsForModelIds = async (
pipelineReferences: [],
trainedModelName,
types: getMlModelTypesForModelConfig(trainedModelData),
+ sourceFields: [],
};
}
});
From 3d95981a5984b034f143f1955b5804913c7ac3cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yulia=20=C4=8Cech?=
<6585477+yuliacech@users.noreply.github.com>
Date: Mon, 12 Feb 2024 17:50:24 +0100
Subject: [PATCH 06/83] [Console] Update autocomplete definitions (#176484)
## Summary
This PR introduces changes after running the script to re-generate
autocomplete definitions from the elasticsearch-specification repo.
### Checklist
Delete any items that are not applicable to this PR.
- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### Risk Matrix
Delete this section if it is not applicable to this PR.
Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.
When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:
| Risk | Probability | Severity | Mitigation/Notes |
|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---
.../json/generated/eql.get.json | 2 +-
.../json/generated/eql.get_status.json | 2 +-
.../json/generated/esql.query.json | 23 +++++++++++++++++++
.../json/generated/fleet.delete_secret.json | 15 ++++++++++++
.../json/generated/fleet.get_secret.json | 15 ++++++++++++
.../json/generated/fleet.post_secret.json | 15 ++++++++++++
.../indices.delete_index_template.json | 2 +-
.../generated/indices.delete_template.json | 2 +-
.../indices.exists_index_template.json | 2 +-
.../generated/indices.exists_template.json | 2 +-
.../generated/indices.get_index_template.json | 2 +-
.../json/generated/indices.get_template.json | 2 +-
.../generated/indices.put_index_template.json | 2 +-
.../json/generated/indices.put_template.json | 2 +-
.../indices.simulate_index_template.json | 2 +-
.../generated/indices.simulate_template.json | 2 +-
.../generated/search_application.delete.json | 2 +-
...security.create_cross_cluster_api_key.json | 2 +-
.../json/generated/security.get_api_key.json | 3 ++-
.../json/generated/security.get_settings.json | 15 ++++++++++++
...security.update_cross_cluster_api_key.json | 2 +-
.../generated/security.update_settings.json | 15 ++++++++++++
.../text_structure.find_structure.json | 1 +
.../text_structure.test_grok_pattern.json | 23 +++++++++++++++++++
.../generated/transform.delete_transform.json | 1 +
25 files changed, 140 insertions(+), 16 deletions(-)
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/esql.query.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/fleet.delete_secret.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/fleet.get_secret.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/fleet.post_secret.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/security.get_settings.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/security.update_settings.json
create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.test_grok_pattern.json
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get.json b/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get.json
index 78e7f48069cb8..5afbc9335217d 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get.json
@@ -20,7 +20,7 @@
"patterns": [
"_eql/search/{id}"
],
- "documentation": " https://www.elastic.co/guide/en/elasticsearch/reference/{branch}/get-async-eql-search-api.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/{branch}/get-async-eql-search-api.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get_status.json b/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get_status.json
index 74683e246654a..08854c55283f1 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get_status.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/eql.get_status.json
@@ -12,7 +12,7 @@
"patterns": [
"_eql/search/status/{id}"
],
- "documentation": " https://www.elastic.co/guide/en/elasticsearch/reference/{branch}/get-async-eql-status-api.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/{branch}/get-async-eql-status-api.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/esql.query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/esql.query.json
new file mode 100644
index 0000000000000..452ab7c7b7eb9
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/esql.query.json
@@ -0,0 +1,23 @@
+{
+ "esql.query": {
+ "url_params": {
+ "error_trace": "__flag__",
+ "filter_path": [],
+ "human": "__flag__",
+ "pretty": "__flag__",
+ "format": "",
+ "delimiter": ""
+ },
+ "methods": [
+ "POST"
+ ],
+ "patterns": [
+ "_query"
+ ],
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/{branch}/esql-rest.html",
+ "availability": {
+ "stack": true,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.delete_secret.json b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.delete_secret.json
new file mode 100644
index 0000000000000..30424a461ceef
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.delete_secret.json
@@ -0,0 +1,15 @@
+{
+ "fleet.delete_secret": {
+ "methods": [
+ "DELETE"
+ ],
+ "patterns": [
+ "_fleet/secret/{id}"
+ ],
+ "documentation": null,
+ "availability": {
+ "stack": false,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.get_secret.json b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.get_secret.json
new file mode 100644
index 0000000000000..55c4d2c10c013
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.get_secret.json
@@ -0,0 +1,15 @@
+{
+ "fleet.get_secret": {
+ "methods": [
+ "GET"
+ ],
+ "patterns": [
+ "_fleet/secret/{id}"
+ ],
+ "documentation": null,
+ "availability": {
+ "stack": false,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.post_secret.json b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.post_secret.json
new file mode 100644
index 0000000000000..546428df76bd3
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/fleet.post_secret.json
@@ -0,0 +1,15 @@
+{
+ "fleet.post_secret": {
+ "methods": [
+ "POST"
+ ],
+ "patterns": [
+ "_fleet/secret"
+ ],
+ "documentation": null,
+ "availability": {
+ "stack": false,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json
index 55308d609009d..8144a6adbc8a0 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json
@@ -22,7 +22,7 @@
"patterns": [
"_index_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-delete-template.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_template.json
index 99e379a038b9f..97f2be6b72999 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_template.json
@@ -22,7 +22,7 @@
"patterns": [
"_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-delete-template-v1.html",
"availability": {
"stack": true,
"serverless": false
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json
index d0a06f9a5387d..db69eca5efef5 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json
@@ -17,7 +17,7 @@
"patterns": [
"_index_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/index-templates.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_template.json
index ecfbd25d0b60d..4089c0f8b6a36 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_template.json
@@ -18,7 +18,7 @@
"patterns": [
"_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-template-exists-v1.html",
"availability": {
"stack": true,
"serverless": false
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json
index 9a7eb9a8c69a6..18f1cdf510134 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json
@@ -21,7 +21,7 @@
"_index_template",
"_index_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-get-template.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json
index 9556552da5c9c..d257e18b62af4 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json
@@ -20,7 +20,7 @@
"_template",
"_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-get-template-v1.html",
"availability": {
"stack": true,
"serverless": false
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json
index a78ee9a8078b6..9ad7c1efb4a46 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json
@@ -14,7 +14,7 @@
"patterns": [
"_index_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-put-template.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json
index 79d1b509f3a87..9a9ca116f50c5 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json
@@ -26,7 +26,7 @@
"patterns": [
"_template/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates-v1.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_index_template.json
index 5bdefa03e721e..f3e8070bdb999 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_index_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_index_template.json
@@ -19,7 +19,7 @@
"patterns": [
"_index_template/_simulate_index/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-simulate-index.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_template.json
index de04035e02ad0..d19188cbd4c41 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_template.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.simulate_template.json
@@ -20,7 +20,7 @@
"_index_template/_simulate",
"_index_template/_simulate/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-simulate-template.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_application.delete.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_application.delete.json
index a8c3706ac4b4d..8e127e8d58995 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_application.delete.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_application.delete.json
@@ -12,7 +12,7 @@
"patterns": [
"_application/search_application/{name}"
],
- "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/put-search-application.html",
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/delete-search-application.html",
"availability": {
"stack": true,
"serverless": true
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/security.create_cross_cluster_api_key.json b/src/plugins/console/server/lib/spec_definitions/json/generated/security.create_cross_cluster_api_key.json
index 9319fee560ba4..75a98cd4373f3 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/security.create_cross_cluster_api_key.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/security.create_cross_cluster_api_key.json
@@ -8,7 +8,7 @@
],
"documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-cross-cluster-api-key.html",
"availability": {
- "stack": false,
+ "stack": true,
"serverless": false
}
}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_api_key.json b/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_api_key.json
index 3c9d65672ca1e..eb5aaeccce007 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_api_key.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_api_key.json
@@ -10,7 +10,8 @@
"owner": "__flag__",
"realm_name": "",
"username": "",
- "with_limited_by": "__flag__"
+ "with_limited_by": "__flag__",
+ "active_only": "__flag__"
},
"methods": [
"GET"
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_settings.json
new file mode 100644
index 0000000000000..f91793b92cc8c
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/security.get_settings.json
@@ -0,0 +1,15 @@
+{
+ "security.get_settings": {
+ "methods": [
+ "GET"
+ ],
+ "patterns": [
+ "_security/settings"
+ ],
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-get-settings.html",
+ "availability": {
+ "stack": true,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_cross_cluster_api_key.json b/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_cross_cluster_api_key.json
index f297f664e562a..e0897d85d46d4 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_cross_cluster_api_key.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_cross_cluster_api_key.json
@@ -8,7 +8,7 @@
],
"documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-update-cross-cluster-api-key.html",
"availability": {
- "stack": false,
+ "stack": true,
"serverless": false
}
}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_settings.json
new file mode 100644
index 0000000000000..609adc164cc0f
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/security.update_settings.json
@@ -0,0 +1,15 @@
+{
+ "security.update_settings": {
+ "methods": [
+ "PUT"
+ ],
+ "patterns": [
+ "_security/settings"
+ ],
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-update-settings.html",
+ "availability": {
+ "stack": true,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.find_structure.json b/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.find_structure.json
index 8c896988c3c31..7b0248d640819 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.find_structure.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.find_structure.json
@@ -4,6 +4,7 @@
"charset": "",
"column_names": "",
"delimiter": "",
+ "ecs_compatibility": "",
"explain": "__flag__",
"format": "",
"grok_pattern": "",
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.test_grok_pattern.json b/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.test_grok_pattern.json
new file mode 100644
index 0000000000000..0c0c73c99bf51
--- /dev/null
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/text_structure.test_grok_pattern.json
@@ -0,0 +1,23 @@
+{
+ "text_structure.test_grok_pattern": {
+ "url_params": {
+ "error_trace": "__flag__",
+ "filter_path": [],
+ "human": "__flag__",
+ "pretty": "__flag__",
+ "ecs_compatibility": ""
+ },
+ "methods": [
+ "GET",
+ "POST"
+ ],
+ "patterns": [
+ "_text_structure/test_grok_pattern"
+ ],
+ "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/test-grok-pattern-api.html",
+ "availability": {
+ "stack": true,
+ "serverless": false
+ }
+ }
+}
diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/transform.delete_transform.json b/src/plugins/console/server/lib/spec_definitions/json/generated/transform.delete_transform.json
index 4f03a50181c00..6d1631deb021f 100644
--- a/src/plugins/console/server/lib/spec_definitions/json/generated/transform.delete_transform.json
+++ b/src/plugins/console/server/lib/spec_definitions/json/generated/transform.delete_transform.json
@@ -6,6 +6,7 @@
"human": "__flag__",
"pretty": "__flag__",
"force": "__flag__",
+ "delete_dest_index": "__flag__",
"timeout": [
"30s",
"-1",
From 385ddf8c47bd0b1b8fb24462c523fbfa739059f2 Mon Sep 17 00:00:00 2001
From: Ido Cohen <90558359+CohenIdo@users.noreply.github.com>
Date: Mon, 12 Feb 2024 19:09:15 +0200
Subject: [PATCH 07/83] [Cloud Security][telemetry] posture score based on
enabled rules
---
.../cloud_accounts_stats_collector.ts | 85 +++++++++++++++++--
.../lib/telemetry/collectors/register.ts | 5 +-
.../server/lib/telemetry/collectors/schema.ts | 8 ++
.../server/lib/telemetry/collectors/types.ts | 2 +
.../schema/xpack_plugins.json | 22 +++++
5 files changed, 115 insertions(+), 7 deletions(-)
diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/cloud_accounts_stats_collector.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/cloud_accounts_stats_collector.ts
index fac84a5d8a7a2..5cd7c6dc03766 100644
--- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/cloud_accounts_stats_collector.ts
+++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/cloud_accounts_stats_collector.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
-import type { Logger } from '@kbn/core/server';
+import type { ISavedObjectsRepository, Logger } from '@kbn/core/server';
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { getPackagePolicyIdRuntimeMapping } from '../../../../common/runtime_mappings/get_package_policy_id_mapping';
import { getIdentifierRuntimeMapping } from '../../../../common/runtime_mappings/get_identifier_runtime_mapping';
@@ -23,6 +23,10 @@ import {
LATEST_VULNERABILITIES_INDEX_DEFAULT_NS,
VULN_MGMT_POLICY_TEMPLATE,
} from '../../../../common/constants';
+import {
+ getCspBenchmarkRulesStatesHandler,
+ getMutedRulesFilterQuery,
+} from '../../../routes/benchmark_rules/get_states/v1';
export const getPostureAccountsStatsQuery = (index: string): SearchRequest => ({
index,
@@ -348,23 +352,90 @@ export const getCloudAccountsStats = (
return cloudAccountsStats;
};
+const getAccountStatsBasedOnEnablesRule = async (
+ esClient: ElasticsearchClient,
+ encryptedSoClient: ISavedObjectsRepository,
+ accountQuery: SearchRequest,
+ logger: Logger
+): Promise => {
+ const mutedRulesObject = await getCspBenchmarkRulesStatesHandler(encryptedSoClient);
+ const benchmarksWithMutedRules = [
+ ...new Set(
+ Object.values(mutedRulesObject).map((item) => {
+ if (item.muted === true) return item.benchmark_id;
+ })
+ ),
+ ].filter(Boolean);
+
+ if (benchmarksWithMutedRules.length) {
+ const mutedRulesFilterQuery = await getMutedRulesFilterQuery(encryptedSoClient);
+
+ const enabledRulesQuery = {
+ ...accountQuery,
+ query: {
+ bool: {
+ must_not: mutedRulesFilterQuery,
+ must: {
+ terms: {
+ 'rule.benchmark.id': benchmarksWithMutedRules,
+ },
+ },
+ },
+ },
+ };
+
+ const enabledRulesAccountsStatsResponse = await esClient.search(
+ enabledRulesQuery
+ );
+ const cloudAccountsStatsForEnabledRules = enabledRulesAccountsStatsResponse.aggregations
+ ? getCloudAccountsStats(enabledRulesAccountsStatsResponse.aggregations, logger)
+ : [];
+ return cloudAccountsStatsForEnabledRules;
+ }
+ return [];
+};
+
export const getIndexAccountStats = async (
esClient: ElasticsearchClient,
+ encryptedSoClient: ISavedObjectsRepository,
logger: Logger,
index: string,
getAccountQuery: (index: string) => SearchRequest
-) => {
- const accountsStatsResponse = await esClient.search(
- getAccountQuery(index)
- );
+): Promise => {
+ const accountQuery = getAccountQuery(index);
+
+ const accountsStatsResponse = await esClient.search(accountQuery);
- return accountsStatsResponse.aggregations
+ const cloudAccountsStats = accountsStatsResponse.aggregations
? getCloudAccountsStats(accountsStatsResponse.aggregations, logger)
: [];
+
+ if (index === LATEST_FINDINGS_INDEX_DEFAULT_NS) {
+ const cloudAccountsStatsForEnabledRules = await getAccountStatsBasedOnEnablesRule(
+ esClient,
+ encryptedSoClient,
+ accountQuery,
+ logger
+ );
+
+ cloudAccountsStatsForEnabledRules.forEach((statsEnabledRule) => {
+ const foundStatsIndex = cloudAccountsStats.findIndex(
+ (stats) => stats.account_id === statsEnabledRule.account_id
+ );
+ if (foundStatsIndex !== -1) {
+ // Update the object in cloudAccountsStats based on the object in cloudAccountsStatsForEnabledRules
+ cloudAccountsStats[foundStatsIndex].posture_management_stats_enabled_rules =
+ statsEnabledRule.posture_management_stats;
+ cloudAccountsStats[foundStatsIndex].has_muted_rules = true;
+ }
+ });
+ }
+ return cloudAccountsStats;
};
export const getAllCloudAccountsStats = async (
esClient: ElasticsearchClient,
+ encryptedSoClient: ISavedObjectsRepository,
logger: Logger
): Promise => {
try {
@@ -385,6 +456,7 @@ export const getAllCloudAccountsStats = async (
if (findingIndex.exists) {
postureIndexAccountStats = await getIndexAccountStats(
esClient,
+ encryptedSoClient,
logger,
findingIndex.name,
getPostureAccountsStatsQuery
@@ -394,6 +466,7 @@ export const getAllCloudAccountsStats = async (
if (vulnerabilitiesIndex.exists) {
vulnerabilityIndexAccountStats = await getIndexAccountStats(
esClient,
+ encryptedSoClient,
logger,
vulnerabilitiesIndex.name,
getVulnMgmtAccountsStatsQuery
diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts
index 3d283eed41834..259611e12032e 100644
--- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts
+++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts
@@ -78,7 +78,10 @@ export function registerCspmUsageCollector(
getInstallationStats(esClient, soClient, coreServices, logger)
),
awaitPromiseSafe('Alerts', getAlertsStats(esClient, logger)),
- awaitPromiseSafe('Cloud Accounts', getAllCloudAccountsStats(esClient, logger)),
+ awaitPromiseSafe(
+ 'Cloud Accounts',
+ getAllCloudAccountsStats(esClient, encryptedSoClient, logger)
+ ),
awaitPromiseSafe('Muted Rules', getMutedRulesStats(soClient, encryptedSoClient, logger)),
]);
return {
diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts
index 0b0be13ba3a1f..22be67ca6a7b1 100644
--- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts
+++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts
@@ -185,12 +185,20 @@ export const cspmUsageSchema: MakeSchemaFrom = {
passed_findings_count: { type: 'long' },
failed_findings_count: { type: 'long' },
},
+ posture_management_stats_enabled_rules: {
+ posture_score: { type: 'long' },
+ benchmark_name: { type: 'keyword' },
+ benchmark_version: { type: 'keyword' },
+ passed_findings_count: { type: 'long' },
+ failed_findings_count: { type: 'long' },
+ },
kspm_stats: {
kubernetes_version: { type: 'keyword' },
agents_count: { type: 'short' },
nodes_count: { type: 'short' },
pods_count: { type: 'short' },
},
+ has_muted_rules: { type: 'boolean' },
},
},
muted_rules_stats: {
diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts
index f078b35e25ee6..35308647df386 100644
--- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts
+++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts
@@ -77,9 +77,11 @@ export interface CloudSecurityAccountsStats {
cloud_provider: string | null;
package_policy_id: string | null;
posture_management_stats?: CloudPostureAccountsStats;
+ posture_management_stats_enabled_rules?: CloudPostureAccountsStats;
kspm_stats?: KSPMAccountsStats;
latest_doc_count: number;
latest_doc_updated_timestamp: string;
+ has_muted_rules?: boolean;
}
export interface CloudPostureAccountsStats {
posture_score: number;
diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
index d6840a2267d7b..26b8ab2f1c537 100644
--- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
+++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
@@ -7778,6 +7778,25 @@
}
}
},
+ "posture_management_stats_enabled_rules": {
+ "properties": {
+ "posture_score": {
+ "type": "long"
+ },
+ "benchmark_name": {
+ "type": "keyword"
+ },
+ "benchmark_version": {
+ "type": "keyword"
+ },
+ "passed_findings_count": {
+ "type": "long"
+ },
+ "failed_findings_count": {
+ "type": "long"
+ }
+ }
+ },
"kspm_stats": {
"properties": {
"kubernetes_version": {
@@ -7793,6 +7812,9 @@
"type": "short"
}
}
+ },
+ "has_muted_rules": {
+ "type": "boolean"
}
}
}
From 509248b0c6a4f4c7a7f658eab5e69254bc1bd9b5 Mon Sep 17 00:00:00 2001
From: Davis McPhee
Date: Mon, 12 Feb 2024 13:18:17 -0400
Subject: [PATCH 08/83] [Saved Queries] Improve saved query management
(#170599)
## Summary
This PR introduces a number of changes and improvements to saved query
management:
- Add server side pagination (5 queries per page) and search
functionality to the "Load query" list, which improves UX and
performance by no longer requesting all queries at once.
- Redesign the "Load query" list to improve the UX and a11y, making it
possible for keyboard users to effectively navigate the list and
load/delete queries.
- Add an "Active" badge to the "Load query" list to indicate which list
entry represents the currently loaded query, and hoist the entry to the
top of the first page for better visibility when no search term exists.
- Deprecate the saved query `/_all` endpoint and update it to return
only the first 100 queries instead of loading them all into memory at
once.
- Add a new `titleKeyword` field to the saved query SO, which allows
sorting queries alphabetically by title when displaying them in the
"Load query" list.
- Improve the performance of the "has saved queries" check when Unified
Search is mounted to no longer request actual queries, and instead just
request the count.
- Update the saved query duplicate title check to no longer rely on
fetching all queries at once, and instead asynchronously check for
duplicates by title on save.
- Add server side duplicate title validation to the create and update
saved query endpoints.
- Various small fixes and cleanups throughout saved query management.
https://github.com/elastic/kibana/assets/25592674/43328aea-0f7b-4b7a-a5fb-e33ed822f317
Resolves #172044.
Resolves #176427.
## Testing
To generate saved queries for testing, run the script below and replace
`{KIBANA_REQUEST_COOKIE}` with the cookie header value from an API
request of a Kibana user with an active session:
```shell
for i in {1..100}; do curl 'http://localhost:5601/internal/saved_query/_create' \
-H 'Accept: */*' \
-H 'Accept-Language: en-US,en;q=0.9,az;q=0.8,es;q=0.7' \
-H 'Cache-Control: no-cache' \
-H 'Connection: keep-alive' \
-H 'Content-Type: application/json' \
-H 'Cookie: {KIBANA_REQUEST_COOKIE}' \
-H 'elastic-api-version: 1' \
-H 'kbn-build-number: 9007199254740991' \
-H 'kbn-version: 8.13.0' \
-H 'x-elastic-internal-origin: Kibana' \
--data-raw '{"title":"query '"$(echo $(($i - 1)) | tr '[0-9]' '[a-j]')"'","description":"","query":{"query":"bytes > 500","language":"kuery"},"filters":[]}' \
--compressed; done
```
### Checklist
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli
---
.../current_fields.json | 3 +-
.../current_mappings.json | 3 +
.../check_registered_types.test.ts | 2 +-
src/plugins/data/public/query/mocks.ts | 2 +-
.../saved_query/saved_query_service.test.ts | 27 +-
.../query/saved_query/saved_query_service.ts | 22 +-
.../data/public/query/saved_query/types.ts | 2 +-
.../query/route_handler_context.test.ts | 116 ++-
.../server/query/route_handler_context.ts | 178 +++--
src/plugins/data/server/query/route_types.ts | 2 +-
src/plugins/data/server/query/routes.ts | 69 +-
.../data/server/saved_objects/query.test.ts | 60 ++
.../data/server/saved_objects/query.ts | 38 +-
.../server/saved_objects/schemas/query.ts | 40 +-
src/plugins/data/tsconfig.json | 3 +-
.../add_filter_popover.styles.ts | 20 -
.../query_string_input/add_filter_popover.tsx | 5 +-
.../public/query_string_input/panel_title.tsx | 97 +++
.../query_bar_menu.test.tsx | 1 +
.../query_string_input/query_bar_menu.tsx | 112 +--
.../query_bar_menu_panels.tsx | 144 ++--
.../saved_query_form/save_query_form.tsx | 77 +-
.../saved_query_management_list.scss | 4 -
.../saved_query_management_list.test.tsx | 626 ++++++++++++++---
.../saved_query_management_list.tsx | 662 ++++++++++++------
.../public/search_bar/search_bar.test.tsx | 2 +
.../public/search_bar/search_bar.tsx | 76 +-
src/plugins/unified_search/public/types.ts | 3 +
src/plugins/unified_search/tsconfig.json | 1 +
test/accessibility/apps/discover.ts | 8 +-
.../apis/saved_queries/{index.js => index.ts} | 4 +-
.../apis/saved_queries/saved_queries.js | 154 ----
.../apis/saved_queries/saved_queries.ts | 426 +++++++++++
test/functional/page_objects/discover_page.ts | 10 +-
.../saved_query_management_component.ts | 10 +-
.../lens/public/app_plugin/lens_top_nav.tsx | 9 +-
.../common/lib/kibana/kibana_react.mock.ts | 17 +-
.../public/mocks/test_providers.tsx | 17 +-
.../translations/translations/fr-FR.json | 3 -
.../translations/translations/ja-JP.json | 3 -
.../translations/translations/zh-CN.json | 3 -
.../cypress/tasks/api_calls/saved_queries.ts | 2 +-
42 files changed, 2204 insertions(+), 859 deletions(-)
create mode 100644 src/plugins/data/server/saved_objects/query.test.ts
delete mode 100644 src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts
create mode 100644 src/plugins/unified_search/public/query_string_input/panel_title.tsx
rename test/api_integration/apis/saved_queries/{index.js => index.ts} (77%)
delete mode 100644 test/api_integration/apis/saved_queries/saved_queries.js
create mode 100644 test/api_integration/apis/saved_queries/saved_queries.ts
diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json
index 4d8c775710f09..01a7de459affb 100644
--- a/packages/kbn-check-mappings-update-cli/current_fields.json
+++ b/packages/kbn-check-mappings-update-cli/current_fields.json
@@ -716,7 +716,8 @@
],
"query": [
"description",
- "title"
+ "title",
+ "titleKeyword"
],
"risk-engine-configuration": [
"dataViewId",
diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json
index 758fde639d00f..aaf612ed8ed56 100644
--- a/packages/kbn-check-mappings-update-cli/current_mappings.json
+++ b/packages/kbn-check-mappings-update-cli/current_mappings.json
@@ -2403,6 +2403,9 @@
},
"title": {
"type": "text"
+ },
+ "titleKeyword": {
+ "type": "keyword"
}
}
},
diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
index e99ca235bfad4..5f1c48c0391ff 100644
--- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
+++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts
@@ -130,7 +130,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"osquery-pack-asset": "cd140bc2e4b092e93692b587bf6e38051ef94c75",
"osquery-saved-query": "6095e288750aa3164dfe186c74bc5195c2bf2bd4",
"policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352",
- "query": "21cbbaa09abb679078145ce90087b1e88b7eae95",
+ "query": "501bece68f26fe561286a488eabb1a8ab12f1137",
"risk-engine-configuration": "b105d4a3c6adce40708d729d12e5ef3c8fbd9508",
"rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f",
"sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5",
diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts
index 14de815c0d793..74dd77c904165 100644
--- a/src/plugins/data/public/query/mocks.ts
+++ b/src/plugins/data/public/query/mocks.ts
@@ -38,7 +38,7 @@ const createStartContractMock = () => {
addToQueryLog: jest.fn(),
filterManager: createFilterManagerMock(),
queryString: queryStringManagerMock.createStartContract(),
- savedQueries: { getSavedQuery: jest.fn() } as any,
+ savedQueries: { getSavedQuery: jest.fn(), getSavedQueryCount: jest.fn() } as any,
state$: new Observable(),
getState: jest.fn(),
timefilter: timefilterServiceMock.createStartContract(),
diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts
index 3a223109dbd76..07b341fb3eaa5 100644
--- a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts
+++ b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts
@@ -14,12 +14,12 @@ import { SAVED_QUERY_BASE_URL } from '../../../common/constants';
const http = httpServiceMock.createStartContract();
const {
+ isDuplicateTitle,
deleteSavedQuery,
getSavedQuery,
findSavedQueries,
createQuery,
updateQuery,
- getAllSavedQueries,
getSavedQueryCount,
} = createSavedQueryService(http);
@@ -42,6 +42,18 @@ describe('saved query service', () => {
http.delete.mockReset();
});
+ describe('isDuplicateTitle', function () {
+ it('should post the title and ID', async () => {
+ http.post.mockResolvedValue({ isDuplicate: true });
+ await isDuplicateTitle('foo', 'bar');
+ expect(http.post).toBeCalled();
+ expect(http.post).toHaveBeenCalledWith(`${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, {
+ body: '{"title":"foo","id":"bar"}',
+ version,
+ });
+ });
+ });
+
describe('createQuery', function () {
it('should post the stringified given attributes', async () => {
await createQuery(savedQueryAttributes);
@@ -64,19 +76,6 @@ describe('saved query service', () => {
});
});
- describe('getAllSavedQueries', function () {
- it('should post and extract the saved queries from the response', async () => {
- http.post.mockResolvedValue({
- total: 0,
- savedQueries: [{ attributes: savedQueryAttributes }],
- });
- const result = await getAllSavedQueries();
- expect(http.post).toBeCalled();
- expect(http.post).toHaveBeenCalledWith(`${SAVED_QUERY_BASE_URL}/_all`, { version });
- expect(result).toEqual([{ attributes: savedQueryAttributes }]);
- });
- });
-
describe('findSavedQueries', function () {
it('should post and return the total & saved queries', async () => {
http.post.mockResolvedValue({
diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.ts b/src/plugins/data/public/query/saved_query/saved_query_service.ts
index 09afd75470dd0..e3847b357bdee 100644
--- a/src/plugins/data/public/query/saved_query/saved_query_service.ts
+++ b/src/plugins/data/public/query/saved_query/saved_query_service.ts
@@ -14,6 +14,17 @@ import { SAVED_QUERY_BASE_URL } from '../../../common/constants';
const version = '1';
export const createSavedQueryService = (http: HttpStart) => {
+ const isDuplicateTitle = async (title: string, id?: string) => {
+ const response = await http.post<{ isDuplicate: boolean }>(
+ `${SAVED_QUERY_BASE_URL}/_is_duplicate_title`,
+ {
+ body: JSON.stringify({ title, id }),
+ version,
+ }
+ );
+ return response.isDuplicate;
+ };
+
const createQuery = async (attributes: SavedQueryAttributes, { overwrite = false } = {}) => {
const savedQuery = await http.post(`${SAVED_QUERY_BASE_URL}/_create`, {
body: JSON.stringify(attributes),
@@ -30,15 +41,6 @@ export const createSavedQueryService = (http: HttpStart) => {
return savedQuery;
};
- // we have to tell the saved objects client how many to fetch, otherwise it defaults to fetching 20 per page
- const getAllSavedQueries = async (): Promise => {
- const { savedQueries } = await http.post<{ savedQueries: SavedQuery[] }>(
- `${SAVED_QUERY_BASE_URL}/_all`,
- { version }
- );
- return savedQueries;
- };
-
// findSavedQueries will do a 'match_all' if no search string is passed in
const findSavedQueries = async (
search: string = '',
@@ -69,9 +71,9 @@ export const createSavedQueryService = (http: HttpStart) => {
};
return {
+ isDuplicateTitle,
createQuery,
updateQuery,
- getAllSavedQueries,
findSavedQueries,
getSavedQuery,
deleteSavedQuery,
diff --git a/src/plugins/data/public/query/saved_query/types.ts b/src/plugins/data/public/query/saved_query/types.ts
index 7b6b7408b4369..984ca9e804b01 100644
--- a/src/plugins/data/public/query/saved_query/types.ts
+++ b/src/plugins/data/public/query/saved_query/types.ts
@@ -17,9 +17,9 @@ export type SavedQueryTimeFilter = TimeRange & {
export type { SavedQuery, SavedQueryAttributes };
export interface SavedQueryService {
+ isDuplicateTitle: (title: string, id?: string) => Promise;
createQuery: (attributes: SavedQueryAttributes) => Promise;
updateQuery: (id: string, attributes: SavedQueryAttributes) => Promise;
- getAllSavedQueries: () => Promise;
findSavedQueries: (
searchText?: string,
perPage?: number,
diff --git a/src/plugins/data/server/query/route_handler_context.test.ts b/src/plugins/data/server/query/route_handler_context.test.ts
index 08052944cb283..5976db550f182 100644
--- a/src/plugins/data/server/query/route_handler_context.test.ts
+++ b/src/plugins/data/server/query/route_handler_context.test.ts
@@ -10,7 +10,10 @@ import { coreMock } from '@kbn/core/server/mocks';
import { FilterStateStore, Query } from '@kbn/es-query';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../common';
import type { SavedObject, SavedQueryAttributes } from '../../common';
-import { registerSavedQueryRouteHandlerContext } from './route_handler_context';
+import {
+ InternalSavedQueryAttributes,
+ registerSavedQueryRouteHandlerContext,
+} from './route_handler_context';
import { SavedObjectsFindResponse, SavedObjectsUpdateResponse } from '@kbn/core/server';
const mockContext = {
@@ -31,6 +34,10 @@ const savedQueryAttributes: SavedQueryAttributes = {
},
filters: [],
};
+const internalSavedQueryAttributes: InternalSavedQueryAttributes = {
+ ...savedQueryAttributes,
+ titleKeyword: 'foo',
+};
const savedQueryAttributesBar: SavedQueryAttributes = {
title: 'bar',
description: 'baz',
@@ -90,19 +97,29 @@ describe('saved query route handler context', () => {
describe('create', function () {
it('should create a saved object for the given attributes', async () => {
- const mockResponse: SavedObject = {
+ const mockResponse: SavedObject = {
id: 'foo',
type: 'query',
- attributes: savedQueryAttributes,
+ attributes: internalSavedQueryAttributes,
references: [],
};
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.create.mockResolvedValue(mockResponse);
const response = await context.create(savedQueryAttributes);
- expect(mockSavedObjectsClient.create).toHaveBeenCalledWith('query', savedQueryAttributes, {
- references: [],
- });
+ expect(mockSavedObjectsClient.create).toHaveBeenCalledWith(
+ 'query',
+ { ...internalSavedQueryAttributes, timefilter: null },
+ {
+ references: [],
+ }
+ );
expect(response).toEqual({
id: 'foo',
attributes: savedQueryAttributes,
@@ -117,17 +134,29 @@ describe('saved query route handler context', () => {
query: { match_all: {} },
},
};
- const mockResponse: SavedObject = {
+ const mockResponse: SavedObject = {
id: 'foo',
type: 'query',
- attributes: savedQueryAttributesWithQueryObject,
+ attributes: {
+ ...savedQueryAttributesWithQueryObject,
+ titleKeyword: 'foo',
+ },
references: [],
};
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.create.mockResolvedValue(mockResponse);
- const { attributes } = await context.create(savedQueryAttributesWithQueryObject);
+ const result = await context.create(savedQueryAttributesWithQueryObject);
- expect(attributes).toEqual(savedQueryAttributesWithQueryObject);
+ expect(result).toEqual({
+ id: 'foo',
+ attributes: savedQueryAttributesWithQueryObject,
+ });
});
it('should optionally accept filters and timefilters in object format', async () => {
@@ -136,12 +165,21 @@ describe('saved query route handler context', () => {
filters: savedQueryAttributesWithFilters.filters,
timefilter: savedQueryAttributesWithFilters.timefilter,
};
- const mockResponse: SavedObject = {
+ const mockResponse: SavedObject = {
id: 'foo',
type: 'query',
- attributes: serializedSavedQueryAttributesWithFilters,
+ attributes: {
+ ...serializedSavedQueryAttributesWithFilters,
+ titleKeyword: 'foo',
+ },
references: [],
};
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.create.mockResolvedValue(mockResponse);
await context.create(savedQueryAttributesWithFilters);
@@ -154,6 +192,12 @@ describe('saved query route handler context', () => {
});
it('should throw an error when saved objects client returns error', async () => {
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.create.mockResolvedValue({
error: {
error: '123',
@@ -169,19 +213,25 @@ describe('saved query route handler context', () => {
it('should throw an error if the saved query does not have a title', async () => {
const response = context.create({ ...savedQueryAttributes, title: '' });
expect(response).rejects.toMatchInlineSnapshot(
- `[Error: Cannot create saved query without a title]`
+ `[Error: Cannot create query without a title]`
);
});
});
describe('update', function () {
it('should update a saved object for the given attributes', async () => {
- const mockResponse: SavedObject = {
+ const mockResponse: SavedObject = {
id: 'foo',
type: 'query',
- attributes: savedQueryAttributes,
+ attributes: internalSavedQueryAttributes,
references: [],
};
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.update.mockResolvedValue(mockResponse);
const response = await context.update('foo', savedQueryAttributes);
@@ -189,7 +239,7 @@ describe('saved query route handler context', () => {
expect(mockSavedObjectsClient.update).toHaveBeenCalledWith(
'query',
'foo',
- savedQueryAttributes,
+ { ...internalSavedQueryAttributes, timefilter: null },
{
references: [],
}
@@ -201,6 +251,12 @@ describe('saved query route handler context', () => {
});
it('should throw an error when saved objects client returns error', async () => {
+ mockSavedObjectsClient.find.mockResolvedValue({
+ total: 0,
+ page: 0,
+ per_page: 0,
+ saved_objects: [],
+ });
mockSavedObjectsClient.update.mockResolvedValue({
error: {
error: '123',
@@ -216,7 +272,7 @@ describe('saved query route handler context', () => {
it('should throw an error if the saved query does not have a title', async () => {
const response = context.create({ ...savedQueryAttributes, title: '' });
expect(response).rejects.toMatchInlineSnapshot(
- `[Error: Cannot create saved query without a title]`
+ `[Error: Cannot create query without a title]`
);
});
});
@@ -241,6 +297,13 @@ describe('saved query route handler context', () => {
const response = await context.find();
+ expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({
+ type: 'query',
+ page: 1,
+ perPage: 50,
+ sortField: 'titleKeyword',
+ sortOrder: 'asc',
+ });
expect(response.savedQueries).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]);
});
@@ -271,13 +334,15 @@ describe('saved query route handler context', () => {
};
mockSavedObjectsClient.find.mockResolvedValue(mockResponse);
- const response = await context.find({ search: 'foo' });
+ const response = await context.find({ search: 'Foo < And > Bar' });
expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({
+ type: 'query',
page: 1,
perPage: 50,
- search: 'foo',
- type: 'query',
+ filter: 'query.attributes.title:(*Foo AND \\And AND Bar*)',
+ sortField: 'titleKeyword',
+ sortOrder: 'asc',
});
expect(response.savedQueries).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]);
});
@@ -360,7 +425,8 @@ describe('saved query route handler context', () => {
expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({
page: 1,
perPage: 2,
- search: '',
+ sortField: 'titleKeyword',
+ sortOrder: 'asc',
type: 'query',
});
expect(response.savedQueries).toEqual(
@@ -378,7 +444,6 @@ describe('saved query route handler context', () => {
attributes: {
description: 'baz',
query: { language: 'kuery', query: 'response:200' },
- filters: [],
title: 'bar',
},
id: 'bar',
@@ -529,7 +594,7 @@ describe('saved query route handler context', () => {
});
const response = await context.get('food');
- expect(response.attributes.filters[0].meta.index).toBe('my-new-index');
+ expect(response.attributes.filters?.[0].meta.index).toBe('my-new-index');
});
it('should throw if conflict', async () => {
@@ -568,6 +633,11 @@ describe('saved query route handler context', () => {
const response = await context.count();
+ expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({
+ type: 'query',
+ page: 0,
+ perPage: 0,
+ });
expect(response).toEqual(1);
});
});
diff --git a/src/plugins/data/server/query/route_handler_context.ts b/src/plugins/data/server/query/route_handler_context.ts
index fcca59ece59fd..7971ae5104a41 100644
--- a/src/plugins/data/server/query/route_handler_context.ts
+++ b/src/plugins/data/server/query/route_handler_context.ts
@@ -6,17 +6,51 @@
* Side Public License, v 1.
*/
-import { CustomRequestHandlerContext, RequestHandlerContext, SavedObject } from '@kbn/core/server';
-import { isFilters, isOfQueryType } from '@kbn/es-query';
+import { badRequest, internal, conflict } from '@hapi/boom';
+import type {
+ CustomRequestHandlerContext,
+ RequestHandlerContext,
+ SavedObject,
+} from '@kbn/core/server';
+import { escapeKuery, escapeQuotes, isFilters, isOfQueryType } from '@kbn/es-query';
+import { omit } from 'lodash';
import { isQuery, SavedQueryAttributes } from '../../common';
import { extract, inject } from '../../common/query/filters/persistable_state';
+import type { SavedQueryRestResponse } from './route_types';
+
+export interface InternalSavedQueryAttributes
+ extends Omit {
+ titleKeyword: string;
+ filters?: SavedQueryAttributes['filters'] | null;
+ timefilter?: SavedQueryAttributes['timefilter'] | null;
+}
function injectReferences({
id,
- attributes,
+ attributes: internalAttributes,
namespaces,
references,
-}: Pick, 'id' | 'attributes' | 'namespaces' | 'references'>) {
+}: Pick<
+ SavedObject,
+ 'id' | 'attributes' | 'namespaces' | 'references'
+>) {
+ const attributes: SavedQueryAttributes = omit(
+ internalAttributes,
+ 'titleKeyword',
+ 'filters',
+ 'timefilter'
+ );
+
+ // filters or timefilter can be null if previously removed in an update,
+ // which isn't valid for the client model, so we conditionally add them
+ if (internalAttributes.filters) {
+ attributes.filters = inject(internalAttributes.filters, references);
+ }
+
+ if (internalAttributes.timefilter) {
+ attributes.timefilter = internalAttributes.timefilter;
+ }
+
const { query } = attributes;
if (isOfQueryType(query) && typeof query.query === 'string') {
try {
@@ -26,18 +60,21 @@ function injectReferences({
// Just keep it as a string
}
}
- const filters = inject(attributes.filters ?? [], references);
- return { id, attributes: { ...attributes, filters }, namespaces };
+
+ return { id, attributes, namespaces };
}
function extractReferences({
title,
description,
query,
- filters = [],
+ filters,
timefilter,
}: SavedQueryAttributes) {
- const { state: extractedFilters, references } = extract(filters);
+ const { state: extractedFilters, references } = filters
+ ? extract(filters)
+ : { state: undefined, references: [] };
+
const isOfQueryTypeQuery = isOfQueryType(query);
let queryString = '';
if (isOfQueryTypeQuery) {
@@ -48,15 +85,19 @@ function extractReferences({
}
}
- const attributes: SavedQueryAttributes = {
+ const attributes: InternalSavedQueryAttributes = {
title: title.trim(),
+ titleKeyword: title.trim(),
description: description.trim(),
query: {
...query,
...(queryString && { query: queryString }),
},
- filters: extractedFilters,
- ...(timefilter && { timefilter }),
+ // Pass null instead of undefined for filters and timefilter
+ // to ensure they are removed from the saved object on update
+ // since the saved objects client ignores undefined values
+ filters: extractedFilters ?? null,
+ timefilter: timefilter ?? null,
};
return { attributes, references };
@@ -64,109 +105,134 @@ function extractReferences({
function verifySavedQuery({ title, query, filters = [] }: SavedQueryAttributes) {
if (!isQuery(query)) {
- throw new Error(`Invalid query: ${query}`);
+ throw badRequest(`Invalid query: ${JSON.stringify(query, null, 2)}`);
}
if (!isFilters(filters)) {
- throw new Error(`Invalid filters: ${filters}`);
+ throw badRequest(`Invalid filters: ${JSON.stringify(filters, null, 2)}`);
}
if (!title.trim().length) {
- throw new Error('Cannot create saved query without a title');
+ throw badRequest('Cannot create query without a title');
}
}
export async function registerSavedQueryRouteHandlerContext(context: RequestHandlerContext) {
const soClient = (await context.core).savedObjects.client;
- const createSavedQuery = async (attrs: SavedQueryAttributes) => {
+ const isDuplicateTitle = async ({ title, id }: { title: string; id?: string }) => {
+ const preparedTitle = title.trim();
+ const { saved_objects: savedQueries } = await soClient.find({
+ type: 'query',
+ page: 1,
+ perPage: 1,
+ filter: `query.attributes.titleKeyword:"${escapeQuotes(preparedTitle)}"`,
+ });
+ const existingQuery = savedQueries[0];
+
+ return Boolean(
+ existingQuery &&
+ existingQuery.attributes.titleKeyword === preparedTitle &&
+ (!id || existingQuery.id !== id)
+ );
+ };
+
+ const validateSavedQueryTitle = async (title: string, id?: string) => {
+ if (await isDuplicateTitle({ title, id })) {
+ throw badRequest(`Query with title "${title.trim()}" already exists`);
+ }
+ };
+
+ const createSavedQuery = async (attrs: SavedQueryAttributes): Promise => {
verifySavedQuery(attrs);
- const { attributes, references } = extractReferences(attrs);
+ await validateSavedQueryTitle(attrs.title);
- const savedObject = await soClient.create('query', attributes, {
+ const { attributes, references } = extractReferences(attrs);
+ const savedObject = await soClient.create('query', attributes, {
references,
});
// TODO: Handle properly
- if (savedObject.error) throw new Error(savedObject.error.message);
+ if (savedObject.error) throw internal(savedObject.error.message);
return injectReferences(savedObject);
};
- const updateSavedQuery = async (id: string, attrs: SavedQueryAttributes) => {
+ const updateSavedQuery = async (
+ id: string,
+ attrs: SavedQueryAttributes
+ ): Promise => {
verifySavedQuery(attrs);
- const { attributes, references } = extractReferences(attrs);
+ await validateSavedQueryTitle(attrs.title, id);
- const savedObject = await soClient.update('query', id, attributes, {
- references,
- });
+ const { attributes, references } = extractReferences(attrs);
+ const savedObject = await soClient.update(
+ 'query',
+ id,
+ attributes,
+ {
+ references,
+ }
+ );
// TODO: Handle properly
- if (savedObject.error) throw new Error(savedObject.error.message);
+ if (savedObject.error) throw internal(savedObject.error.message);
return injectReferences({ id, attributes, references });
};
- const getSavedQuery = async (id: string) => {
- const { saved_object: savedObject, outcome } = await soClient.resolve(
- 'query',
- id
- );
+ const getSavedQuery = async (id: string): Promise => {
+ const { saved_object: savedObject, outcome } =
+ await soClient.resolve('query', id);
if (outcome === 'conflict') {
- throw new Error(`Multiple saved queries found with ID: ${id} (legacy URL alias conflict)`);
+ throw conflict(`Multiple saved queries found with ID: ${id} (legacy URL alias conflict)`);
} else if (savedObject.error) {
- throw new Error(savedObject.error.message);
+ throw internal(savedObject.error.message);
}
return injectReferences(savedObject);
};
const getSavedQueriesCount = async () => {
- const { total } = await soClient.find({
+ const { total } = await soClient.find({
type: 'query',
+ page: 0,
+ perPage: 0,
});
return total;
};
- const findSavedQueries = async ({ page = 1, perPage = 50, search = '' } = {}) => {
- const { total, saved_objects: savedObjects } = await soClient.find({
- type: 'query',
- page,
- perPage,
- search,
- });
+ const findSavedQueries = async ({ page = 1, perPage = 50, search = '' } = {}): Promise<{
+ total: number;
+ savedQueries: SavedQueryRestResponse[];
+ }> => {
+ const cleanedSearch = search.replace(/\W/g, ' ').trim();
+ const preparedSearch = escapeKuery(cleanedSearch).split(/\s+/).join(' AND ');
+ const { total, saved_objects: savedObjects } =
+ await soClient.find({
+ type: 'query',
+ page,
+ perPage,
+ filter: preparedSearch.length ? `query.attributes.title:(*${preparedSearch}*)` : undefined,
+ sortField: 'titleKeyword',
+ sortOrder: 'asc',
+ });
const savedQueries = savedObjects.map(injectReferences);
return { total, savedQueries };
};
- const getAllSavedQueries = async () => {
- const finder = soClient.createPointInTimeFinder({
- type: 'query',
- perPage: 100,
- });
-
- const savedObjects: Array> = [];
- for await (const response of finder.find()) {
- savedObjects.push(...(response.saved_objects ?? []));
- }
- await finder.close();
-
- const savedQueries = savedObjects.map(injectReferences);
- return { total: savedQueries.length, savedQueries };
- };
-
const deleteSavedQuery = async (id: string) => {
return await soClient.delete('query', id, { force: true });
};
return {
+ isDuplicateTitle,
create: createSavedQuery,
update: updateSavedQuery,
get: getSavedQuery,
count: getSavedQueriesCount,
find: findSavedQueries,
- getAll: getAllSavedQueries,
delete: deleteSavedQuery,
};
}
diff --git a/src/plugins/data/server/query/route_types.ts b/src/plugins/data/server/query/route_types.ts
index 656d52dad9fa7..535eaeecbeefe 100644
--- a/src/plugins/data/server/query/route_types.ts
+++ b/src/plugins/data/server/query/route_types.ts
@@ -120,7 +120,7 @@ type SavedQueryTimeFilterRestResponse = TimeRangeRestResponse & {
export interface SavedQueryRestResponse {
id: string;
attributes: {
- filters: FilterRestResponse[];
+ filters?: FilterRestResponse[];
title: string;
description: string;
query: QueryRestResponse;
diff --git a/src/plugins/data/server/query/routes.ts b/src/plugins/data/server/query/routes.ts
index 5ac18df37d544..5bed196b6373b 100644
--- a/src/plugins/data/server/query/routes.ts
+++ b/src/plugins/data/server/query/routes.ts
@@ -10,7 +10,6 @@ import { schema } from '@kbn/config-schema';
import { CoreSetup } from '@kbn/core/server';
import { reportServerError } from '@kbn/kibana-utils-plugin/server';
import { SavedQueryRouteHandlerContext } from './route_handler_context';
-import { SavedQueryRestResponse } from './route_types';
import { SAVED_QUERY_BASE_URL } from '../../common/constants';
const SAVED_QUERY_ID_CONFIG = schema.object({
@@ -39,6 +38,37 @@ const version = '1';
export function registerSavedQueryRoutes({ http }: CoreSetup): void {
const router = http.createRouter();
+ router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, access }).addVersion(
+ {
+ version,
+ validate: {
+ request: {
+ body: schema.object({
+ title: schema.string(),
+ id: schema.maybe(schema.string()),
+ }),
+ },
+ response: {
+ 200: {
+ body: schema.object({
+ isDuplicate: schema.boolean(),
+ }),
+ },
+ },
+ },
+ },
+ async (context, request, response) => {
+ try {
+ const savedQuery = await context.savedQuery;
+ const isDuplicate = await savedQuery.isDuplicateTitle(request.body);
+ return response.ok({ body: { isDuplicate } });
+ } catch (e) {
+ const err = e.output?.payload ?? e;
+ return reportServerError(response, err);
+ }
+ }
+ );
+
router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_create`, access }).addVersion(
{
version,
@@ -56,7 +86,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void {
async (context, request, response) => {
try {
const savedQuery = await context.savedQuery;
- const body: SavedQueryRestResponse = await savedQuery.create(request.body);
+ const body = await savedQuery.create(request.body);
return response.ok({ body });
} catch (e) {
const err = e.output?.payload ?? e;
@@ -84,7 +114,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void {
const { id } = request.params;
try {
const savedQuery = await context.savedQuery;
- const body: SavedQueryRestResponse = await savedQuery.update(id, request.body);
+ const body = await savedQuery.update(id, request.body);
return response.ok({ body });
} catch (e) {
const err = e.output?.payload ?? e;
@@ -111,7 +141,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void {
const { id } = request.params;
try {
const savedQuery = await context.savedQuery;
- const body: SavedQueryRestResponse = await savedQuery.get(id);
+ const body = await savedQuery.get(id);
return response.ok({ body });
} catch (e) {
const err = e.output?.payload ?? e;
@@ -168,36 +198,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void {
async (context, request, response) => {
try {
const savedQuery = await context.savedQuery;
- const body: { total: number; savedQueries: SavedQueryRestResponse[] } =
- await savedQuery.find(request.body);
- return response.ok({ body });
- } catch (e) {
- const err = e.output?.payload ?? e;
- return reportServerError(response, err);
- }
- }
- );
-
- router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_all`, access }).addVersion(
- {
- version,
- validate: {
- request: {},
- response: {
- 200: {
- body: schema.object({
- total: schema.number(),
- savedQueries: schema.arrayOf(savedQueryResponseSchema),
- }),
- },
- },
- },
- },
- async (context, request, response) => {
- try {
- const savedQuery = await context.savedQuery;
- const body: { total: number; savedQueries: SavedQueryRestResponse[] } =
- await savedQuery.getAll();
+ const body = await savedQuery.find(request.body);
return response.ok({ body });
} catch (e) {
const err = e.output?.payload ?? e;
diff --git a/src/plugins/data/server/saved_objects/query.test.ts b/src/plugins/data/server/saved_objects/query.test.ts
new file mode 100644
index 0000000000000..0968413c27d83
--- /dev/null
+++ b/src/plugins/data/server/saved_objects/query.test.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import {
+ createModelVersionTestMigrator,
+ type ModelVersionTestMigrator,
+} from '@kbn/core-test-helpers-model-versions';
+import { querySavedObjectType } from './query';
+
+describe('saved query model version transformations', () => {
+ let migrator: ModelVersionTestMigrator;
+
+ beforeEach(() => {
+ migrator = createModelVersionTestMigrator({ type: querySavedObjectType });
+ });
+
+ describe('model version 2', () => {
+ const query = {
+ id: 'some-id',
+ type: 'query',
+ attributes: {
+ title: 'Some Title',
+ description: 'some description',
+ query: { language: 'kuery', query: 'some query' },
+ },
+ references: [],
+ };
+
+ it('should properly backfill the titleKeyword field when converting from v1 to v2', () => {
+ const migrated = migrator.migrate({
+ document: query,
+ fromVersion: 1,
+ toVersion: 2,
+ });
+
+ expect(migrated.attributes).toEqual({
+ ...query.attributes,
+ titleKeyword: query.attributes.title,
+ });
+ });
+
+ it('should properly remove the titleKeyword field when converting from v2 to v1', () => {
+ const migrated = migrator.migrate({
+ document: {
+ ...query,
+ attributes: { ...query.attributes, titleKeyword: query.attributes.title },
+ },
+ fromVersion: 2,
+ toVersion: 1,
+ });
+
+ expect(migrated.attributes).toEqual(query.attributes);
+ });
+ });
+});
diff --git a/src/plugins/data/server/saved_objects/query.ts b/src/plugins/data/server/saved_objects/query.ts
index 59ff2483fbefb..4a96b4e2777aa 100644
--- a/src/plugins/data/server/saved_objects/query.ts
+++ b/src/plugins/data/server/saved_objects/query.ts
@@ -9,7 +9,11 @@
import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import { SavedObjectsType } from '@kbn/core/server';
import { savedQueryMigrations } from './migrations/query';
-import { SCHEMA_QUERY_V8_8_0 } from './schemas/query';
+import {
+ SCHEMA_QUERY_V8_8_0,
+ SCHEMA_QUERY_MODEL_VERSION_1,
+ SCHEMA_QUERY_MODEL_VERSION_2,
+} from './schemas/query';
export const querySavedObjectType: SavedObjectsType = {
name: 'query',
@@ -31,10 +35,42 @@ export const querySavedObjectType: SavedObjectsType = {
};
},
},
+ modelVersions: {
+ 1: {
+ changes: [],
+ schemas: {
+ forwardCompatibility: SCHEMA_QUERY_MODEL_VERSION_1.extends({}, { unknowns: 'ignore' }),
+ create: SCHEMA_QUERY_MODEL_VERSION_1,
+ },
+ },
+ 2: {
+ changes: [
+ {
+ type: 'mappings_addition',
+ addedMappings: {
+ titleKeyword: { type: 'keyword' },
+ },
+ },
+ {
+ type: 'data_backfill',
+ backfillFn: (doc) => {
+ return {
+ attributes: { ...doc.attributes, titleKeyword: doc.attributes.title },
+ };
+ },
+ },
+ ],
+ schemas: {
+ forwardCompatibility: SCHEMA_QUERY_MODEL_VERSION_2.extends({}, { unknowns: 'ignore' }),
+ create: SCHEMA_QUERY_MODEL_VERSION_2,
+ },
+ },
+ },
mappings: {
dynamic: false,
properties: {
title: { type: 'text' },
+ titleKeyword: { type: 'keyword' },
description: { type: 'text' },
},
},
diff --git a/src/plugins/data/server/saved_objects/schemas/query.ts b/src/plugins/data/server/saved_objects/schemas/query.ts
index c460a06b9727a..ae6e50340510f 100644
--- a/src/plugins/data/server/saved_objects/schemas/query.ts
+++ b/src/plugins/data/server/saved_objects/schemas/query.ts
@@ -8,25 +8,37 @@
import { schema } from '@kbn/config-schema';
+const FILTERS_SCHEMA = schema.arrayOf(schema.object({}, { unknowns: 'allow' }));
+
+const TIME_FILTER_SCHEMA = schema.object({
+ from: schema.string(),
+ to: schema.string(),
+ refreshInterval: schema.maybe(
+ schema.object({
+ value: schema.number(),
+ pause: schema.boolean(),
+ })
+ ),
+});
+
// As per `SavedQueryAttributes`
-export const SCHEMA_QUERY_V8_8_0 = schema.object({
+export const SCHEMA_QUERY_BASE = schema.object({
title: schema.string(),
description: schema.string({ defaultValue: '' }),
query: schema.object({
language: schema.string(),
query: schema.oneOf([schema.string(), schema.object({}, { unknowns: 'allow' })]),
}),
- filters: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))),
- timefilter: schema.maybe(
- schema.object({
- from: schema.string(),
- to: schema.string(),
- refreshInterval: schema.maybe(
- schema.object({
- value: schema.number(),
- pause: schema.boolean(),
- })
- ),
- })
- ),
+ filters: schema.maybe(FILTERS_SCHEMA),
+ timefilter: schema.maybe(TIME_FILTER_SCHEMA),
+});
+
+export const SCHEMA_QUERY_V8_8_0 = SCHEMA_QUERY_BASE;
+
+export const SCHEMA_QUERY_MODEL_VERSION_1 = SCHEMA_QUERY_BASE;
+
+export const SCHEMA_QUERY_MODEL_VERSION_2 = SCHEMA_QUERY_BASE.extends({
+ titleKeyword: schema.string(),
+ filters: schema.maybe(schema.nullable(FILTERS_SCHEMA)),
+ timefilter: schema.maybe(schema.nullable(TIME_FILTER_SCHEMA)),
});
diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json
index 53cdd2e1f5d9f..74fc83691ec53 100644
--- a/src/plugins/data/tsconfig.json
+++ b/src/plugins/data/tsconfig.json
@@ -52,7 +52,8 @@
"@kbn/shared-ux-link-redirect-app",
"@kbn/bfetch-error",
"@kbn/es-types",
- "@kbn/code-editor"
+ "@kbn/code-editor",
+ "@kbn/core-test-helpers-model-versions"
],
"exclude": [
"target/**/*",
diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts b/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts
deleted file mode 100644
index 21e3d6b649175..0000000000000
--- a/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-import { euiShadowMedium, UseEuiTheme } from '@elastic/eui';
-import { css } from '@emotion/react';
-
-/** @todo important style should be remove after fixing elastic/eui/issues/6314. */
-export const popoverDragAndDropCss = (euiTheme: UseEuiTheme) =>
- css`
- // Always needed for popover with drag & drop in them
- transform: none !important;
- transition: none !important;
- filter: none !important;
- ${euiShadowMedium(euiTheme)}
- `;
diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx
index 48cf1a0f481e1..7954abed26c85 100644
--- a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx
+++ b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx
@@ -13,13 +13,11 @@ import {
EuiPopover,
EuiButtonIconProps,
EuiToolTip,
- useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Filter } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
import { FilterEditorWrapper } from './filter_editor_wrapper';
-import { popoverDragAndDropCss } from './add_filter_popover.styles';
import {
withCloseFilterEditorConfirmModal,
WithCloseFilterEditorConfirmModalProps,
@@ -57,7 +55,6 @@ const AddFilterPopoverComponent = React.memo(function AddFilterPopover({
onLocalFilterCreate,
suggestionsAbstraction,
}: AddFilterPopoverProps) {
- const euiTheme = useEuiTheme();
const [showAddFilterPopover, setShowAddFilterPopover] = useState(false);
const button = (
@@ -91,11 +88,11 @@ const AddFilterPopoverComponent = React.memo(function AddFilterPopover({
panelPaddingSize="none"
panelProps={{
'data-test-subj': 'addFilterPopover',
- css: popoverDragAndDropCss(euiTheme),
}}
initialFocus=".filterEditor__hiddenItem"
ownFocus
repositionOnScroll
+ hasDragDrop
>
;
+ title: string;
+ append?: ReactNode;
+}) => {
+ const { euiTheme } = useEuiTheme();
+ const titleRef = useRef(null);
+
+ const onTitleClick = useCallback(
+ () => queryBarMenuRef.current?.showPanel(QueryBarMenuPanel.main, 'previous'),
+ [queryBarMenuRef]
+ );
+
+ const onTitleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== keys.ARROW_LEFT) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ queryBarMenuRef.current?.showPreviousPanel();
+ queryBarMenuRef.current?.onUseKeyboardToNavigate();
+ },
+ [queryBarMenuRef]
+ );
+
+ useEffectOnce(() => {
+ const panel = titleRef.current?.closest('.euiContextMenuPanel');
+ const focus = () => titleRef.current?.focus();
+
+ panel?.addEventListener('animationend', focus, { once: true });
+
+ return () => panel?.removeEventListener('animationend', focus);
+ });
+
+ return (
+
+
+
+
+ {title}
+
+
+
+ {append && (
+
+ {append}
+
+ )}
+
+ );
+};
diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx
index bdab607b2030c..85956745dff98 100644
--- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx
+++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx
@@ -119,6 +119,7 @@ describe('Querybar Menu component', () => {
],
}),
},
+ queryBarMenuRef: React.createRef(),
};
});
it('should not render the popover if the openQueryBarMenu prop is false', async () => {
diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx
index 14b11737e8844..ef16d9b3e48d8 100644
--- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx
+++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, RefObject } from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
@@ -15,15 +15,22 @@ import {
useGeneratedHtmlId,
EuiButtonIconProps,
EuiToolTip,
- useEuiTheme,
} from '@elastic/eui';
+import {
+ EuiContextMenuClass,
+ EuiContextMenuPanelId,
+} from '@elastic/eui/src/components/context_menu/context_menu';
import { i18n } from '@kbn/i18n';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/public';
-import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public';
-import { QueryBarMenuPanels, QueryBarMenuPanelsProps } from './query_bar_menu_panels';
+import type { SavedQueryService, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public';
+import { euiThemeVars } from '@kbn/ui-theme';
+import {
+ QueryBarMenuPanel,
+ useQueryBarMenuPanels,
+ QueryBarMenuPanelsProps,
+} from './query_bar_menu_panels';
import { FilterEditorWrapper } from './filter_editor_wrapper';
-import { popoverDragAndDropCss } from './add_filter_popover.styles';
import {
withCloseFilterEditorConfirmModal,
WithCloseFilterEditorConfirmModalProps,
@@ -51,6 +58,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp
disableQueryLanguageSwitcher?: boolean;
dateRangeFrom?: string;
dateRangeTo?: string;
+ timeFilter?: SavedQueryTimeFilter;
savedQueryService: SavedQueryService;
saveAsNewQueryFormComponent?: JSX.Element;
saveFormComponent?: JSX.Element;
@@ -71,6 +79,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp
isDisabled?: boolean;
suggestionsAbstraction?: SuggestionsAbstraction;
renderQueryInputAppend?: () => React.ReactNode;
+ queryBarMenuRef: RefObject;
}
function QueryBarMenuComponent({
@@ -79,6 +88,7 @@ function QueryBarMenuComponent({
disableQueryLanguageSwitcher,
dateRangeFrom,
dateRangeTo,
+ timeFilter,
onQueryChange,
onQueryBarSubmit,
savedQueryService,
@@ -105,13 +115,16 @@ function QueryBarMenuComponent({
onLocalFilterCreate,
onLocalFilterUpdate,
suggestionsAbstraction,
+ queryBarMenuRef,
}: QueryBarMenuProps) {
const [renderedComponent, setRenderedComponent] = useState('menu');
-
- const euiTheme = useEuiTheme();
+ const [currentPanelId, setCurrentPanelId] = useState(
+ QueryBarMenuPanel.main
+ );
useEffect(() => {
if (openQueryBarMenu) {
+ setCurrentPanelId(QueryBarMenuPanel.main);
setRenderedComponent('menu');
}
}, [openQueryBarMenu]);
@@ -141,7 +154,7 @@ function QueryBarMenuComponent({
onClick={onButtonClick}
isDisabled={isDisabled}
{...buttonProps}
- style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
+ css={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
iconType="filter"
aria-label={strings.getFilterSetButtonLabel()}
data-test-subj="showQueryBarMenu"
@@ -149,22 +162,25 @@ function QueryBarMenuComponent({
);
- const panels = QueryBarMenuPanels({
+ const panels = useQueryBarMenuPanels({
filters,
savedQuery,
language,
dateRangeFrom,
dateRangeTo,
+ timeFilter,
query,
showSaveQuery,
showFilterBar,
showQueryInput,
savedQueryService,
+ saveFormComponent,
saveAsNewQueryFormComponent,
manageFilterSetComponent,
hiddenPanelOptions,
nonKqlMode,
disableQueryLanguageSwitcher,
+ queryBarMenuRef,
closePopover: plainClosePopover,
onQueryBarSubmit,
onFiltersUpdated,
@@ -176,21 +192,41 @@ function QueryBarMenuComponent({
const renderComponent = () => {
switch (renderedComponent) {
case 'menu':
- default:
- return (
-
- );
- case 'saveForm':
return (
- {saveFormComponent}]}
- />
- );
- case 'saveAsNewForm':
- return (
- {saveAsNewQueryFormComponent}]}
+ setCurrentPanelId(panelId)}
+ data-test-subj="queryBarMenuPanel"
+ css={[
+ {
+ // Add width to transition properties to smooth
+ // the animation when the panel width changes
+ transitionProperty: 'width, height !important',
+ // Add a white background to panels since panels
+ // of different widths can overlap each other
+ // when transitioning, but the background colour
+ // ensures the incoming panel always overlays
+ // the outgoing panel which improves the effect
+ '.euiContextMenuPanel': {
+ backgroundColor: euiThemeVars.euiColorEmptyShade,
+ },
+ },
+ // Fix the update button underline on hover, and
+ // the button focus outline being cut off
+ currentPanelId === QueryBarMenuPanel.main && {
+ '.euiContextMenuPanel__title': {
+ ':hover': {
+ textDecoration: 'none !important',
+ },
+ '.euiContextMenuItem__text': {
+ overflow: 'visible',
+ },
+ },
+ },
+ ]}
/>
);
case 'addFilter':
@@ -217,23 +253,19 @@ function QueryBarMenuComponent({
};
return (
- <>
-
- >
+
);
}
diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx
index b55377d618b3d..6ca40656467e5 100644
--- a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx
+++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import React, { useState, useRef, useEffect, useCallback } from 'react';
+import React, { useState, useRef, useEffect, RefObject } from 'react';
import { isEqual } from 'lodash';
import {
EuiContextMenuPanelDescriptor,
@@ -14,6 +14,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiButton,
+ EuiContextMenuPanelItemDescriptor,
} from '@elastic/eui';
import {
Filter,
@@ -24,6 +25,8 @@ import {
toggleFilterNegated,
pinFilter,
unpinFilter,
+ compareFilters,
+ COMPARE_ALL_OPTIONS,
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
@@ -33,11 +36,14 @@ import {
KQL_TELEMETRY_ROUTE_LATEST_VERSION,
UI_SETTINGS,
} from '@kbn/data-plugin/common';
-import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public';
+import type { SavedQueryService, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public';
+import { euiThemeVars } from '@kbn/ui-theme';
+import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu';
import type { IUnifiedSearchPluginServices } from '../types';
import { fromUser } from './from_user';
import { QueryLanguageSwitcher } from './language_switcher';
import { FilterPanelOption } from '../types';
+import { PanelTitle } from './panel_title';
const MAP_ITEMS_TO_FILTER_OPTION: Record = {
'filter-sets-pinAllFilters': 'pinFilter',
@@ -81,7 +87,7 @@ export const strings = {
i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', {
defaultMessage: 'Save query',
}),
- getClearllFiltersButtonLabel: () =>
+ getClearAllFiltersButtonLabel: () =>
i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', {
defaultMessage: 'Clear all',
}),
@@ -136,22 +142,34 @@ export const strings = {
}),
};
+export enum QueryBarMenuPanel {
+ main = 'main',
+ applyToAllFilters = 'applyToAllFilters',
+ updateCurrentQuery = 'updateCurrentQuery',
+ saveAsNewQuery = 'saveAsNewQuery',
+ loadQuery = 'loadQuery',
+ selectLanguage = 'selectLanguage',
+}
+
export interface QueryBarMenuPanelsProps {
filters?: Filter[];
savedQuery?: SavedQuery;
language: string;
dateRangeFrom?: string;
dateRangeTo?: string;
+ timeFilter?: SavedQueryTimeFilter;
query?: Query;
showSaveQuery?: boolean;
showQueryInput?: boolean;
showFilterBar?: boolean;
savedQueryService: SavedQueryService;
+ saveFormComponent?: JSX.Element;
saveAsNewQueryFormComponent?: JSX.Element;
manageFilterSetComponent?: JSX.Element;
hiddenPanelOptions?: FilterPanelOption[];
nonKqlMode?: 'lucene' | 'text';
disableQueryLanguageSwitcher?: boolean;
+ queryBarMenuRef: RefObject;
closePopover: () => void;
onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void;
onFiltersUpdated?: (filters: Filter[]) => void;
@@ -160,22 +178,25 @@ export interface QueryBarMenuPanelsProps {
setRenderedComponent: (component: string) => void;
}
-export function QueryBarMenuPanels({
+export function useQueryBarMenuPanels({
filters,
savedQuery,
language,
dateRangeFrom,
dateRangeTo,
+ timeFilter,
query,
showSaveQuery,
showFilterBar,
showQueryInput,
savedQueryService,
+ saveFormComponent,
saveAsNewQueryFormComponent,
manageFilterSetComponent,
hiddenPanelOptions,
nonKqlMode,
disableQueryLanguageSwitcher = false,
+ queryBarMenuRef,
closePopover,
onQueryBarSubmit,
onFiltersUpdated,
@@ -188,10 +209,17 @@ export function QueryBarMenuPanels({
const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName);
const cancelPendingListingRequest = useRef<() => void>(() => {});
- const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]);
+ const [hasSavedQueries, setHasSavedQueries] = useState(false);
const [hasFiltersOrQuery, setHasFiltersOrQuery] = useState(false);
const [savedQueryHasChanged, setSavedQueryHasChanged] = useState(false);
+ useEffect(() => {
+ if (savedQuery) {
+ cancelPendingListingRequest.current();
+ setHasSavedQueries(true);
+ }
+ }, [savedQuery]);
+
useEffect(() => {
const fetchSavedQueries = async () => {
cancelPendingListingRequest.current();
@@ -200,35 +228,39 @@ export function QueryBarMenuPanels({
requestGotCancelled = true;
};
- const { queries: savedQueryItems } = await savedQueryService.findSavedQueries('');
+ const queryCount = await savedQueryService.getSavedQueryCount();
if (requestGotCancelled) return;
- setSavedQueries(savedQueryItems.reverse().slice(0, 5));
+ setHasSavedQueries(queryCount > 0);
};
if (showQueryInput && showFilterBar) {
fetchSavedQueries();
}
- }, [savedQueryService, savedQuery, showQueryInput, showFilterBar]);
+ }, [savedQueryService, showQueryInput, showFilterBar]);
useEffect(() => {
if (savedQuery) {
- let filtersHaveChanged = filters?.length !== savedQuery.attributes?.filters?.length;
- if (filters?.length === savedQuery.attributes?.filters?.length) {
- filtersHaveChanged = Boolean(
- filters?.some(
- (filter, index) =>
- !isEqual(filter.query, savedQuery.attributes?.filters?.[index]?.query)
- )
- );
- }
- if (filtersHaveChanged || !isEqual(query, savedQuery?.attributes.query)) {
+ const filtersHaveChanged = Boolean(
+ savedQuery?.attributes.filters &&
+ !compareFilters(filters ?? [], savedQuery.attributes.filters, COMPARE_ALL_OPTIONS)
+ );
+
+ const timeFilterHasChanged = Boolean(
+ savedQuery?.attributes.timefilter && !isEqual(timeFilter, savedQuery?.attributes.timefilter)
+ );
+
+ if (
+ filtersHaveChanged ||
+ timeFilterHasChanged ||
+ !isEqual(query, savedQuery?.attributes.query)
+ ) {
setSavedQueryHasChanged(true);
} else {
setSavedQueryHasChanged(false);
}
}
- }, [filters, query, savedQuery, savedQuery?.attributes.filters, savedQuery?.attributes.query]);
+ }, [filters, query, savedQuery, timeFilter]);
useEffect(() => {
const hasFilters = Boolean(filters && filters.length > 0);
@@ -244,10 +276,6 @@ export function QueryBarMenuPanels({
};
};
- const handleSave = useCallback(() => {
- setRenderedComponent('saveForm');
- }, [setRenderedComponent]);
-
const onEnableAll = () => {
reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`);
const enabledFilters = filters?.map(enableFilter);
@@ -320,7 +348,7 @@ export function QueryBarMenuPanels({
const luceneLabel = strings.getLuceneLanguageName();
const kqlLabel = strings.getKqlLanguageName();
- const filtersRelatedPanels = [
+ const filtersRelatedPanels: EuiContextMenuPanelItemDescriptor[] = [
{
name: strings.getOptionsAddFilterButtonLabel(),
icon: 'plus',
@@ -331,35 +359,34 @@ export function QueryBarMenuPanels({
{
name: strings.getOptionsApplyAllFiltersButtonLabel(),
icon: 'filter',
- panel: 2,
+ panel: QueryBarMenuPanel.applyToAllFilters,
disabled: !Boolean(filters && filters.length > 0),
'data-test-subj': 'filter-sets-applyToAllFilters',
},
];
- const queryAndFiltersRelatedPanels = [
+ const queryAndFiltersRelatedPanels: EuiContextMenuPanelItemDescriptor[] = [
{
name: savedQuery
? strings.getLoadOtherFilterSetLabel()
: strings.getLoadCurrentFilterSetLabel(),
- panel: 4,
- width: 350,
+ panel: QueryBarMenuPanel.loadQuery,
icon: 'filter',
'data-test-subj': 'saved-query-management-load-button',
- disabled: !savedQueries.length,
+ disabled: !hasSavedQueries,
},
{
name: savedQuery ? strings.getSaveAsNewFilterSetLabel() : strings.getSaveFilterSetLabel(),
icon: 'save',
disabled:
!Boolean(showSaveQuery) || !hasFiltersOrQuery || (savedQuery && !savedQueryHasChanged),
- panel: 1,
+ panel: QueryBarMenuPanel.saveAsNewQuery,
'data-test-subj': 'saved-query-management-save-button',
},
{ isSeparator: true },
];
- const items = [];
+ const items: EuiContextMenuPanelItemDescriptor[] = [];
// apply to all actions are only shown when there are filters
if (showFilterBar) {
items.push(...filtersRelatedPanels);
@@ -368,7 +395,7 @@ export function QueryBarMenuPanels({
if (showFilterBar || showQueryInput) {
items.push(
{
- name: strings.getClearllFiltersButtonLabel(),
+ name: strings.getClearAllFiltersButtonLabel(),
disabled: !hasFiltersOrQuery && !Boolean(savedQuery),
icon: 'cross',
'data-test-subj': 'filter-sets-removeAllFilters',
@@ -394,14 +421,14 @@ export function QueryBarMenuPanels({
if (showQueryInput && !disableQueryLanguageSwitcher) {
items.push({
name: `Language: ${language === 'kuery' ? kqlLabel : luceneLabel}`,
- panel: 3,
+ panel: QueryBarMenuPanel.selectLanguage,
'data-test-subj': 'switchQueryLanguageButton',
});
}
- let panels = [
+ let panels: EuiContextMenuPanelDescriptor[] = [
{
- id: 0,
+ id: QueryBarMenuPanel.main,
title: savedQuery?.attributes.title ? (
<>
@@ -419,7 +446,12 @@ export function QueryBarMenuPanels({
{
+ queryBarMenuRef.current?.showPanel(
+ QueryBarMenuPanel.updateCurrentQuery,
+ 'next'
+ );
+ }}
aria-label={strings.getSavedQueryPopoverSaveChangesButtonAriaLabel(
savedQuery?.attributes.title
)}
@@ -435,13 +467,7 @@ export function QueryBarMenuPanels({
items,
},
{
- id: 1,
- title: strings.getSaveCurrentFilterSetLabel(),
- disabled: !Boolean(showSaveQuery),
- content: {saveAsNewQueryFormComponent}
,
- },
- {
- id: 2,
+ id: QueryBarMenuPanel.applyToAllFilters,
initialFocusedItemIndex: 1,
title: strings.getApplyAllFiltersButtonLabel(),
items: [
@@ -493,7 +519,29 @@ export function QueryBarMenuPanels({
],
},
{
- id: 3,
+ id: QueryBarMenuPanel.updateCurrentQuery,
+ content: (
+ <>
+
+ {saveFormComponent}
+ >
+ ),
+ },
+ {
+ id: QueryBarMenuPanel.saveAsNewQuery,
+ title: strings.getSaveCurrentFilterSetLabel(),
+ content: {saveAsNewQueryFormComponent}
,
+ },
+ {
+ id: QueryBarMenuPanel.loadQuery,
+ width: 400,
+ content: {manageFilterSetComponent}
,
+ },
+ {
+ id: QueryBarMenuPanel.selectLanguage,
title: strings.getFilterLanguageLabel(),
content: (
),
},
- {
- id: 4,
- title: strings.getLoadCurrentFilterSetLabel(),
- width: 400,
- content: {manageFilterSetComponent}
,
- },
- ] as EuiContextMenuPanelDescriptor[];
+ ];
if (hiddenPanelOptions && hiddenPanelOptions.length > 0) {
panels = panels.map((panel) => ({
diff --git a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx
index 6f70944bae972..e9d7a548fadaa 100644
--- a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx
+++ b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx
@@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
import { EuiButton, EuiForm, EuiFormRow, EuiFieldText, EuiSwitch } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { sortBy, isEqual } from 'lodash';
+import { isEqual } from 'lodash';
import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public';
interface Props {
@@ -38,22 +38,22 @@ export function SaveQueryForm({
showTimeFilterOption = true,
}: Props) {
const [title, setTitle] = useState(savedQuery?.attributes.title ?? '');
- const [savedQueries, setSavedQueries] = useState([]);
const [shouldIncludeFilters, setShouldIncludeFilters] = useState(
- Boolean(savedQuery?.attributes.filters ?? true)
+ Boolean(savedQuery ? savedQuery.attributes.filters : true)
);
// Defaults to false because saved queries are meant to be as portable as possible and loading
// a saved query with a time filter will override whatever the current value of the global timepicker
// is. We expect this option to be used rarely and only when the user knows they want this behavior.
const [shouldIncludeTimefilter, setIncludeTimefilter] = useState(
- Boolean(savedQuery?.attributes.timefilter ?? false)
+ Boolean(savedQuery ? savedQuery.attributes.timefilter : false)
);
const [formErrors, setFormErrors] = useState([]);
+ const [saveIsDisabled, setSaveIsDisabled] = useState(false);
const titleConflictErrorText = i18n.translate(
'unifiedSearch.search.searchBar.savedQueryForm.titleConflictText',
{
- defaultMessage: 'Name conflicts with an existing query',
+ defaultMessage: 'Name conflicts with an existing query.',
}
);
@@ -64,47 +64,48 @@ export function SaveQueryForm({
}
);
- useEffect(() => {
- const fetchQueries = async () => {
- const allSavedQueries = await savedQueryService.getAllSavedQueries();
- const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title');
- setSavedQueries(sortedAllSavedQueries);
- };
- fetchQueries();
- }, [savedQueryService]);
-
- const validate = useCallback(() => {
+ const validate = useCallback(async () => {
const errors = [];
- if (
- !!savedQueries.find(
- (existingSavedQuery) => !savedQuery && existingSavedQuery.attributes.title === title
- )
- ) {
- errors.push(titleConflictErrorText);
- }
if (!title) {
errors.push(titleExistsErrorText);
}
+ if (await savedQueryService.isDuplicateTitle(title, savedQuery?.id)) {
+ errors.push(titleConflictErrorText);
+ }
+
if (!isEqual(errors, formErrors)) {
setFormErrors(errors);
return false;
}
return !formErrors.length;
- }, [savedQueries, formErrors, title, savedQuery, titleConflictErrorText, titleExistsErrorText]);
-
- const onClickSave = useCallback(() => {
- if (validate()) {
- onSave({
- id: savedQuery?.id,
- title,
- description: '',
- shouldIncludeFilters,
- shouldIncludeTimefilter,
- });
- onClose();
+ }, [
+ formErrors,
+ savedQuery,
+ savedQueryService,
+ title,
+ titleConflictErrorText,
+ titleExistsErrorText,
+ ]);
+
+ const onClickSave = useCallback(async () => {
+ try {
+ setSaveIsDisabled(true);
+
+ if (await validate()) {
+ onSave({
+ id: savedQuery?.id,
+ title,
+ description: '',
+ shouldIncludeFilters,
+ shouldIncludeTimefilter,
+ });
+ onClose();
+ }
+ } finally {
+ setSaveIsDisabled(false);
}
}, [
validate,
@@ -136,10 +137,6 @@ export function SaveQueryForm({
label={i18n.translate('unifiedSearch.search.searchBar.savedQueryNameLabelText', {
defaultMessage: 'Name',
})}
- helpText={i18n.translate('unifiedSearch.search.searchBar.savedQueryNameHelpText', {
- defaultMessage:
- 'Name cannot contain a leading or trailing whitespace and must be unique.',
- })}
isInvalid={hasErrors}
display="rowCompressed"
>
@@ -200,7 +197,7 @@ export function SaveQueryForm({
onClick={onClickSave}
fill
data-test-subj="savedQueryFormSaveButton"
- disabled={hasErrors}
+ disabled={hasErrors || saveIsDisabled}
>
{i18n.translate('unifiedSearch.search.searchBar.savedQueryFormSaveButtonText', {
defaultMessage: 'Save query',
diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss
index 2e6f639ea792d..ad78b43fb1963 100644
--- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss
+++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss
@@ -4,10 +4,6 @@
overflow-y: hidden;
}
-.kbnSavedQueryManagement__text {
- padding: $euiSizeM $euiSizeM calc($euiSizeM / 2) $euiSizeM;
-}
-
.kbnSavedQueryManagement__list {
@include euiYScrollWithShadows;
max-height: inherit; // Fixes overflow for applied max-height
diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx
index 3dbdfaf7588fc..c5103c49b93fe 100644
--- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx
+++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx
@@ -7,12 +7,7 @@
*/
import React from 'react';
-import { EuiSelectable } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
-import { act } from 'react-dom/test-utils';
-import { findTestSubject } from '@elastic/eui/lib/test';
-import { mountWithIntl as mount } from '@kbn/test-jest-helpers';
-import { ReactWrapper } from 'enzyme';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { coreMock, applicationServiceMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
@@ -20,6 +15,8 @@ import {
SavedQueryManagementListProps,
SavedQueryManagementList,
} from './saved_query_management_list';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
describe('Saved query management list component', () => {
const startMock = coreMock.createStart();
@@ -32,11 +29,16 @@ describe('Saved query management list component', () => {
savedObjectsManagement: { edit: true },
},
};
- function wrapSavedQueriesListComponentInContext(testProps: SavedQueryManagementListProps) {
+
+ const wrapSavedQueriesListComponentInContext = (
+ testProps: SavedQueryManagementListProps,
+ applicationService = application
+ ) => {
const services = {
uiSettings: startMock.uiSettings,
http: startMock.http,
- application,
+ application: applicationService,
+ notifications: startMock.notifications,
};
return (
@@ -46,119 +48,203 @@ describe('Saved query management list component', () => {
);
- }
+ };
+
+ const generateSavedQueries = (total: number) => {
+ const queries = [];
+ for (let i = 0; i < total; i++) {
+ queries.push({
+ id: `8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a${i}`,
+ attributes: {
+ title: `Test ${i}`,
+ description: '',
+ query: {
+ query: 'category.keyword : "Men\'s Shoes" ',
+ language: 'kuery',
+ },
+ filters: [],
+ },
+ namespaces: ['default'],
+ });
+ }
+ return queries;
+ };
+
+ const fooQuery = {
+ id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9',
+ attributes: {
+ title: 'Foo',
+ description: '',
+ query: {
+ query: 'category.keyword : "Men\'s Shoes" ',
+ language: 'kuery',
+ },
+ filters: [],
+ },
+ namespaces: ['default'],
+ };
+
+ const barQuery = {
+ id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a8',
+ attributes: {
+ title: 'Bar',
+ description: '',
+ query: {
+ query: 'category.keyword : "Men\'s Shoes" ',
+ language: 'kuery',
+ },
+ filters: [],
+ },
+ namespaces: ['default'],
+ };
+
+ const testQuery = {
+ id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9',
+ attributes: {
+ title: 'Test',
+ description: '',
+ query: {
+ query: 'category.keyword : "Men\'s Shoes" ',
+ language: 'kuery',
+ },
+ filters: [],
+ },
+ namespaces: ['default'],
+ };
- function flushEffect(component: ReactWrapper) {
- return act(async () => {
- await component;
- await new Promise((r) => setImmediate(r));
- component.update();
- });
- }
let props: SavedQueryManagementListProps;
+
beforeEach(() => {
props = {
onLoad: jest.fn(),
onClearSavedQuery: jest.fn(),
onClose: jest.fn(),
showSaveQuery: true,
- hasFiltersOrQuery: false,
savedQueryService: {
...dataMock.query.savedQueries,
- getAllSavedQueries: jest.fn().mockResolvedValue([
- {
- id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9',
- attributes: {
- title: 'Test',
- description: '',
- query: {
- query: 'category.keyword : "Men\'s Shoes" ',
- language: 'kuery',
- },
- filters: [],
- },
- namespaces: ['default'],
- },
- ]),
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 1,
+ queries: [testQuery],
+ }),
deleteSavedQuery: jest.fn(),
},
+ queryBarMenuRef: React.createRef(),
};
});
+
it('should render the list component if saved queries exist', async () => {
- const component = mount(wrapSavedQueriesListComponentInContext(props));
- await flushEffect(component);
- expect(component.find('[data-test-subj="saved-query-management-list"]').length).toBe(1);
+ render(wrapSavedQueriesListComponentInContext(props));
+ expect(await screen.findByRole('listbox', { name: 'Query list' })).toBeInTheDocument();
});
- it('should not rendet the list component if not saved queries exist', async () => {
+ it('should not render the list component if saved queries do not exist', async () => {
const newProps = {
...props,
savedQueryService: {
...dataMock.query.savedQueries,
- getAllSavedQueries: jest.fn().mockResolvedValue([]),
+ findSavedQueries: jest.fn().mockResolvedValue({ total: 0, queries: [] }),
},
};
- const component = mount(wrapSavedQueriesListComponentInContext(newProps));
- await flushEffect(component);
- expect(component.find('[data-test-subj="saved-query-management-empty"]').length).toBeTruthy();
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox', { name: 'Query list' })).not.toBeInTheDocument();
+ });
+ expect(screen.queryAllByText(/No saved queries/)[0]).toBeInTheDocument();
});
it('should render the saved queries on the selectable component', async () => {
- const component = mount(wrapSavedQueriesListComponentInContext(props));
- await flushEffect(component);
- expect(component.find(EuiSelectable).prop('options').length).toBe(1);
- expect(component.find(EuiSelectable).prop('options')[0].label).toBe('Test');
+ render(wrapSavedQueriesListComponentInContext(props));
+ expect(await screen.findAllByRole('option')).toHaveLength(1);
+ expect(screen.getByRole('option', { name: 'Test' })).toBeInTheDocument();
+ });
+
+ it('should display the total and selected count', async () => {
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ }),
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByText('6 queries')).toBeInTheDocument();
+ expect(screen.queryByText('6 queries | 1 selected')).not.toBeInTheDocument();
+ userEvent.click(screen.getByRole('option', { name: 'Test 0' }));
+ expect(screen.queryByText('6 queries')).not.toBeInTheDocument();
+ expect(screen.getByText('6 queries | 1 selected')).toBeInTheDocument();
+ });
+
+ it('should not display the "Manage queries" link if application.capabilities.savedObjectsManagement.edit is false', async () => {
+ render(
+ wrapSavedQueriesListComponentInContext(props, {
+ ...application,
+ capabilities: {
+ ...application.capabilities,
+ savedObjectsManagement: { edit: false },
+ },
+ })
+ );
+ await waitFor(() => {
+ expect(screen.queryByRole('link', { name: 'Manage queries' })).not.toBeInTheDocument();
+ });
+ });
+
+ it('should display the "Manage queries" link if application.capabilities.savedObjectsManagement.edit is true', async () => {
+ render(wrapSavedQueriesListComponentInContext(props));
+ expect(await screen.findByRole('link', { name: 'Manage queries' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Manage queries' })).toHaveAttribute(
+ 'href',
+ '/app/management/kibana/objects?initialQuery=type:("query")'
+ );
});
- it('should call the onLoad function', async () => {
+ it('should call the onLoad and onClose function', async () => {
const onLoadSpy = jest.fn();
+ const onCloseSpy = jest.fn();
const newProps = {
...props,
onLoad: onLoadSpy,
+ onClose: onCloseSpy,
};
- const component = mount(wrapSavedQueriesListComponentInContext(newProps));
- await flushEffect(component);
- component.find('[data-test-subj="load-saved-query-Test-button"]').first().simulate('click');
- expect(
- component.find('[data-test-subj="saved-query-management-apply-changes-button"]').length
- ).toBeTruthy();
- component
- .find('button[data-test-subj="saved-query-management-apply-changes-button"]')
- .first()
- .simulate('click');
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByLabelText('Load query')).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Delete query' })).toBeDisabled();
+ userEvent.click(screen.getByRole('option', { name: 'Test' }));
+ expect(screen.getByLabelText('Load query')).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Delete query' })).toBeEnabled();
+ userEvent.click(screen.getByLabelText('Load query'));
expect(onLoadSpy).toBeCalled();
+ expect(onCloseSpy).toBeCalled();
});
it('should render the button with the correct text', async () => {
- const component = mount(wrapSavedQueriesListComponentInContext(props));
- await flushEffect(component);
+ render(wrapSavedQueriesListComponentInContext(props));
expect(
- component
- .find('[data-test-subj="saved-query-management-apply-changes-button"]')
- .first()
- .text()
- ).toBe('Load query');
+ await screen.findByTestId('saved-query-management-apply-changes-button')
+ ).toHaveTextContent('Load query');
+ });
+ it('should not render the delete button if showSaveQuery is false', async () => {
const newProps = {
...props,
- hasFiltersOrQuery: true,
+ showSaveQuery: false,
};
- const updatedComponent = mount(wrapSavedQueriesListComponentInContext(newProps));
- await flushEffect(component);
- expect(
- updatedComponent
- .find('[data-test-subj="saved-query-management-apply-changes-button"]')
- .first()
- .text()
- ).toBe('Load query');
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByLabelText('Load query')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Delete query' })).not.toBeInTheDocument();
});
it('should render the modal on delete', async () => {
- const component = mount(wrapSavedQueriesListComponentInContext(props));
- await flushEffect(component);
- findTestSubject(component, 'delete-saved-query-Test-button').simulate('click');
- expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy();
- expect(component.text()).not.toContain('you remove it from every space');
+ render(wrapSavedQueriesListComponentInContext(props));
+ userEvent.click(await screen.findByRole('option', { name: 'Test' }));
+ userEvent.click(screen.getByRole('button', { name: 'Delete query' }));
+ expect(screen.getByRole('heading', { name: 'Delete "Test"?' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+ expect(screen.queryByText(/you remove it from every space/)).not.toBeInTheDocument();
});
it('should render the modal with warning for multiple namespaces on delete', async () => {
@@ -166,55 +252,369 @@ describe('Saved query management list component', () => {
...props,
savedQueryService: {
...props.savedQueryService,
- getAllSavedQueries: jest.fn().mockResolvedValue([
- {
- id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9',
- attributes: {
- title: 'Test',
- description: '',
- query: {
- query: 'category.keyword : "Men\'s Shoes" ',
- language: 'kuery',
- },
- filters: [],
- },
- namespaces: ['one', 'two'],
- },
- ]),
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 1,
+ queries: [{ ...testQuery, namespaces: ['one', 'two'] }],
+ }),
deleteSavedQuery: jest.fn(),
},
};
- const component = mount(wrapSavedQueriesListComponentInContext(newProps));
- await flushEffect(component);
- findTestSubject(component, 'delete-saved-query-Test-button').simulate('click');
-
- expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy();
- expect(component.text()).toContain('you remove it from every space');
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ userEvent.click(await screen.findByRole('option', { name: 'Test' }));
+ userEvent.click(screen.getByRole('button', { name: 'Delete query' }));
+ expect(screen.getByRole('heading', { name: 'Delete "Test"?' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+ expect(screen.queryByText(/you remove it from every space/)).toBeInTheDocument();
});
- it('should render the onClearSavedQuery on delete of the current selected query', async () => {
+ it('should call deleteSavedQuery and onClearSavedQuery on delete of the current selected query', async () => {
+ const deleteSavedQuerySpy = jest.fn();
const onClearSavedQuerySpy = jest.fn();
const newProps = {
...props,
- loadedSavedQuery: {
- id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9',
- attributes: {
- title: 'Test',
- description: '',
- query: {
- query: 'category.keyword : "Men\'s Shoes" ',
- language: 'kuery',
- },
- filters: [],
- },
- namespaces: ['default'],
+ loadedSavedQuery: testQuery,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 2,
+ queries: generateSavedQueries(1),
+ }),
+ deleteSavedQuery: deleteSavedQuerySpy,
},
onClearSavedQuery: onClearSavedQuerySpy,
};
- const component = mount(wrapSavedQueriesListComponentInContext(newProps));
- await flushEffect(component);
- findTestSubject(component, 'delete-saved-query-Test-button').simulate('click');
- findTestSubject(component, 'confirmModalConfirmButton').simulate('click');
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByText('2 queries | 1 selected')).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(2);
+ expect(screen.getByLabelText('Load query')).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Delete query' })).toBeEnabled();
+ userEvent.click(screen.getByRole('button', { name: 'Delete query' }));
+ userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expect(screen.getByText('1 query')).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ expect(screen.getByLabelText('Load query')).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Delete query' })).toBeDisabled();
+ expect(deleteSavedQuerySpy).toHaveBeenLastCalledWith('8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9');
expect(onClearSavedQuerySpy).toBeCalled();
});
+
+ it('should not render pagination if there are less than 5 saved queries', async () => {
+ render(wrapSavedQueriesListComponentInContext(props));
+ await waitFor(() => {
+ expect(screen.queryByText(/1 of/)).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render pagination if there are more than 5 saved queries', async () => {
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ }),
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByText(/1 of 2/)).toBeInTheDocument();
+ });
+
+ it('should allow navigating between saved query pages', async () => {
+ const findSavedQueriesSpy = jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(screen.getByText(/1 of 2/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(1),
+ });
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(screen.getByText(/1 of 2/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ });
+
+ it('should not clear the currently selected saved query when navigating between pages', async () => {
+ const findSavedQueriesSpy = jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(screen.getByRole('option', { name: 'Test 0', checked: false })).toBeInTheDocument();
+ userEvent.click(screen.getByRole('option', { name: 'Test 0' }));
+ expect(screen.getByRole('option', { name: 'Test 0', checked: true })).toBeInTheDocument();
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(1),
+ });
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(screen.getByRole('option', { name: 'Test 0', checked: true })).toBeInTheDocument();
+ });
+
+ it('should allow providing a search term', async () => {
+ const findSavedQueriesSpy = jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(screen.getByText(/1 of 2/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(1),
+ });
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ expect(screen.getByText(/2 of 2/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ findSavedQueriesSpy.mockResolvedValue({
+ total: 1,
+ queries: generateSavedQueries(1),
+ });
+ userEvent.type(screen.getByRole('combobox', { name: 'Query list' }), ' Test And Search ');
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith('Test And Search', 5, 1);
+ });
+ expect(screen.queryByText(/1 of/)).not.toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ });
+
+ it('should correctly handle out of order responses', async () => {
+ const completionOrder: number[] = [];
+ let triggerResolve = () => {};
+ const findSavedQueriesSpy = jest.fn().mockImplementation(async (_, __, page) => {
+ let queries: ReturnType = [];
+ if (page === 1) {
+ queries = generateSavedQueries(5);
+ completionOrder.push(1);
+ } else if (page === 2) {
+ queries = await new Promise((resolve) => {
+ triggerResolve = () => resolve(generateSavedQueries(5));
+ });
+ completionOrder.push(2);
+ } else if (page === 3) {
+ queries = generateSavedQueries(1);
+ completionOrder.push(3);
+ }
+ return {
+ total: 11,
+ queries,
+ };
+ });
+ const newProps = {
+ ...props,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ await waitFor(() => {
+ expect(completionOrder).toEqual([1]);
+ });
+ expect(screen.getByText(/1 of 3/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ expect(completionOrder).toEqual([1]);
+ expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 3);
+ });
+ expect(completionOrder).toEqual([1, 3]);
+ triggerResolve();
+ await waitFor(() => {
+ expect(completionOrder).toEqual([1, 3, 2]);
+ });
+ expect(screen.getByText(/3 of 3/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ expect(completionOrder).toEqual([1, 3, 2]);
+ expect(screen.getByText(/2 of 3/)).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1);
+ });
+ expect(completionOrder).toEqual([1, 3, 2, 1]);
+ triggerResolve();
+ await waitFor(() => {
+ expect(completionOrder).toEqual([1, 3, 2, 1, 2]);
+ });
+ });
+
+ it('should not display an "Active" badge if there is no currently loaded saved query', async () => {
+ render(wrapSavedQueriesListComponentInContext(props));
+ await waitFor(() => {
+ expect(screen.queryByText(/Active/)).not.toBeInTheDocument();
+ });
+ });
+
+ it('should display an "Active" badge for the currently loaded saved query', async () => {
+ const newProps = {
+ ...props,
+ loadedSavedQuery: testQuery,
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findByText(/Active/)).toBeInTheDocument();
+ });
+
+ it('should hoist the currently loaded saved query to the top of the list', async () => {
+ const newProps = {
+ ...props,
+ loadedSavedQuery: fooQuery,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 2,
+ queries: [barQuery, fooQuery],
+ }),
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findAllByRole('option')).toHaveLength(2);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo');
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active');
+ expect(screen.getAllByRole('option')[1]).toHaveTextContent('Bar');
+ expect(screen.getAllByRole('option')[1]).not.toHaveTextContent('Active');
+ });
+
+ it('should hoist the currently loaded saved query to the top of the list even if it is not in the first page of results', async () => {
+ const newProps = {
+ ...props,
+ loadedSavedQuery: fooQuery,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ }),
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findAllByRole('option')).toHaveLength(6);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo');
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active');
+ expect(screen.getAllByRole('option')[1]).toHaveTextContent('Test 0');
+ expect(screen.getAllByRole('option')[1]).not.toHaveTextContent('Active');
+ });
+
+ it('should not hoist the currently loaded saved query to the top of the list if there is a search term', async () => {
+ const findSavedQueriesSpy = jest.fn().mockResolvedValue({
+ total: 2,
+ queries: [barQuery, fooQuery],
+ });
+ const newProps = {
+ ...props,
+ loadedSavedQuery: fooQuery,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findAllByRole('option')).toHaveLength(2);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo');
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active');
+ userEvent.type(screen.getByRole('searchbox', { name: 'Query list' }), ' Test And Search ');
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith('Test And Search', 5, 1);
+ });
+ expect(screen.getAllByRole('option')).toHaveLength(2);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Bar');
+ expect(screen.getAllByRole('option')[0]).not.toHaveTextContent('Active');
+ });
+
+ it('should not hoist the currently loaded saved query to the top of the list if not on the first page', async () => {
+ const findSavedQueriesSpy = jest.fn().mockResolvedValue({
+ total: 6,
+ queries: generateSavedQueries(5),
+ });
+ const newProps = {
+ ...props,
+ loadedSavedQuery: fooQuery,
+ savedQueryService: {
+ ...props.savedQueryService,
+ findSavedQueries: findSavedQueriesSpy,
+ },
+ };
+ render(wrapSavedQueriesListComponentInContext(newProps));
+ expect(await screen.findAllByRole('option')).toHaveLength(6);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo');
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active');
+ userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() => {
+ expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2);
+ });
+ expect(screen.getAllByRole('option')).toHaveLength(5);
+ expect(screen.getAllByRole('option')[0]).toHaveTextContent('Test 0');
+ expect(screen.getAllByRole('option')[0]).not.toHaveTextContent('Active');
+ });
});
diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx
index 0cff3baa90883..d62061d7d6cf6 100644
--- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx
+++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx
@@ -13,33 +13,42 @@ import {
EuiIcon,
EuiPanel,
EuiSelectable,
- EuiText,
EuiPopoverFooter,
EuiButtonIcon,
- EuiButtonEmpty,
EuiConfirmModal,
- usePrettyDuration,
ShortDate,
+ EuiPagination,
+ EuiBadge,
+ EuiToolTip,
+ EuiText,
+ EuiHorizontalRule,
+ EuiProgress,
+ PrettyDuration,
} from '@elastic/eui';
-
+import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu';
import { i18n } from '@kbn/i18n';
-import React, { useCallback, useEffect, useState, useRef } from 'react';
-import { css } from '@emotion/react';
-import { sortBy } from 'lodash';
+import React, { useCallback, useState, useRef, useEffect, useMemo, RefObject } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
import { useKibana } from '@kbn/kibana-react-plugin/public';
-import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public';
+import type { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public';
import type { SavedQueryAttributes } from '@kbn/data-plugin/common';
import './saved_query_management_list.scss';
+import { euiThemeVars } from '@kbn/ui-theme';
+import { debounce } from 'lodash';
+import useLatest from 'react-use/lib/useLatest';
+import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { IUnifiedSearchPluginServices } from '../types';
+import { strings as queryBarMenuPanelsStrings } from '../query_string_input/query_bar_menu_panels';
+import { PanelTitle } from '../query_string_input/panel_title';
export interface SavedQueryManagementListProps {
showSaveQuery?: boolean;
loadedSavedQuery?: SavedQuery;
savedQueryService: SavedQueryService;
+ queryBarMenuRef: RefObject;
onLoad: (savedQuery: SavedQuery) => void;
onClearSavedQuery: () => void;
onClose: () => void;
- hasFiltersOrQuery: boolean;
}
interface SelectableProps {
@@ -60,34 +69,90 @@ interface DurationRange {
}
const commonDurationRanges: DurationRange[] = [
- { start: 'now/d', end: 'now/d', label: 'Today' },
- { start: 'now/w', end: 'now/w', label: 'This week' },
- { start: 'now/M', end: 'now/M', label: 'This month' },
- { start: 'now/y', end: 'now/y', label: 'This year' },
- { start: 'now-1d/d', end: 'now-1d/d', label: 'Yesterday' },
- { start: 'now/w', end: 'now', label: 'Week to date' },
- { start: 'now/M', end: 'now', label: 'Month to date' },
- { start: 'now/y', end: 'now', label: 'Year to date' },
+ {
+ start: 'now/d',
+ end: 'now/d',
+ label: i18n.translate('unifiedSearch.search.searchBar.savedQueryTodayLabel', {
+ defaultMessage: 'Today',
+ }),
+ },
+ {
+ start: 'now/w',
+ end: 'now/w',
+ label: i18n.translate('unifiedSearch.search.searchBar.savedQueryWeekLabel', {
+ defaultMessage: 'This week',
+ }),
+ },
+ {
+ start: 'now/M',
+ end: 'now/M',
+ label: i18n.translate('unifiedSearch.search.searchBar.savedQueryMonthLabel', {
+ defaultMessage: 'This month',
+ }),
+ },
+ {
+ start: 'now/y',
+ end: 'now/y',
+ label: i18n.translate('unifiedSearch.search.searchBar.savedQueryYearLabel', {
+ defaultMessage: 'This year',
+ }),
+ },
+ {
+ start: 'now-1d/d',
+ end: 'now-1d/d',
+ label: i18n.translate('unifiedSearch.searchBar.savedQueryYesterdayLabel', {
+ defaultMessage: 'Yesterday',
+ }),
+ },
+ {
+ start: 'now/w',
+ end: 'now',
+ label: i18n.translate('unifiedSearch.searchBar.savedQueryWeekToDateLabel', {
+ defaultMessage: 'Week to date',
+ }),
+ },
+ {
+ start: 'now/M',
+ end: 'now',
+ label: i18n.translate('unifiedSearch.searchBar.savedQueryMonthToDateLabel', {
+ defaultMessage: 'Month to date',
+ }),
+ },
+ {
+ start: 'now/y',
+ end: 'now',
+ label: i18n.translate('unifiedSearch.searchBar.savedQueryYearToDateLabel', {
+ defaultMessage: 'Year to date',
+ }),
+ },
];
-const itemTitle = (attributes: SavedQueryAttributes, format: string) => {
- let label = attributes.title;
- const prettifier = usePrettyDuration;
+const itemTitle = (attributes: SavedQueryAttributes, services: IUnifiedSearchPluginServices) => {
+ const label = [attributes.title];
if (attributes.description) {
- label += `; ${attributes.description}`;
+ label.push(attributes.description);
}
if (attributes.timefilter) {
- label += `; ${prettifier({
- timeFrom: attributes.timefilter?.from,
- timeTo: attributes.timefilter?.to,
- quickRanges: commonDurationRanges,
- dateFormat: format,
- })}`;
+ label.push(
+ // This is a hack to render the PrettyDuration component to a string since itemTitle
+ // is called in a loop, so the usePrettyDuration hook is not an option, and it must
+ // return a string, but there is no non-hook alternative that returns a string
+ renderToStaticMarkup(
+
+
+
+ )
+ );
}
- return label;
+ return label.join('; ');
};
const itemLabel = (attributes: SavedQueryAttributes) => {
@@ -112,41 +177,103 @@ const itemLabel = (attributes: SavedQueryAttributes) => {
return label;
};
-export function SavedQueryManagementList({
+const noSavedQueriesDescriptionText = [
+ i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', {
+ defaultMessage: 'No saved queries.',
+ }),
+ i18n.translate('unifiedSearch.search.searchBar.savedQueryDescriptionText', {
+ defaultMessage: 'Save query text and filters that you want to use again.',
+ }),
+].join(' ');
+
+const savedQueryMultipleNamespacesDeleteWarning = i18n.translate(
+ 'unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning',
+ {
+ defaultMessage: `This saved query is shared in multiple spaces. When you delete it, you remove it from every space it is shared in. You can't undo this action.`,
+ }
+);
+
+const SAVED_QUERY_PAGE_SIZE = 5;
+const SAVED_QUERY_SEARCH_DEBOUNCE = 500;
+const LOADING_INDICATOR_DELAY = 250;
+
+export const SavedQueryManagementList = ({
showSaveQuery,
loadedSavedQuery,
+ savedQueryService,
+ queryBarMenuRef,
onLoad,
onClearSavedQuery,
- savedQueryService,
onClose,
- hasFiltersOrQuery,
-}: SavedQueryManagementListProps) {
- const kibana = useKibana();
- const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]);
+}: SavedQueryManagementListProps) => {
+ const services = useKibana().services;
+ const [searchTerm, setSearchTerm] = useState('');
+ const [currentPageNumber, setCurrentPageNumber] = useState(0);
+ const [totalQueryCount, setTotalQueryCount] = useState(0);
+ const [currentPageQueries, setCurrentPageQueries] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isInitializing, setIsInitializing] = useState(true);
+ const currentPageFetchId = useRef(0);
+ const selectableRef = useRef(null);
const [selectedSavedQuery, setSelectedSavedQuery] = useState(loadedSavedQuery);
- const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null as SavedQuery | null);
+ const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null);
const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false);
- const cancelPendingListingRequest = useRef<() => void>(() => {});
- const { uiSettings, http, application } = kibana.services;
- const format = uiSettings.get('dateFormat');
- useEffect(() => {
- const fetchCountAndSavedQueries = async () => {
- cancelPendingListingRequest.current();
- let requestGotCancelled = false;
- cancelPendingListingRequest.current = () => {
- requestGotCancelled = true;
- };
+ const debouncedSetSearchTerm = useMemo(() => {
+ return debounce((newSearchTerm: string) => {
+ setSearchTerm((currentSearchTerm) => {
+ if (currentSearchTerm !== newSearchTerm) {
+ setCurrentPageNumber(0);
+ }
- const savedQueryItems = await savedQueryService.getAllSavedQueries();
+ return newSearchTerm;
+ });
+ }, SAVED_QUERY_SEARCH_DEBOUNCE);
+ }, []);
- if (requestGotCancelled) return;
+ const fetchPage = useLatest(async () => {
+ const fetchIdValue = ++currentPageFetchId.current;
+ const loadingTimeout = setTimeout(() => {
+ setIsLoading(true);
+ }, LOADING_INDICATOR_DELAY);
+
+ try {
+ const preparedSearch = searchTerm.trim();
+ const { total, queries } = await savedQueryService.findSavedQueries(
+ preparedSearch || undefined,
+ SAVED_QUERY_PAGE_SIZE,
+ currentPageNumber + 1
+ );
+
+ if (fetchIdValue !== currentPageFetchId.current) {
+ return;
+ }
+
+ let filteredQueries = queries;
+
+ if (loadedSavedQuery && !preparedSearch && currentPageNumber === 0) {
+ filteredQueries = [
+ loadedSavedQuery,
+ ...queries.filter((savedQuery) => savedQuery.id !== loadedSavedQuery.id),
+ ];
+ }
+
+ setTotalQueryCount(total);
+ setCurrentPageQueries(filteredQueries);
+ selectableRef.current?.scrollToItem(0);
+ } finally {
+ clearTimeout(loadingTimeout);
+
+ if (fetchIdValue === currentPageFetchId.current) {
+ setIsLoading(false);
+ setIsInitializing(false);
+ }
+ }
+ });
- const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title');
- setSavedQueries(sortedSavedQueryItems);
- };
- fetchCountAndSavedQueries();
- }, [savedQueryService]);
+ useEffect(() => {
+ fetchPage.current();
+ }, [currentPageNumber, fetchPage, searchTerm]);
const handleLoad = useCallback(() => {
if (selectedSavedQuery) {
@@ -165,194 +292,275 @@ export function SavedQueryManagementList({
}, []);
const onDelete = useCallback(
- (savedQueryToDelete: string) => {
+ (savedQueryToDelete: SavedQuery) => {
const onDeleteSavedQuery = async (savedQueryId: string) => {
- cancelPendingListingRequest.current();
- setSavedQueries(
- savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId)
+ setTotalQueryCount((currentTotalQueryCount) => Math.max(0, currentTotalQueryCount - 1));
+ setCurrentPageQueries(
+ currentPageQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId)
);
+ setSelectedSavedQuery(undefined);
if (loadedSavedQuery && loadedSavedQuery.id === savedQueryId) {
onClearSavedQuery();
- setSelectedSavedQuery(undefined);
}
- await savedQueryService.deleteSavedQuery(savedQueryId);
+ try {
+ await savedQueryService.deleteSavedQuery(savedQueryId);
+
+ services.notifications.toasts.addSuccess(
+ i18n.translate('unifiedSearch.search.searchBar.deleteQuerySuccessMessage', {
+ defaultMessage: 'Query "{queryTitle}" was deleted',
+ values: {
+ queryTitle: savedQueryToDelete.attributes.title,
+ },
+ })
+ );
+ } catch (error) {
+ services.notifications.toasts.addDanger(
+ i18n.translate('unifiedSearch.search.searchBar.deleteQueryErrorMessage', {
+ defaultMessage:
+ 'An error occured while deleting query "{queryTitle}": {errorMessage}',
+ values: {
+ queryTitle: savedQueryToDelete.attributes.title,
+ errorMessage: error.message,
+ },
+ })
+ );
+ throw error;
+ }
};
- onDeleteSavedQuery(savedQueryToDelete);
+ onDeleteSavedQuery(savedQueryToDelete.id);
},
- [loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService]
+ [
+ currentPageQueries,
+ loadedSavedQuery,
+ onClearSavedQuery,
+ savedQueryService,
+ services.notifications.toasts,
+ ]
);
- const savedQueryDescriptionText = i18n.translate(
- 'unifiedSearch.search.searchBar.savedQueryDescriptionText',
- {
- defaultMessage: 'Save query text and filters that you want to use again.',
- }
- );
-
- const noSavedQueriesDescriptionText =
- i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', {
- defaultMessage: 'No saved queries.',
- }) +
- ' ' +
- savedQueryDescriptionText;
-
- const savedQueryMultipleNamespacesDeleteWarning = i18n.translate(
- 'unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning',
- {
- defaultMessage: `This saved query is shared in multiple spaces. When you delete it, you remove it from every space it is shared in. You can't undo this action.`,
- }
- );
-
- const savedQueriesOptions = () => {
- const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => {
- if (!loadedSavedQuery) return true;
- return savedQuery.id !== loadedSavedQuery.id;
- });
- const savedQueriesReordered =
- loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length
- ? [loadedSavedQuery, ...savedQueriesWithoutCurrent]
- : [...savedQueriesWithoutCurrent];
-
- return savedQueriesReordered.map((savedQuery) => {
+ const savedQueriesOptions = useMemo(() => {
+ return currentPageQueries.map((savedQuery) => {
return {
key: savedQuery.id,
label: savedQuery.attributes.title,
- title: itemTitle(savedQuery.attributes, format),
+ title: itemTitle(savedQuery.attributes, services),
'data-test-subj': `load-saved-query-${savedQuery.attributes.title}-button`,
value: savedQuery.id,
checked: selectedSavedQuery && savedQuery.id === selectedSavedQuery.id ? 'on' : undefined,
data: {
attributes: savedQuery.attributes,
},
- append: !!showSaveQuery && (
- handleDelete(savedQuery)}
- color="danger"
- />
- ),
};
- }) as unknown as SelectableProps[];
- };
+ });
+ }, [currentPageQueries, selectedSavedQuery, services]);
- const renderOption = (option: RenderOptionProps) => {
- return <>{option.attributes ? itemLabel(option.attributes) : option.label}>;
- };
+ const renderOption = useCallback(
+ (option: RenderOptionProps) => {
+ return (
+ <>
+ {option.attributes ? itemLabel(option.attributes) : option.label}
+ {option.value === loadedSavedQuery?.id && (
+
+ {i18n.translate('unifiedSearch.search.searchBar.savedQueryActiveBadgeText', {
+ defaultMessage: 'Active',
+ })}
+
+ )}
+ >
+ );
+ },
+ [loadedSavedQuery?.id]
+ );
- const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit;
+ const countDisplay = useMemo(() => {
+ const parts = [
+ i18n.translate('unifiedSearch.search.searchBar.savedQueryTotalQueryCount', {
+ defaultMessage: '{totalQueryCount, plural, one {# query} other {# queries}}',
+ values: { totalQueryCount },
+ }),
+ ];
+
+ if (Boolean(selectedSavedQuery)) {
+ parts.push(
+ i18n.translate('unifiedSearch.search.searchBar.savedQuerySelectedQueryCount', {
+ defaultMessage: '1 selected',
+ })
+ );
+ }
+
+ return parts.join(' | ');
+ }, [selectedSavedQuery, totalQueryCount]);
- const listComponent = (
+ return (
<>
- {savedQueries.length > 0 ? (
- <>
-
-
- aria-label="Basic example"
- options={savedQueriesOptions()}
- searchable
- singleSelection="always"
- onChange={(choices) => {
- const choice = choices.find(({ checked }) => checked) as unknown as {
- value: string;
- };
- if (choice) {
- handleSelect(savedQueries.find((savedQuery) => savedQuery.id === choice.value));
- }
- }}
- searchProps={{
- compressed: true,
- placeholder: i18n.translate(
- 'unifiedSearch.query.queryBar.indexPattern.findFilterSet',
- {
- defaultMessage: 'Find a query',
- }
- ),
- }}
- listProps={{
- isVirtualized: true,
- }}
- renderOption={renderOption}
- >
- {(list, search) => (
- <>
-
- {search}
-
- {list}
- >
- )}
-
-
- >
- ) : (
- <>
-
- {noSavedQueriesDescriptionText}
-
- >
- )}
-
-
-
-
- {i18n.translate(
- 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel',
+
+
+
+ {isLoading && }
+
+ ref={selectableRef}
+ aria-label={i18n.translate('unifiedSearch.search.searchBar.savedQueryListAriaLabel', {
+ defaultMessage: 'Query list',
+ })}
+ isLoading={isInitializing}
+ singleSelection="always"
+ options={savedQueriesOptions}
+ listProps={{ onFocusBadge: false }}
+ isPreFiltered
+ searchable
+ searchProps={{
+ compressed: true,
+ placeholder: i18n.translate(
+ 'unifiedSearch.query.queryBar.indexPattern.findFilterSet',
{
- defaultMessage: 'Load query',
+ defaultMessage: 'Find a query',
}
- )}
-
+ ),
+ onChange: debouncedSetSearchTerm,
+ 'data-test-subj': 'saved-query-management-search-input',
+ }}
+ loadingMessage={i18n.translate(
+ 'unifiedSearch.search.searchBar.savedQueryLoadingQueriesText',
+ {
+ defaultMessage: 'Loading queries',
+ }
+ )}
+ emptyMessage={
+
+ {noSavedQueriesDescriptionText}
+
+ }
+ onChange={(choices) => {
+ const choice = choices.find(({ checked }) => checked);
+ if (choice) {
+ handleSelect(
+ currentPageQueries.find((savedQuery) => savedQuery.id === choice.value)
+ );
+ }
+ }}
+ renderOption={renderOption}
+ css={{
+ '.euiSelectableList__list': {
+ WebkitMaskImage: 'unset',
+ maskImage: 'unset',
+ },
+ }}
+ >
+ {(list, search) => (
+ <>
+
+ {search}
+
+
+
+ {countDisplay}
+
+
+
+ {list}
+ >
+ )}
+
+
+ {totalQueryCount > SAVED_QUERY_PAGE_SIZE && (
+
+
+
+ setCurrentPageNumber(activePage)}
+ compressed
+ />
+
+
- {canEditSavedObjects && (
+ )}
+
+
+
+ {Boolean(showSaveQuery) && (
-
- {i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverManageLabel', {
- defaultMessage: 'Manage saved objects',
- })}
-
+ {
+ if (selectedSavedQuery) {
+ handleDelete(selectedSavedQuery);
+ }
+ }}
+ />
+
)}
+
+
+
+ {i18n.translate(
+ 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel',
+ {
+ defaultMessage: 'Load query',
+ }
+ )}
+
+
+
{showDeletionConfirmationModal && toBeDeletedSavedQuery && (
@@ -379,7 +587,7 @@ export function SavedQueryManagementList({
}
)}
onConfirm={() => {
- onDelete(toBeDeletedSavedQuery.id);
+ onDelete(toBeDeletedSavedQuery);
setShowDeletionConfirmationModal(false);
}}
buttonColor="danger"
@@ -395,6 +603,40 @@ export function SavedQueryManagementList({
)}
>
);
+};
- return listComponent;
-}
+const ListTitle = ({ queryBarMenuRef }: { queryBarMenuRef: RefObject }) => {
+ const { application, http } = useKibana().services;
+ const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit;
+
+ return (
+
+
+
+ )
+ }
+ />
+ );
+};
diff --git a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx
index b7c7e83b7c7f5..e9fce0f749928 100644
--- a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx
+++ b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx
@@ -100,6 +100,7 @@ function wrapSearchBarInContext(testProps: any) {
savedQueries: {
findSavedQueries: () =>
Promise.resolve({
+ total: 1,
queries: [
{
id: 'testwewe',
@@ -115,6 +116,7 @@ function wrapSearchBarInContext(testProps: any) {
},
],
}),
+ getSavedQueryCount: jest.fn(),
},
},
dataViewEditor: dataViewEditorMock,
diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx
index a8a81224df534..77755ccd6a990 100644
--- a/src/plugins/unified_search/public/search_bar/search_bar.tsx
+++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx
@@ -9,18 +9,24 @@
import { compact } from 'lodash';
import { InjectedIntl, injectI18n } from '@kbn/i18n-react';
import classNames from 'classnames';
-import React, { Component } from 'react';
+import React, { Component, createRef } from 'react';
import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui';
+import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu';
import { get, isEqual } from 'lodash';
import memoizeOne from 'memoize-one';
import { METRIC_TYPE } from '@kbn/analytics';
import { Query, Filter, TimeRange, AggregateQuery, isOfQueryType } from '@kbn/es-query';
import { withKibana, KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
-import type { TimeHistoryContract, SavedQuery } from '@kbn/data-plugin/public';
+import type {
+ TimeHistoryContract,
+ SavedQuery,
+ SavedQueryTimeFilter,
+} from '@kbn/data-plugin/public';
import type { SavedQueryAttributes } from '@kbn/data-plugin/common';
import { DataView } from '@kbn/data-views-plugin/public';
+import { i18n } from '@kbn/i18n';
import type { IUnifiedSearchPluginServices } from '../types';
import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form';
import { SavedQueryManagementList } from '../saved_query_management';
@@ -153,6 +159,7 @@ class SearchBarUI extends C
private services = this.props.kibana.services;
private savedQueryService = this.services.data.query.savedQueries;
+ private queryBarMenuRef = createRef();
public static getDerivedStateFromProps(
nextProps: SearchBarProps,
@@ -290,27 +297,14 @@ class SearchBarUI extends C
return true;
}
- public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => {
- if (!this.state.query) return;
-
- const savedQueryAttributes: SavedQueryAttributes = {
- title: savedQueryMeta.title,
- description: savedQueryMeta.description,
- query: this.state.query as Query,
- };
-
- if (savedQueryMeta.shouldIncludeFilters) {
- savedQueryAttributes.filters = this.props.filters;
- }
-
+ private getTimeFilter(): SavedQueryTimeFilter | undefined {
if (
- savedQueryMeta.shouldIncludeTimefilter &&
this.state.dateRangeTo !== undefined &&
this.state.dateRangeFrom !== undefined &&
this.props.refreshInterval !== undefined &&
this.props.isRefreshPaused !== undefined
) {
- savedQueryAttributes.timefilter = {
+ return {
from: this.state.dateRangeFrom,
to: this.state.dateRangeTo,
refreshInterval: {
@@ -319,6 +313,26 @@ class SearchBarUI extends C
},
};
}
+ }
+
+ public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => {
+ if (!this.state.query) return;
+
+ const savedQueryAttributes: SavedQueryAttributes = {
+ title: savedQueryMeta.title,
+ description: savedQueryMeta.description,
+ query: this.state.query as Query,
+ };
+
+ if (savedQueryMeta.shouldIncludeFilters) {
+ savedQueryAttributes.filters = this.props.filters;
+ }
+
+ const timeFilter = this.getTimeFilter();
+
+ if (savedQueryMeta.shouldIncludeTimefilter && timeFilter) {
+ savedQueryAttributes.timefilter = timeFilter;
+ }
try {
let response;
@@ -332,7 +346,12 @@ class SearchBarUI extends C
}
this.services.notifications.toasts.addSuccess(
- `Your query "${response.attributes.title}" was saved`
+ i18n.translate('unifiedSearch.search.searchBar.saveQuerySuccessMessage', {
+ defaultMessage: 'Your query "{queryTitle}" was saved',
+ values: {
+ queryTitle: response.attributes.title,
+ },
+ })
);
if (this.props.onSaved) {
@@ -340,7 +359,12 @@ class SearchBarUI extends C
}
} catch (error) {
this.services.notifications.toasts.addDanger(
- `An error occured while saving your query: ${error.message}`
+ i18n.translate('unifiedSearch.search.searchBar.saveQueryErrorMessage', {
+ defaultMessage: 'An error occured while saving your query: {errorMessage}',
+ values: {
+ errorMessage: error.message,
+ },
+ })
);
throw error;
}
@@ -498,6 +522,7 @@ class SearchBarUI extends C
onQueryBarSubmit={this.onQueryBarSubmit}
dateRangeFrom={this.state.dateRangeFrom}
dateRangeTo={this.state.dateRangeTo}
+ timeFilter={this.getTimeFilter()}
savedQueryService={this.savedQueryService}
saveAsNewQueryFormComponent={saveAsNewQueryFormComponent}
saveFormComponent={saveQueryFormComponent}
@@ -528,6 +553,7 @@ class SearchBarUI extends C
}
suggestionsAbstraction={this.props.suggestionsAbstraction}
renderQueryInputAppend={this.props.renderQueryInputAppend}
+ queryBarMenuRef={this.queryBarMenuRef}
/>
) : undefined;
@@ -621,14 +647,6 @@ class SearchBarUI extends C
);
}
- private hasFiltersOrQuery() {
- const hasFilters = Boolean(this.props.filters && this.props.filters.length > 0);
- const hasQuery = Boolean(
- this.state.query && isOfQueryType(this.state.query) && this.state.query.query
- );
- return hasFilters || hasQuery;
- }
-
private renderSavedQueryManagement = memoizeOne(
(
onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'],
@@ -639,11 +657,11 @@ class SearchBarUI extends C
this.setState({ openQueryBarMenu: false })}
- hasFiltersOrQuery={this.hasFiltersOrQuery()}
/>
);
diff --git a/src/plugins/unified_search/public/types.ts b/src/plugins/unified_search/public/types.ts
index 73c581e8f4c27..fa74c87884bb3 100755
--- a/src/plugins/unified_search/public/types.ts
+++ b/src/plugins/unified_search/public/types.ts
@@ -92,6 +92,9 @@ export interface IUnifiedSearchPluginServices extends Partial {
notifications: CoreStart['notifications'];
application: CoreStart['application'];
http: CoreStart['http'];
+ analytics: CoreStart['analytics'];
+ i18n: CoreStart['i18n'];
+ theme: CoreStart['theme'];
storage: IStorageWrapper;
docLinks: DocLinksStart;
data: DataPublicPluginStart;
diff --git a/src/plugins/unified_search/tsconfig.json b/src/plugins/unified_search/tsconfig.json
index d5842db6d1c58..7a70c4aafe2a3 100644
--- a/src/plugins/unified_search/tsconfig.json
+++ b/src/plugins/unified_search/tsconfig.json
@@ -44,6 +44,7 @@
"@kbn/ml-string-hash",
"@kbn/code-editor",
"@kbn/calculate-width-from-char-count",
+ "@kbn/react-kibana-context-render",
],
"exclude": [
"target/**/*",
diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts
index 4deb2acb66d74..8a4dc8de7a52b 100644
--- a/test/accessibility/apps/discover.ts
+++ b/test/accessibility/apps/discover.ts
@@ -109,7 +109,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.discover.clickSavedQueriesPopOver();
await testSubjects.click('saved-query-management-load-button');
await savedQueryManagementComponent.deleteSavedQuery('test');
- await a11y.testAppSnapshot();
+ await a11y.testAppSnapshot({
+ // The saved query selectable search input has invalid aria attrs after
+ // the query is deleted and the `emptyMessage` is displayed, and it fails
+ // with this error, likely because the list is replaced by `emptyMessage`:
+ // [aria-valid-attr-value]: Ensures all ARIA attributes have valid values
+ excludeTestSubj: ['saved-query-management-search-input'],
+ });
});
// adding a11y tests for the new data grid
diff --git a/test/api_integration/apis/saved_queries/index.js b/test/api_integration/apis/saved_queries/index.ts
similarity index 77%
rename from test/api_integration/apis/saved_queries/index.js
rename to test/api_integration/apis/saved_queries/index.ts
index 6f531e8026940..fd029c8764f01 100644
--- a/test/api_integration/apis/saved_queries/index.js
+++ b/test/api_integration/apis/saved_queries/index.ts
@@ -6,7 +6,9 @@
* Side Public License, v 1.
*/
-export default function ({ loadTestFile }) {
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ loadTestFile }: FtrProviderContext) {
describe('Saved queries', () => {
loadTestFile(require.resolve('./saved_queries'));
});
diff --git a/test/api_integration/apis/saved_queries/saved_queries.js b/test/api_integration/apis/saved_queries/saved_queries.js
deleted file mode 100644
index eb3c1465e24de..0000000000000
--- a/test/api_integration/apis/saved_queries/saved_queries.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-import expect from '@kbn/expect';
-import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
-import { SAVED_QUERY_BASE_URL } from '@kbn/data-plugin/common';
-
-// node scripts/functional_tests --config test/api_integration/config.js --grep="search session"
-
-const mockSavedQuery = {
- title: 'my title',
- description: 'my description',
- query: {
- query: 'foo: bar',
- language: 'kql',
- },
- filters: [],
-};
-
-export default function ({ getService }) {
- const esArchiver = getService('esArchiver');
- const supertest = getService('supertest');
- void SAVED_QUERY_BASE_URL;
-
- describe('Saved queries API', function () {
- before(async () => {
- await esArchiver.emptyKibanaIndex();
- await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
- });
-
- after(async () => {
- await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
- });
-
- it('should return 200 for create saved query', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_create`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send(mockSavedQuery)
- .expect(200)
- .then(({ body }) => {
- expect(body.id).to.have.length(36);
- expect(body.attributes.title).to.be('my title');
- expect(body.attributes.description).to.be('my description');
- }));
-
- it('should return 400 for create invalid saved query', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_create`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send({ description: 'my description' })
- .expect(400));
-
- it('should return 200 for update saved query', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_create`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send(mockSavedQuery)
- .expect(200)
- .then(({ body }) =>
- supertest
- .put(`${SAVED_QUERY_BASE_URL}/${body.id}`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send({
- ...mockSavedQuery,
- title: 'my new title',
- })
- .expect(200)
- .then((res) => {
- expect(res.body.id).to.be(body.id);
- expect(res.body.attributes.title).to.be('my new title');
- })
- ));
-
- it('should return 404 for update non-existent saved query', () =>
- supertest
- .put(`${SAVED_QUERY_BASE_URL}/invalid_id`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send(mockSavedQuery)
- .expect(404));
-
- it('should return 200 for get saved query', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_create`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send(mockSavedQuery)
- .expect(200)
- .then(({ body }) =>
- supertest
- .get(`${SAVED_QUERY_BASE_URL}/${body.id}`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(200)
- .then((res) => {
- expect(res.body.id).to.be(body.id);
- expect(res.body.attributes.title).to.be(body.attributes.title);
- })
- ));
-
- it('should return 404 for get non-existent saved query', () =>
- supertest
- .get(`${SAVED_QUERY_BASE_URL}/invalid_id`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(404));
-
- it('should return 200 for saved query count', () =>
- supertest
- .get(`${SAVED_QUERY_BASE_URL}/_count`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(200));
-
- it('should return 200 for find saved queries', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_find`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send({})
- .expect(200));
-
- it('should return 400 for bad find saved queries request', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_find`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send({ foo: 'bar' })
- .expect(400));
-
- it('should return 200 for find all saved queries', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_all`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(200));
-
- it('should return 200 for delete saved query', () =>
- supertest
- .post(`${SAVED_QUERY_BASE_URL}/_create`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .send(mockSavedQuery)
- .expect(200)
- .then(({ body }) =>
- supertest
- .delete(`${SAVED_QUERY_BASE_URL}/${body.id}`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(200)
- ));
-
- it('should return 404 for get non-existent saved query', () =>
- supertest
- .delete(`${SAVED_QUERY_BASE_URL}/invalid_id`)
- .set(ELASTIC_HTTP_VERSION_HEADER, '1')
- .expect(404));
- });
-}
diff --git a/test/api_integration/apis/saved_queries/saved_queries.ts b/test/api_integration/apis/saved_queries/saved_queries.ts
new file mode 100644
index 0000000000000..3134ab6b80fdb
--- /dev/null
+++ b/test/api_integration/apis/saved_queries/saved_queries.ts
@@ -0,0 +1,426 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import expect from '@kbn/expect';
+import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
+import { SavedQueryAttributes, SAVED_QUERY_BASE_URL } from '@kbn/data-plugin/common';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+// node scripts/functional_tests --config test/api_integration/config.js --grep="search session"
+
+const mockSavedQuery: SavedQueryAttributes = {
+ title: 'my title',
+ description: 'my description',
+ query: {
+ query: 'foo: bar',
+ language: 'kql',
+ },
+ filters: [],
+};
+
+export default function ({ getService }: FtrProviderContext) {
+ const esArchiver = getService('esArchiver');
+ const supertest = getService('supertest');
+ const kibanaServer = getService('kibanaServer');
+
+ const createQuery = (query: Partial = mockSavedQuery) =>
+ supertest
+ .post(`${SAVED_QUERY_BASE_URL}/_create`)
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .send(query);
+
+ const updateQuery = (id: string, query: Partial = mockSavedQuery) =>
+ supertest
+ .put(`${SAVED_QUERY_BASE_URL}/${id}`)
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .send(query);
+
+ const deleteQuery = (id: string) =>
+ supertest.delete(`${SAVED_QUERY_BASE_URL}/${id}`).set(ELASTIC_HTTP_VERSION_HEADER, '1');
+
+ const getQuery = (id: string) =>
+ supertest.get(`${SAVED_QUERY_BASE_URL}/${id}`).set(ELASTIC_HTTP_VERSION_HEADER, '1');
+
+ const findQueries = (options: { search?: string; perPage?: number; page?: number } = {}) =>
+ supertest
+ .post(`${SAVED_QUERY_BASE_URL}/_find`)
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .send(options);
+
+ const countQueries = () =>
+ supertest.get(`${SAVED_QUERY_BASE_URL}/_count`).set(ELASTIC_HTTP_VERSION_HEADER, '1');
+
+ const isDuplicateTitle = (title: string, id?: string) =>
+ supertest
+ .post(`${SAVED_QUERY_BASE_URL}/_is_duplicate_title`)
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
+ .send({ title, id });
+
+ describe('Saved queries API', function () {
+ before(async () => {
+ await esArchiver.emptyKibanaIndex();
+ await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
+ });
+
+ after(async () => {
+ await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
+ });
+
+ afterEach(async () => {
+ await kibanaServer.savedObjects.clean({ types: ['query'] });
+ });
+
+ describe('create', () => {
+ it('should return 200 for create saved query', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) => {
+ expect(body.id).to.have.length(36);
+ expect(body.attributes.title).to.be('my title');
+ expect(body.attributes.description).to.be('my description');
+ }));
+
+ it('should return 400 for create invalid saved query', () =>
+ createQuery({ description: 'my description' })
+ .expect(400)
+ .then(({ body }) => {
+ expect(body.message).to.be(
+ '[request body.title]: expected value of type [string] but got [undefined]'
+ );
+ }));
+
+ it('should return 400 for create saved query with duplicate title', () =>
+ createQuery()
+ .expect(200)
+ .then(() =>
+ createQuery()
+ .expect(400)
+ .then(({ body }) => {
+ expect(body.message).to.be('Query with title "my title" already exists');
+ })
+ ));
+
+ it('should leave filters and timefilter undefined if not provided', () =>
+ createQuery({ ...mockSavedQuery, filters: undefined, timefilter: undefined })
+ .expect(200)
+ .then(({ body }) =>
+ getQuery(body.id)
+ .expect(200)
+ .then(({ body: body2 }) => {
+ expect(body.attributes.filters).to.be(undefined);
+ expect(body.attributes.timefilter).to.be(undefined);
+ expect(body2.attributes.filters).to.be(undefined);
+ expect(body2.attributes.timefilter).to.be(undefined);
+ })
+ ));
+ });
+
+ describe('update', () => {
+ it('should return 200 for update saved query', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) =>
+ updateQuery(body.id, {
+ ...mockSavedQuery,
+ title: 'my updated title',
+ })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.id).to.be(body.id);
+ expect(res.body.attributes.title).to.be('my updated title');
+ })
+ ));
+
+ it('should return 404 for update non-existent saved query', () =>
+ updateQuery('invalid_id').expect(404));
+
+ it('should return 400 for update saved query with duplicate title', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) =>
+ createQuery({ ...mockSavedQuery, title: 'my duplicate title' })
+ .expect(200)
+ .then(() =>
+ updateQuery(body.id, { ...mockSavedQuery, title: 'my duplicate title' })
+ .expect(400)
+ .then(({ body: body2 }) => {
+ expect(body2.message).to.be(
+ 'Query with title "my duplicate title" already exists'
+ );
+ })
+ )
+ ));
+
+ it('should remove filters and timefilter if not provided', () =>
+ createQuery({
+ ...mockSavedQuery,
+ filters: [{ meta: {}, query: {} }],
+ timefilter: {
+ from: 'now-7d',
+ to: 'now',
+ refreshInterval: {
+ pause: false,
+ value: 60000,
+ },
+ },
+ })
+ .expect(200)
+ .then(({ body }) =>
+ updateQuery(body.id, {
+ ...mockSavedQuery,
+ filters: undefined,
+ timefilter: undefined,
+ })
+ .expect(200)
+ .then(({ body: body2 }) =>
+ getQuery(body2.id)
+ .expect(200)
+ .then(({ body: body3 }) => {
+ expect(body.attributes.filters).not.to.be(undefined);
+ expect(body.attributes.timefilter).not.to.be(undefined);
+ expect(body2.attributes.filters).to.be(undefined);
+ expect(body2.attributes.timefilter).to.be(undefined);
+ expect(body3.attributes.filters).to.be(undefined);
+ expect(body3.attributes.timefilter).to.be(undefined);
+ })
+ )
+ ));
+ });
+
+ describe('delete', () => {
+ it('should return 200 for delete saved query', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) => deleteQuery(body.id).expect(200)));
+
+ it('should return 404 for delete non-existent saved query', () =>
+ deleteQuery('invalid_id').expect(404));
+ });
+
+ describe('get', () => {
+ it('should return 200 for get saved query', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) =>
+ getQuery(body.id)
+ .expect(200)
+ .then((res) => {
+ expect(res.body.id).to.be(body.id);
+ expect(res.body.attributes.title).to.be(body.attributes.title);
+ })
+ ));
+
+ it('should return 404 for get non-existent saved query', () =>
+ getQuery('invalid_id').expect(404));
+ });
+
+ describe('find', () => {
+ it('should return 200 for find saved queries', () => findQueries().expect(200));
+
+ it('should return 400 for bad find saved queries request', () =>
+ findQueries({ foo: 'bar' } as any)
+ .expect(400)
+ .then(({ body }) => {
+ expect(body.message).to.be('[request body.foo]: definition for this key is missing');
+ }));
+
+ it('should return expected queries for find saved queries', async () => {
+ await createQuery().expect(200);
+
+ const result = await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200);
+
+ await findQueries()
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(2);
+ expect(res.body.savedQueries.length).to.be(2);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title',
+ 'my title 2',
+ ]);
+ });
+
+ await deleteQuery(result.body.id).expect(200);
+
+ await findQueries()
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(1);
+ expect(res.body.savedQueries.length).to.be(1);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql(['my title']);
+ });
+ });
+
+ it('should return expected queries for find saved queries with a search', async () => {
+ await createQuery().expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200);
+
+ const result = await createQuery({ ...mockSavedQuery, title: 'my title 2 again' }).expect(
+ 200
+ );
+
+ await findQueries({ search: 'itle 2' })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(2);
+ expect(res.body.savedQueries.length).to.be(2);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title 2',
+ 'my title 2 again',
+ ]);
+ });
+
+ await deleteQuery(result.body.id).expect(200);
+
+ await findQueries({ search: 'itle 2' })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(1);
+ expect(res.body.savedQueries.length).to.be(1);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title 2',
+ ]);
+ });
+ });
+
+ it('should support pagination for find saved queries', async () => {
+ await createQuery().expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'my title 3' }).expect(200);
+
+ await findQueries({ perPage: 2 })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(3);
+ expect(res.body.savedQueries.length).to.be(2);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title',
+ 'my title 2',
+ ]);
+ });
+
+ await findQueries({ perPage: 2, page: 2 })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(3);
+ expect(res.body.savedQueries.length).to.be(1);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title 3',
+ ]);
+ });
+ });
+
+ it('should support pagination for find saved queries with a search', async () => {
+ await createQuery().expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'my title 3' }).expect(200);
+ await createQuery({ ...mockSavedQuery, title: 'not a match' }).expect(200);
+
+ await findQueries({ perPage: 2, search: 'itle' })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(3);
+ expect(res.body.savedQueries.length).to.be(2);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title',
+ 'my title 2',
+ ]);
+ });
+
+ await findQueries({ perPage: 2, page: 2, search: 'itle' })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(3);
+ expect(res.body.savedQueries.length).to.be(1);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'my title 3',
+ ]);
+ });
+ });
+
+ it('should support searching for queries containing special characters', async () => {
+ await createQuery({ ...mockSavedQuery, title: 'query <> title' }).expect(200);
+
+ await findQueries({ search: 'ry <> ti' })
+ .expect(200)
+ .then((res) => {
+ expect(res.body.total).to.be(1);
+ expect(res.body.savedQueries.length).to.be(1);
+ expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([
+ 'query <> title',
+ ]);
+ });
+ });
+ });
+
+ describe('count', () => {
+ it('should return 200 for saved query count', () => countQueries().expect(200));
+
+ it('should return expected counts for saved query count', async () => {
+ await countQueries()
+ .expect(200)
+ .then((res) => {
+ expect(res.text).to.be('0');
+ });
+
+ await createQuery().expect(200);
+
+ const result = await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200);
+
+ await countQueries()
+ .expect(200)
+ .then((res) => {
+ expect(res.text).to.be('2');
+ });
+
+ await deleteQuery(result.body.id).expect(200);
+
+ await countQueries()
+ .expect(200)
+ .then((res) => {
+ expect(res.text).to.be('1');
+ });
+ });
+ });
+
+ describe('isDuplicateTitle', () => {
+ it('should return isDuplicate = true for _is_duplicate_title check with a duplicate title', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) =>
+ isDuplicateTitle(body.attributes.title)
+ .expect(200)
+ .then(({ body: body2 }) => {
+ expect(body2.isDuplicate).to.be(true);
+ })
+ ));
+
+ it('should return isDuplicate = false for _is_duplicate_title check with a duplicate title and matching ID', () =>
+ createQuery()
+ .expect(200)
+ .then(({ body }) =>
+ isDuplicateTitle(body.attributes.title, body.id)
+ .expect(200)
+ .then(({ body: body2 }) => {
+ expect(body2.isDuplicate).to.be(false);
+ })
+ ));
+
+ it('should return isDuplicate = false for _is_duplicate_title check with a unique title', () =>
+ createQuery()
+ .expect(200)
+ .then(() =>
+ isDuplicateTitle('my unique title')
+ .expect(200)
+ .then(({ body }) => {
+ expect(body.isDuplicate).to.be(false);
+ })
+ ));
+ });
+ });
+}
diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts
index 658e235c77d33..1d81faaf8a7fd 100644
--- a/test/functional/page_objects/discover_page.ts
+++ b/test/functional/page_objects/discover_page.ts
@@ -641,11 +641,17 @@ export class DiscoverPageObject extends FtrService {
}
public async saveCurrentSavedQuery() {
- await this.testSubjects.click('savedQueryFormSaveButton');
+ await this.testSubjects.existOrFail('savedQueryFormSaveButton');
+ await this.retry.try(async () => {
+ if (await this.testSubjects.exists('savedQueryFormSaveButton')) {
+ await this.testSubjects.click('savedQueryFormSaveButton');
+ }
+ await this.testSubjects.missingOrFail('queryBarMenuPanel');
+ });
}
public async deleteSavedQuery() {
- await this.testSubjects.click('delete-saved-query-TEST-button');
+ await this.testSubjects.click('delete-saved-query-button');
}
public async confirmDeletionOfSavedQuery() {
diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts
index 7822ed8f77a89..fed8a2e66f601 100644
--- a/test/functional/services/saved_query_management_component.ts
+++ b/test/functional/services/saved_query_management_component.ts
@@ -87,6 +87,9 @@ export class SavedQueryManagementComponentService extends FtrService {
await this.testSubjects.click('saved-query-management-load-button');
await this.testSubjects.click(`~load-saved-query-${title}-button`);
await this.testSubjects.click('saved-query-management-apply-changes-button');
+ await this.retry.try(async () => {
+ await this.testSubjects.missingOrFail('queryBarMenuPanel');
+ });
await this.retry.try(async () => {
await this.openSavedQueryManagementComponent();
const selectedSavedQueryText = await this.testSubjects.getVisibleText('savedQueryTitle');
@@ -105,7 +108,7 @@ export class SavedQueryManagementComponentService extends FtrService {
}
await this.testSubjects.click(`~load-saved-query-${title}-button`);
await this.retry.waitFor('delete saved query', async () => {
- await this.testSubjects.click(`delete-saved-query-${title}-button`);
+ await this.testSubjects.click(`delete-saved-query-button`);
const exists = await this.testSubjects.exists('confirmModalTitleText');
return exists === true;
});
@@ -149,6 +152,9 @@ export class SavedQueryManagementComponentService extends FtrService {
}
await this.testSubjects.click('savedQueryFormSaveButton');
+ await this.retry.try(async () => {
+ await this.testSubjects.missingOrFail('saveQueryForm');
+ });
}
async savedQueryExist(title: string) {
@@ -160,8 +166,8 @@ export class SavedQueryManagementComponentService extends FtrService {
}
async savedQueryExistOrFail(title: string) {
- await this.openSavedQueryManagementComponent();
await this.retry.waitFor('load saved query', async () => {
+ await this.openSavedQueryManagementComponent();
const shouldClickLoadMenu = await this.testSubjects.exists(
'saved-query-management-load-button'
);
diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
index 67f35ef8c99f9..0186804edc814 100644
--- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { isEqual } from 'lodash';
+import { cloneDeep, isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { isOfAggregateQueryType } from '@kbn/es-query';
@@ -856,7 +856,12 @@ export const LensTopNavMenu = ({
const onSavedQueryUpdatedWrapped = useCallback(
(newSavedQuery) => {
- const savedQueryFilters = newSavedQuery.attributes.filters || [];
+ // If the user tries to load the same saved query that is already loaded,
+ // we will receive the same object reference which was previously frozen
+ // by Redux Toolkit. `filterManager.setFilters` will then try to modify
+ // the query's filters, which will throw an error. To avoid this, we need
+ // to clone the filters before passing them to `filterManager.setFilters`.
+ const savedQueryFilters = cloneDeep(newSavedQuery.attributes.filters || []);
const globalFilters = data.query.filterManager.getGlobalFilters();
data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
dispatchSetState({
diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts
index b2cda32060382..8525f40d47d19 100644
--- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts
+++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts
@@ -146,18 +146,17 @@ export const createStartServicesMock = (
...data.query,
savedQueries: {
...data.query.savedQueries,
- getAllSavedQueries: jest.fn(() =>
- Promise.resolve({
- id: '123',
- attributes: {
- total: 123,
- },
- })
- ),
findSavedQueries: jest.fn(() =>
Promise.resolve({
total: 123,
- queries: [],
+ queries: [
+ {
+ id: '123',
+ attributes: {
+ total: 123,
+ },
+ },
+ ],
})
),
},
diff --git a/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx b/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx
index 37360284b6aa7..57e1dee846c0a 100644
--- a/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx
+++ b/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx
@@ -71,18 +71,17 @@ const dataServiceMock = {
...data.query,
savedQueries: {
...data.query.savedQueries,
- getAllSavedQueries: jest.fn(() =>
- Promise.resolve({
- id: '123',
- attributes: {
- total: 123,
- },
- })
- ),
findSavedQueries: jest.fn(() =>
Promise.resolve({
total: 123,
- queries: [],
+ queries: [
+ {
+ id: '123',
+ attributes: {
+ total: 123,
+ },
+ },
+ ],
})
),
},
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json
index 9f2a66925c226..de90e90f58e0a 100644
--- a/x-pack/plugins/translations/translations/fr-FR.json
+++ b/x-pack/plugins/translations/translations/fr-FR.json
@@ -6200,19 +6200,16 @@
"unifiedSearch.queryBarTopRow.submitButton.run": "Exécuter la requête",
"unifiedSearch.queryBarTopRow.submitButton.update": "Nécessite une mise à jour",
"unifiedSearch.search.searchBar.savedQueryDescriptionText": "Enregistrez le texte et les filtres de la requête que vous souhaitez réutiliser.",
- "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "Ce nom est en conflit avec une requête existante",
"unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "Un nom est requis.",
"unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "Enregistrer la requête",
"unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "Inclure les filtres",
"unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "Inclure le filtre temporel",
"unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "Cette requête enregistrée est partagée sur plusieurs espaces. Si vous la supprimez, elle disparaît de tous les espaces où elle est partagée. Vous ne pouvez pas annuler cette action.",
- "unifiedSearch.search.searchBar.savedQueryNameHelpText": "Le nom ne peut pas contenir d'espace au début ni à la fin, et il doit être unique.",
"unifiedSearch.search.searchBar.savedQueryNameLabelText": "Nom",
"unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "Aucune requête enregistrée.",
"unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "Charger la requête",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "Annuler",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "Supprimer",
- "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "Gérer les objets enregistrés",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "Enregistrer en tant que nouvelle requête",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "Enregistrer en tant que nouvelle",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "Mettre à jour la recherche",
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 248e4fddb7f3c..deb0b7bceb6d1 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -6215,19 +6215,16 @@
"unifiedSearch.queryBarTopRow.submitButton.run": "クエリを実行",
"unifiedSearch.queryBarTopRow.submitButton.update": "更新が必要です",
"unifiedSearch.search.searchBar.savedQueryDescriptionText": "再度使用するクエリテキストとフィルターを保存します。",
- "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "名前が既存のクエリと競合しています",
"unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "名前が必要です。",
"unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "クエリを保存",
"unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "フィルターを含める",
"unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "時間フィルターを含める",
"unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "この保存されたクエリは複数のスペースで共有されます。削除すると、それが共有されているすべてのスペースから削除されます。この操作は元に戻すことができません。",
- "unifiedSearch.search.searchBar.savedQueryNameHelpText": "名前の始めと終わりにはスペースを使用できません。名前は一意でなければなりません。",
"unifiedSearch.search.searchBar.savedQueryNameLabelText": "名前",
"unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "保存されたクエリがありません。",
"unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "クエリを読み込む",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "キャンセル",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "削除",
- "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "保存されたオブジェクトを管理",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "新しいクエリとして保存",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "新規保存",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "クエリの更新",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 6c23e8d2f26a3..2393a8e4391db 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -6308,19 +6308,16 @@
"unifiedSearch.queryBarTopRow.submitButton.run": "运行查询",
"unifiedSearch.queryBarTopRow.submitButton.update": "需要更新",
"unifiedSearch.search.searchBar.savedQueryDescriptionText": "保存想要再次使用的查询文本和筛选。",
- "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "名称与现有查询有冲突",
"unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "“名称”必填。",
"unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "保存查询",
"unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "包括筛选",
"unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "包括时间筛选",
"unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "此已保存查询将在多个工作区中共享。如果将其删除,则会从进行共享的每个工作区中删除该项。此操作无法撤消。",
- "unifiedSearch.search.searchBar.savedQueryNameHelpText": "名称不能包含前导或尾随空格,并且必须唯一。",
"unifiedSearch.search.searchBar.savedQueryNameLabelText": "名称",
"unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "无已保存查询。",
"unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "加载查询",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "取消",
"unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "删除",
- "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "管理已保存对象",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "另存为新查询",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "另存为新的",
"unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "更新查询",
diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts
index 0ec356e83727c..2105a9b57d9b9 100644
--- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts
@@ -45,7 +45,7 @@ export const deleteSavedQueries = () => {
const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`;
rootRequest({
method: 'POST',
- url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`,
+ url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`,
body: {
query: {
bool: {
From b965b4c281bc5aa756b92006b4168419d74be3ff Mon Sep 17 00:00:00 2001
From: Ash <1849116+ashokaditya@users.noreply.github.com>
Date: Mon, 12 Feb 2024 18:21:01 +0100
Subject: [PATCH 09/83] [Security Solution][Endpoint] Add beta badge to
sentinel one connector cards/flyout and responder/isolation action flyouts
(#176228)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Replaces Technical Preview badge with a Beta badge for Sentinel One
connector cards and connector form flyouts.
Additionally it shows Beta badge on Responder for sentinel one alerts as
well as Isolate/Release action flyouts.
### TODO/DONE
- [x] Update beta badge tooltip text on all used instances
- [x] beta badges on alerts flyout/responder behind feature flag
### Screens
### Connectors list flyout
### Sentinel one connector forms
#### Create
#### Edit
#### Isolate/Release flyout forms for SentinelOne
![Screenshot 2024-02-07 at 1 03
32 PM](https://github.com/elastic/kibana/assets/1849116/92f238e7-4d9c-45aa-8b73-40fdc24dea43)
![Screenshot 2024-02-07 at 1 03
13 PM](https://github.com/elastic/kibana/assets/1849116/ec0b5afc-88f9-424b-992c-5d65076d2490)
#### Response Console for SentinelOne
![Screenshot 2024-02-07 at 11 51
22 AM](https://github.com/elastic/kibana/assets/1849116/4b5b9a52-5493-4ae2-8f77-65bed1a7d182)
### Checklist
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.../public/common/translations.ts | 5 ++
.../isolate_host/header.test.tsx | 61 ++++++++++++++++---
.../document_details/isolate_host/header.tsx | 45 +++++++++-----
.../hooks/use_with_show_responder.tsx | 22 ++++++-
.../plugins/security_solution/tsconfig.json | 2 +-
.../common/experimental_features.ts | 4 ++
.../public/connector_types/index.ts | 9 ++-
.../sentinelone/sentinelone.ts | 10 ++-
.../action_type_menu.tsx | 21 ++++---
.../beta_badge_props.tsx | 10 +++
.../create_connector_flyout/header.tsx | 37 +++++++----
.../create_connector_flyout/index.test.tsx | 16 ++---
.../create_connector_flyout/index.tsx | 3 +-
.../edit_connector_flyout/header.tsx | 44 ++++++++-----
.../edit_connector_flyout/index.test.tsx | 12 ++--
.../edit_connector_flyout/index.tsx | 5 +-
.../triggers_actions_ui/public/types.ts | 59 ++++++++++--------
17 files changed, 259 insertions(+), 106 deletions(-)
diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts
index 676546b63b224..f3f17b8eb81a5 100644
--- a/x-pack/plugins/security_solution/public/common/translations.ts
+++ b/x-pack/plugins/security_solution/public/common/translations.ts
@@ -19,6 +19,11 @@ export const BETA = i18n.translate('xpack.securitySolution.pages.common.beta', {
defaultMessage: 'Beta',
});
+export const BETA_TOOLTIP = i18n.translate('xpack.securitySolution.pages.common.beta.tooltip', {
+ defaultMessage:
+ 'This functionality is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.',
+});
+
export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) =>
i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailed', {
values: { conflicts },
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.test.tsx
index fa4b57a4313fa..6b147f89261e3 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.test.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.test.tsx
@@ -9,11 +9,18 @@ import React from 'react';
import { render } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { useIsolateHostPanelContext } from './context';
+import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { PanelHeader } from './header';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
+import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
+jest.mock('../../../common/hooks/use_experimental_features');
+jest.mock('../../../common/utils/sentinelone_alert_check');
jest.mock('./context');
+const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock;
+const mockIsAlertFromSentinelOneEvent = isAlertFromSentinelOneEvent as jest.Mock;
+
const renderPanelHeader = () =>
render(
@@ -22,21 +29,57 @@ const renderPanelHeader = () =>
);
describe('', () => {
- (useIsolateHostPanelContext as jest.Mock).mockReturnValue({ isolateAction: 'isolateHost' });
+ beforeEach(() => {
+ mockUseIsExperimentalFeatureEnabled.mockReturnValue(false);
+ });
+
+ it.each([
+ {
+ isolateAction: 'isolateHost',
+ title: 'Isolate host',
+ },
+ {
+ isolateAction: 'unisolateHost',
+ title: 'Release host',
+ },
+ ])('should display release host message', ({ isolateAction, title }) => {
+ (useIsolateHostPanelContext as jest.Mock).mockReturnValue({ isolateAction });
- it('should display isolate host message', () => {
const { getByTestId } = renderPanelHeader();
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
- expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('Isolate host');
+ expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent(title);
});
- it('should display release host message', () => {
- (useIsolateHostPanelContext as jest.Mock).mockReturnValue({ isolateAction: 'unisolateHost' });
+ it.each(['isolateHost', 'unisolateHost'])(
+ 'should display beta badge on %s host message for SentinelOne alerts',
+ (action) => {
+ (useIsolateHostPanelContext as jest.Mock).mockReturnValue({
+ isolateAction: action,
+ });
+ mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
+ mockIsAlertFromSentinelOneEvent.mockReturnValue(true);
- const { getByTestId } = renderPanelHeader();
+ const { getByTestId } = renderPanelHeader();
- expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
- expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('Release host');
- });
+ expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
+ expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('Beta');
+ }
+ );
+
+ it.each(['isolateHost', 'unisolateHost'])(
+ 'should not display beta badge on %s host message for non-SentinelOne alerts',
+ (action) => {
+ (useIsolateHostPanelContext as jest.Mock).mockReturnValue({
+ isolateAction: action,
+ });
+ mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
+ mockIsAlertFromSentinelOneEvent.mockReturnValue(false);
+
+ const { getByTestId } = renderPanelHeader();
+
+ expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
+ expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).not.toHaveTextContent('Beta');
+ }
+ );
});
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx
index 77fec7da38456..da1d933a01013 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/isolate_host/header.tsx
@@ -5,10 +5,13 @@
* 2.0.
*/
-import { EuiTitle } from '@elastic/eui';
+import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type { FC } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
+import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
+import { BETA, BETA_TOOLTIP } from '../../../common/translations';
+import { isAlertFromSentinelOneEvent } from '../../../common/utils/sentinelone_alert_check';
import { useIsolateHostPanelContext } from './context';
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
import { FlyoutHeader } from '../../shared/components/flyout_header';
@@ -17,20 +20,34 @@ import { FlyoutHeader } from '../../shared/components/flyout_header';
* Document details expandable right section header for the isolate host panel
*/
export const PanelHeader: FC = () => {
- const { isolateAction } = useIsolateHostPanelContext();
+ const { isolateAction, dataFormattedForFieldBrowser: data } = useIsolateHostPanelContext();
+ const isSentinelOneAlert = isAlertFromSentinelOneEvent({ data });
+ const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
+ 'responseActionsSentinelOneV1Enabled'
+ );
- const title =
- isolateAction === 'isolateHost' ? (
-
- ) : (
-
- );
+ const title = (
+
+
+ {isolateAction === 'isolateHost' ? (
+
+ ) : (
+
+ )}
+
+ {isSentinelOneV1Enabled && isSentinelOneAlert && (
+
+
+
+ )}
+
+ );
return (
diff --git a/x-pack/plugins/security_solution/public/management/hooks/use_with_show_responder.tsx b/x-pack/plugins/security_solution/public/management/hooks/use_with_show_responder.tsx
index cb216aa7b42ad..7db2c97e82151 100644
--- a/x-pack/plugins/security_solution/public/management/hooks/use_with_show_responder.tsx
+++ b/x-pack/plugins/security_solution/public/management/hooks/use_with_show_responder.tsx
@@ -6,6 +6,8 @@
*/
import React, { useCallback } from 'react';
+import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { BETA, BETA_TOOLTIP } from '../../common/translations';
import { useLicense } from '../../common/hooks/use_license';
import type { ImmutableArray } from '../../../common/endpoint/types';
import {
@@ -26,6 +28,7 @@ import {
import { useConsoleManager } from '../components/console';
import { MissingEncryptionKeyCallout } from '../components/missing_encryption_key_callout';
import { RESPONDER_PAGE_TITLE } from './translations';
+import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
type ShowResponseActionsConsole = (props: ResponderInfoProps) => void;
@@ -50,6 +53,9 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
const consoleManager = useConsoleManager();
const endpointPrivileges = useUserPrivileges().endpointPrivileges;
const isEnterpriseLicense = useLicense().isEnterprise();
+ const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled(
+ 'responseActionsSentinelOneV1Enabled'
+ );
return useCallback(
(props: ResponderInfoProps) => {
@@ -126,7 +132,19 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
hostName,
},
consoleProps,
- PageTitleComponent: () => <>{RESPONDER_PAGE_TITLE}>,
+ PageTitleComponent: () => {
+ if (isSentinelOneV1Enabled && agentType === 'sentinel_one') {
+ return (
+
+ {RESPONDER_PAGE_TITLE}
+
+
+
+
+ );
+ }
+ return <>{RESPONDER_PAGE_TITLE}>;
+ },
ActionComponents: endpointPrivileges.canReadActionsLogManagement
? [ActionLogButton]
: undefined,
@@ -140,6 +158,6 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
.show();
}
},
- [endpointPrivileges, isEnterpriseLicense, consoleManager]
+ [endpointPrivileges, isEnterpriseLicense, isSentinelOneV1Enabled, consoleManager]
);
};
diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json
index 71721668cecc6..99d6524aa3d7b 100644
--- a/x-pack/plugins/security_solution/tsconfig.json
+++ b/x-pack/plugins/security_solution/tsconfig.json
@@ -184,6 +184,6 @@
"@kbn/elastic-assistant-common",
"@kbn/core-elasticsearch-server-mocks",
"@kbn/lens-embeddable-utils",
- "@kbn/esql-utils"
+ "@kbn/esql-utils",
]
}
diff --git a/x-pack/plugins/stack_connectors/common/experimental_features.ts b/x-pack/plugins/stack_connectors/common/experimental_features.ts
index fee440e86b8d5..7bec75eaeb0a9 100644
--- a/x-pack/plugins/stack_connectors/common/experimental_features.ts
+++ b/x-pack/plugins/stack_connectors/common/experimental_features.ts
@@ -13,7 +13,11 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues;
*/
export const allowedExperimentalValues = Object.freeze({
isMustacheAutocompleteOn: false,
+ // set to true to show tech preview badge on sentinel one connector
sentinelOneConnectorOn: true,
+ // set to true to show beta badge on sentinel one connector
+ // TODO: set to true when 8.13 is ready
+ sentinelOneConnectorOnBeta: false,
});
export type ExperimentalConfigKeys = Array;
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts
index a2297dac9d6bf..12991bcc4d055 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts
+++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts
@@ -69,7 +69,14 @@ export function registerConnectorTypes({
connectorTypeRegistry.register(getTinesConnectorType());
connectorTypeRegistry.register(getD3SecurityConnectorType());
- if (ExperimentalFeaturesService.get().sentinelOneConnectorOn) {
+ // get sentinelOne connector type
+ // when either feature flag is enabled
+ if (
+ // 8.12
+ ExperimentalFeaturesService.get().sentinelOneConnectorOn ||
+ // 8.13
+ ExperimentalFeaturesService.get().sentinelOneConnectorOnBeta
+ ) {
connectorTypeRegistry.register(getSentinelOneConnectorType());
}
}
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/sentinelone/sentinelone.ts b/x-pack/plugins/stack_connectors/public/connector_types/sentinelone/sentinelone.ts
index b01fa9fbaed5d..0b92334e90268 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/sentinelone/sentinelone.ts
+++ b/x-pack/plugins/stack_connectors/public/connector_types/sentinelone/sentinelone.ts
@@ -11,15 +11,16 @@ import type {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public';
+import { getIsExperimentalFeatureEnabled } from '../../common/get_experimental_features';
import {
SENTINELONE_CONNECTOR_ID,
SENTINELONE_TITLE,
SUB_ACTION,
} from '../../../common/sentinelone/constants';
import type {
+ SentinelOneActionParams,
SentinelOneConfig,
SentinelOneSecrets,
- SentinelOneActionParams,
} from '../../../common/sentinelone/types';
interface ValidationErrors {
@@ -31,11 +32,16 @@ export function getConnectorType(): ConnectorTypeModel<
SentinelOneSecrets,
SentinelOneActionParams
> {
+ const isSentinelOneBetaBadgeEnabled = getIsExperimentalFeatureEnabled(
+ 'sentinelOneConnectorOnBeta'
+ );
+
return {
id: SENTINELONE_CONNECTOR_ID,
actionTypeTitle: SENTINELONE_TITLE,
iconClass: lazy(() => import('./logo')),
- isExperimental: true,
+ isBeta: isSentinelOneBetaBadgeEnabled ? true : undefined,
+ isExperimental: isSentinelOneBetaBadgeEnabled ? undefined : true,
selectMessage: i18n.translate(
'xpack.stackConnectors.security.sentinelone.config.selectMessageText',
{
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx
index f4717bb512a0c..e0ce5dcceec67 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx
@@ -6,9 +6,8 @@
*/
import React, { useEffect, useState } from 'react';
-import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui';
+import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiIcon, EuiSpacer, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types';
import { loadActionTypes } from '../../lib/action_connector_api';
@@ -16,7 +15,7 @@ import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { useKibana } from '../../../common/lib/kibana';
import { SectionLoading } from '../../components/section_loading';
-import { betaBadgeProps } from './beta_badge_props';
+import { betaBadgeProps, technicalPreviewBadgeProps } from './beta_badge_props';
interface Props {
onActionTypeChange: (actionType: ActionType) => void;
@@ -77,12 +76,13 @@ export const ActionTypeMenu = ({
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+
const registeredActionTypes = Object.entries(actionTypesIndex ?? [])
.filter(
([id, details]) =>
actionTypeRegistry.has(id) &&
- details.enabledInConfig === true &&
- !actionTypeRegistry.get(id).hideInUi
+ !actionTypeRegistry.get(id).hideInUi &&
+ details.enabledInConfig
)
.map(([id, actionType]) => {
const actionTypeModel = actionTypeRegistry.get(id);
@@ -91,6 +91,7 @@ export const ActionTypeMenu = ({
selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '',
actionType,
name: actionType.name,
+ isBeta: actionTypeModel.isBeta,
isExperimental: actionTypeModel.isExperimental,
};
});
@@ -101,7 +102,13 @@ export const ActionTypeMenu = ({
const checkEnabledResult = checkActionTypeEnabled(item.actionType);
const card = (
}
@@ -117,7 +124,7 @@ export const ActionTypeMenu = ({
return (
{checkEnabledResult.isEnabled && card}
- {checkEnabledResult.isEnabled === false && (
+ {!checkEnabledResult.isEnabled && (
{card}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx
index ddd8f4b26a032..3e151eb832f1e 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx
@@ -8,6 +8,16 @@
import { i18n } from '@kbn/i18n';
export const betaBadgeProps = {
+ label: i18n.translate('xpack.triggersActionsUI.betaBadgeLabel', {
+ defaultMessage: 'Beta',
+ }),
+ tooltipContent: i18n.translate('xpack.triggersActionsUI.betaBadgeDescription', {
+ defaultMessage:
+ 'This functionality is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.',
+ }),
+};
+
+export const technicalPreviewBadgeProps = {
label: i18n.translate('xpack.triggersActionsUI.technicalPreviewBadgeLabel', {
defaultMessage: 'Technical preview',
}),
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx
index 2c12431dc12cb..428740e88f66e 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx
@@ -8,18 +8,18 @@
import React, { memo } from 'react';
import {
EuiBadge,
- EuiTitle,
+ EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
+ EuiFlyoutHeader,
EuiIcon,
+ EuiSpacer,
EuiText,
- EuiFlyoutHeader,
+ EuiTitle,
IconType,
- EuiSpacer,
- EuiBetaBadge,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
-import { betaBadgeProps } from '../beta_badge_props';
+import { betaBadgeProps, technicalPreviewBadgeProps } from '../beta_badge_props';
interface Props {
icon?: IconType | null;
@@ -27,6 +27,7 @@ interface Props {
actionTypeMessage?: string | null;
compatibility?: string[] | null;
isExperimental?: boolean;
+ isBeta?: boolean;
}
const FlyoutHeaderComponent: React.FC = ({
@@ -35,6 +36,7 @@ const FlyoutHeaderComponent: React.FC = ({
actionTypeMessage,
compatibility,
isExperimental,
+ isBeta,
}) => {
return (
@@ -61,14 +63,23 @@ const FlyoutHeaderComponent: React.FC = ({
- {actionTypeName && isExperimental && (
-
-
-
- )}
+ {actionTypeName
+ ? isBeta && (
+
+
+
+ )
+ : isExperimental && (
+
+
+
+ )}
{actionTypeMessage}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx
index 554fb9aff3c30..8c6454f9427af 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx
@@ -9,9 +9,9 @@ import React, { lazy } from 'react';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import userEvent from '@testing-library/user-event';
-import { waitFor, act } from '@testing-library/react';
+import { act, waitFor } from '@testing-library/react';
import CreateConnectorFlyout from '.';
-import { betaBadgeProps } from '../beta_badge_props';
+import { technicalPreviewBadgeProps } from '../beta_badge_props';
import { AppMockRenderer, createAppMockRenderer } from '../../test_utils';
jest.mock('../../../lib/action_connector_api', () => ({
@@ -392,7 +392,7 @@ describe('CreateConnectorFlyout', () => {
expect(getByText(`selectMessage-${actionTypeModel.id}`)).toBeInTheDocument();
});
- it('does not show beta badge when isExperimental is undefined', async () => {
+ it('does not show tech preview badge when isExperimental is undefined', async () => {
const { queryByText } = appMockRenderer.render(
{
/>
);
await act(() => Promise.resolve());
- expect(queryByText(betaBadgeProps.label)).not.toBeInTheDocument();
+ expect(queryByText(technicalPreviewBadgeProps.label)).not.toBeInTheDocument();
});
- it('does not show beta badge when isExperimental is false', async () => {
+ it('does not show tech preview badge when isExperimental is false', async () => {
actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isExperimental: false });
const { queryByText } = appMockRenderer.render(
{
/>
);
await act(() => Promise.resolve());
- expect(queryByText(betaBadgeProps.label)).not.toBeInTheDocument();
+ expect(queryByText(technicalPreviewBadgeProps.label)).not.toBeInTheDocument();
});
- it('shows beta badge when isExperimental is true', async () => {
+ it('shows tech preview badge when isExperimental is true', async () => {
actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isExperimental: true });
const { getByText } = appMockRenderer.render(
{
/>
);
await act(() => Promise.resolve());
- expect(getByText(betaBadgeProps.label)).toBeInTheDocument();
+ expect(getByText(technicalPreviewBadgeProps.label)).toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx
index 5cf6f6f8de69b..c0c6941e68410 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx
@@ -21,8 +21,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import {
ActionConnector,
ActionType,
- ActionTypeModel,
ActionTypeIndex,
+ ActionTypeModel,
ActionTypeRegistryContract,
} from '../../../../types';
import { hasSaveActionsCapability } from '../../../lib/capabilities';
@@ -211,6 +211,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({
actionTypeMessage={actionTypeModel?.selectMessage}
compatibility={getConnectorCompatibility(actionType?.supportedFeatureIds)}
isExperimental={actionTypeModel?.isExperimental}
+ isBeta={actionTypeModel?.isBeta}
/>
: null}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/header.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/header.tsx
index f60b791df9773..d23322042a610 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/header.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/header.tsx
@@ -8,26 +8,27 @@
import React, { memo } from 'react';
import { css } from '@emotion/react';
import {
- EuiTitle,
+ EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
- EuiIcon,
- EuiText,
EuiFlyoutHeader,
- IconType,
- EuiBetaBadge,
+ EuiIcon,
EuiTab,
EuiTabs,
+ EuiText,
+ EuiTitle,
+ IconType,
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
-import { betaBadgeProps } from '../beta_badge_props';
+import { betaBadgeProps, technicalPreviewBadgeProps } from '../beta_badge_props';
import { EditConnectorTabs } from '../../../../types';
import { useKibana } from '../../../../common/lib/kibana';
import { hasExecuteActionsCapability } from '../../../lib/capabilities';
const FlyoutHeaderComponent: React.FC<{
+ isBeta?: boolean;
isExperimental?: boolean;
isPreconfigured: boolean;
connectorName: string;
@@ -38,6 +39,7 @@ const FlyoutHeaderComponent: React.FC<{
icon?: IconType | null;
}> = ({
icon,
+ isBeta = false,
isExperimental = false,
isPreconfigured,
connectorName,
@@ -89,11 +91,18 @@ const FlyoutHeaderComponent: React.FC<{
/>
- {isExperimental && (
+ {isBeta ? (
+ ) : (
+ isExperimental && (
+
+ )
)}
@@ -117,13 +126,20 @@ const FlyoutHeaderComponent: React.FC<{
- {isExperimental && (
-
-
-
+ {isBeta ? (
+
+ ) : (
+ isExperimental && (
+
+
+
+ )
)}
)}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx
index 8570250e0d387..001cab3fc0720 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx
@@ -9,10 +9,10 @@ import React, { lazy } from 'react';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import userEvent from '@testing-library/user-event';
-import { waitFor, act } from '@testing-library/react';
+import { act, waitFor } from '@testing-library/react';
import EditConnectorFlyout from '.';
import { ActionConnector, EditConnectorTabs, GenericValidationResult } from '../../../../types';
-import { betaBadgeProps } from '../beta_badge_props';
+import { technicalPreviewBadgeProps } from '../beta_badge_props';
import { AppMockRenderer, createAppMockRenderer } from '../../test_utils';
const updateConnectorResponse = {
@@ -311,7 +311,7 @@ describe('EditConnectorFlyout', () => {
expect(getByTestId('preconfiguredBadge')).toBeInTheDocument();
});
- it('does not show beta badge when isExperimental is false', async () => {
+ it('does not show tech preview badge when isExperimental is false', async () => {
const { queryByText } = appMockRenderer.render(
{
/>
);
await act(() => Promise.resolve());
- expect(queryByText(betaBadgeProps.label)).not.toBeInTheDocument();
+ expect(queryByText(technicalPreviewBadgeProps.label)).not.toBeInTheDocument();
});
- it('shows beta badge when isExperimental is true', async () => {
+ it('shows tech preview badge when isExperimental is true', async () => {
actionTypeRegistry.get.mockReturnValue({ ...actionTypeModel, isExperimental: true });
const { getByText } = appMockRenderer.render(
{
/>
);
await act(() => Promise.resolve());
- expect(getByText(betaBadgeProps.label)).toBeInTheDocument();
+ expect(getByText(technicalPreviewBadgeProps.label)).toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx
index b01725c489379..94e582962940b 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx
@@ -6,11 +6,11 @@
*/
import React, { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
-import { EuiFlyout, EuiFlyoutBody, EuiButton, EuiConfirmModal } from '@elastic/eui';
+import { EuiButton, EuiConfirmModal, EuiFlyout, EuiFlyoutBody } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { ActionTypeExecutorResult, isActionTypeExecutorResult } from '@kbn/actions-plugin/common';
-import { Option, none, some } from 'fp-ts/lib/Option';
+import { none, Option, some } from 'fp-ts/lib/Option';
import { ReadOnlyConnectorMessage } from './read_only';
import {
ActionConnector,
@@ -233,6 +233,7 @@ const EditConnectorFlyoutComponent: React.FC = ({
setTab={handleSetTab}
selectedTab={selectedTab}
icon={actionTypeModel?.iconClass}
+ isBeta={actionTypeModel?.isBeta}
isExperimental={actionTypeModel?.isExperimental}
/>
diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts
index ba5afb74ecfd8..a48e53d74d4af 100644
--- a/x-pack/plugins/triggers_actions_ui/public/types.ts
+++ b/x-pack/plugins/triggers_actions_ui/public/types.ts
@@ -7,54 +7,55 @@
import type { Moment } from 'moment';
import type { ComponentType, ReactNode, RefObject } from 'react';
+import React from 'react';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { DocLinksStart } from '@kbn/core/public';
+import { HttpSetup } from '@kbn/core/public';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type {
- IconType,
- RecursivePartial,
EuiDataGridCellValueElementProps,
- EuiDataGridToolBarAdditionalControlsOptions,
+ EuiDataGridColumnCellAction,
+ EuiDataGridOnColumnResizeHandler,
EuiDataGridProps,
EuiDataGridRefProps,
- EuiDataGridColumnCellAction,
+ EuiDataGridToolBarAdditionalControlsOptions,
EuiDataGridToolBarVisibilityOptions,
EuiSuperSelectOption,
- EuiDataGridOnColumnResizeHandler,
+ IconType,
+ RecursivePartial,
} from '@elastic/eui';
-import type { RuleCreationValidConsumer, ValidFeatureId } from '@kbn/rule-data-utils';
import { EuiDataGridColumn, EuiDataGridControlColumn, EuiDataGridSorting } from '@elastic/eui';
-import { HttpSetup } from '@kbn/core/public';
+import type { RuleCreationValidConsumer, ValidFeatureId } from '@kbn/rule-data-utils';
import { KueryNode } from '@kbn/es-query';
import {
ActionType,
- AlertHistoryEsIndexConnectorId,
- AlertHistoryDocumentTemplate,
ALERT_HISTORY_PREFIX,
AlertHistoryDefaultIndexName,
+ AlertHistoryDocumentTemplate,
+ AlertHistoryEsIndexConnectorId,
AsApiContract,
} from '@kbn/actions-plugin/common';
import {
ActionGroup,
- RuleActionParam,
- SanitizedRule as AlertingSanitizedRule,
- ResolvedSanitizedRule,
- RuleAction,
- RuleTaskState,
+ ActionVariable,
+ AlertingFrameworkHealth,
+ AlertStatus,
AlertSummary as RuleSummary,
ExecutionDuration,
- AlertStatus,
+ MaintenanceWindow,
RawAlertInstance,
- AlertingFrameworkHealth,
+ ResolvedSanitizedRule,
+ RuleAction,
+ RuleActionParam,
+ RuleLastRun,
RuleNotifyWhenType,
- RuleTypeParams,
+ RuleTaskState,
RuleTypeMetaData,
- ActionVariable,
- RuleLastRun,
- MaintenanceWindow,
+ RuleTypeParams,
+ SanitizedRule as AlertingSanitizedRule,
} from '@kbn/alerting-plugin/common';
import type { BulkOperationError } from '@kbn/alerting-plugin/server';
import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common';
@@ -63,7 +64,6 @@ import {
QueryDslQueryContainer,
SortCombinations,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import React from 'react';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import type { RuleType, RuleTypeIndex } from '@kbn/triggers-actions-ui-types';
import { TypeRegistry } from './application/type_registry';
@@ -72,23 +72,23 @@ import type { RuleTagFilterProps } from './application/sections/rules_list/compo
import type { RuleStatusFilterProps } from './application/sections/rules_list/components/rule_status_filter';
import type { RulesListProps } from './application/sections/rules_list/components/rules_list';
import type {
- RuleTagBadgeProps,
RuleTagBadgeOptions,
+ RuleTagBadgeProps,
} from './application/sections/rules_list/components/rule_tag_badge';
import type {
- RuleEventLogListProps,
RuleEventLogListOptions,
+ RuleEventLogListProps,
} from './application/sections/rule_details/components/rule_event_log_list';
import type { GlobalRuleEventLogListProps } from './application/sections/rule_details/components/global_rule_event_log_list';
import type { AlertSummaryTimeRange } from './application/sections/alert_summary_widget/types';
import type { CreateConnectorFlyoutProps } from './application/sections/action_connector_form/create_connector_flyout';
import type { EditConnectorFlyoutProps } from './application/sections/action_connector_form/edit_connector_flyout';
import type {
- FieldBrowserOptions,
+ BrowserFieldItem,
CreateFieldComponent,
- GetFieldTableColumns,
+ FieldBrowserOptions,
FieldBrowserProps,
- BrowserFieldItem,
+ GetFieldTableColumns,
} from './application/sections/field_browser/types';
import { RulesListVisibleColumns } from './application/sections/rules_list/components/rules_list_column_selector';
import { TimelineItem } from './application/sections/alerts_table/bulk_actions/components/toolbar';
@@ -173,11 +173,13 @@ export interface ConnectorValidationError {
}
export type ConnectorValidationFunc = () => Promise;
+
export interface ActionConnectorFieldsProps {
readOnly: boolean;
isEdit: boolean;
registerPreSubmitValidator: (validator: ConnectorValidationFunc) => void;
}
+
export interface ActionReadOnlyElementProps {
connectorId: string;
connectorName: string;
@@ -209,10 +211,12 @@ interface BulkOperationAttributesByIds {
ids: string[];
filter?: never;
}
+
interface BulkOperationAttributesByFilter {
ids?: never;
filter: KueryNode | null;
}
+
export type BulkOperationAttributesWithoutHttp =
| BulkOperationAttributesByIds
| BulkOperationAttributesByFilter;
@@ -282,6 +286,7 @@ export interface ActionTypeModel;
defaultRecoveredActionParams?: RecursivePartial;
customConnectorSelectItem?: CustomConnectorSelectionItem;
+ isBeta?: boolean;
isExperimental?: boolean;
subtype?: Array<{ id: string; name: string }>;
convertParamsBetweenGroups?: (params: ActionParams) => ActionParams | {};
@@ -471,6 +476,7 @@ export interface RuleAddProps<
useRuleProducer?: boolean;
initialSelectedConsumer?: RuleCreationValidConsumer | null;
}
+
export interface RuleDefinitionProps {
rule: Rule;
ruleTypeRegistry: RuleTypeRegistryContract;
@@ -505,6 +511,7 @@ export interface InspectQuery {
request: string[];
response: string[];
}
+
export type GetInspectQuery = () => InspectQuery;
export type Alert = EcsFieldsResponse;
From b64dc62a1c31b1285ee59173e2784fd13d3e7169 Mon Sep 17 00:00:00 2001
From: Sander Philipse <94373878+sphilipse@users.noreply.github.com>
Date: Mon, 12 Feb 2024 18:26:27 +0100
Subject: [PATCH 10/83] [Search] Don't skip ahead if connector doesn't have
config items (#176725)
## Summary
This fixes an issue where connectors config would skip ahead on setting
a service type.
---
.../connectors/connector_config/connector_configuration.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_configuration.tsx b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_configuration.tsx
index aedbe9f450c7f..f4634cbfc91e7 100644
--- a/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_configuration.tsx
+++ b/x-pack/plugins/serverless_search/public/application/components/connectors/connector_config/connector_configuration.tsx
@@ -36,13 +36,14 @@ export const ConnectorConfiguration: React.FC = ({
const step =
connector.status === ConnectorStatus.CREATED
? 'link'
- : connector.status === ConnectorStatus.NEEDS_CONFIGURATION
+ : connector.status === ConnectorStatus.NEEDS_CONFIGURATION &&
+ Object.keys(connector.configuration || {}).length > 0
? 'configure'
: connector.status === ConnectorStatus.CONFIGURED
? 'connect'
: 'connected';
setCurrentStep(step);
- }, [connector.status, setCurrentStep]);
+ }, [connector.status, setCurrentStep, connector.configuration]);
const steps: EuiStepsHorizontalProps['steps'] = [
{
title: i18n.translate('xpack.serverlessSearch.connectors.config.linkToElasticTitle', {
From bc3346f078427b6ed2d32c40a6378a07fc7f2a6a Mon Sep 17 00:00:00 2001
From: Jordan <51442161+JordanSh@users.noreply.github.com>
Date: Mon, 12 Feb 2024 19:36:40 +0200
Subject: [PATCH 11/83] [Cloud Security] Evaluated column buttons (#176498)
---
.../use_benchmark_dynamic_values.test.ts | 100 ++++++++++++
.../hooks/use_benchmark_dynamic_values.ts | 149 ++++++++++++++++++
.../pages/benchmarks/benchmarks_table.tsx | 100 ++++++------
.../public/pages/rules/rules_counters.tsx | 92 ++---------
.../translations/translations/fr-FR.json | 2 +-
.../translations/translations/ja-JP.json | 2 +-
.../translations/translations/zh-CN.json | 2 +-
7 files changed, 317 insertions(+), 130 deletions(-)
create mode 100644 x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.test.ts
create mode 100644 x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.ts
diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.test.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.test.ts
new file mode 100644
index 0000000000000..6207885b60ab0
--- /dev/null
+++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.test.ts
@@ -0,0 +1,100 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useBenchmarkDynamicValues } from './use_benchmark_dynamic_values';
+import { renderHook } from '@testing-library/react-hooks/dom';
+import { useCspIntegrationLink } from '../navigation/use_csp_integration_link';
+import { BenchmarksCisId } from '../../../common/types/benchmarks/v2';
+
+jest.mock('../navigation/use_csp_integration_link');
+
+describe('useBenchmarkDynamicValues', () => {
+ const setupMocks = (cspmIntegrationLink: string, kspmIntegrationLink: string) => {
+ (useCspIntegrationLink as jest.Mock)
+ .mockReturnValueOnce(cspmIntegrationLink)
+ .mockReturnValueOnce(kspmIntegrationLink);
+ };
+
+ it('should return the correct dynamic benchmark values for each provided benchmark ID', () => {
+ setupMocks('cspm-integration-link', 'kspm-integration-link');
+ const { result } = renderHook(() => useBenchmarkDynamicValues());
+
+ const benchmarkValuesCisAws = result.current.getBenchmarkDynamicValues('cis_aws', 3);
+ expect(benchmarkValuesCisAws).toEqual({
+ integrationType: 'CSPM',
+ integrationName: 'AWS',
+ resourceName: 'Accounts',
+ resourceCountLabel: 'accounts',
+ integrationLink: 'cspm-integration-link',
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ });
+
+ const benchmarkValuesCisGcp = result.current.getBenchmarkDynamicValues('cis_gcp', 0);
+ expect(benchmarkValuesCisGcp).toEqual({
+ integrationType: 'CSPM',
+ integrationName: 'GCP',
+ resourceName: 'Projects',
+ resourceCountLabel: 'projects',
+ integrationLink: 'cspm-integration-link',
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ });
+
+ const benchmarkValuesCisAzure = result.current.getBenchmarkDynamicValues('cis_azure', 1);
+ expect(benchmarkValuesCisAzure).toEqual({
+ integrationType: 'CSPM',
+ integrationName: 'Azure',
+ resourceName: 'Subscriptions',
+ resourceCountLabel: 'subscription',
+ integrationLink: 'cspm-integration-link',
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ });
+
+ const benchmarkValuesCisK8s = result.current.getBenchmarkDynamicValues('cis_k8s', 0);
+ expect(benchmarkValuesCisK8s).toEqual({
+ integrationType: 'KSPM',
+ integrationName: 'Kubernetes',
+ resourceName: 'Clusters',
+ resourceCountLabel: 'clusters',
+ integrationLink: 'kspm-integration-link',
+ learnMoreLink: 'https://ela.st/kspm-get-started',
+ });
+
+ const benchmarkValuesCisEks = result.current.getBenchmarkDynamicValues('cis_eks');
+ expect(benchmarkValuesCisEks).toEqual({
+ integrationType: 'KSPM',
+ integrationName: 'EKS',
+ resourceName: 'Clusters',
+ resourceCountLabel: 'clusters',
+ integrationLink: 'kspm-integration-link',
+ learnMoreLink: 'https://ela.st/kspm-get-started',
+ });
+
+ const benchmarkValuesInvalid = result.current.getBenchmarkDynamicValues(
+ 'invalid_benchmark' as BenchmarksCisId
+ );
+ expect(benchmarkValuesInvalid).toEqual({});
+ });
+
+ it('should return the correct resource plurals based on the provided resource count', () => {
+ const { result } = renderHook(() => useBenchmarkDynamicValues());
+
+ const benchmarkValuesCisAws = result.current.getBenchmarkDynamicValues('cis_aws', 3);
+ expect(benchmarkValuesCisAws.resourceCountLabel).toBe('accounts');
+
+ const benchmarkValuesCisGcp = result.current.getBenchmarkDynamicValues('cis_gcp', 0);
+ expect(benchmarkValuesCisGcp.resourceCountLabel).toBe('projects');
+
+ const benchmarkValuesCisAzure = result.current.getBenchmarkDynamicValues('cis_azure', 1);
+ expect(benchmarkValuesCisAzure.resourceCountLabel).toBe('subscription');
+
+ const benchmarkValuesCisK8s = result.current.getBenchmarkDynamicValues('cis_k8s', 0);
+ expect(benchmarkValuesCisK8s.resourceCountLabel).toBe('clusters');
+
+ const benchmarkValuesCisEks = result.current.getBenchmarkDynamicValues('cis_eks');
+ expect(benchmarkValuesCisEks.resourceCountLabel).toBe('clusters');
+ });
+});
diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.ts
new file mode 100644
index 0000000000000..5fe3f8f69050a
--- /dev/null
+++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_benchmark_dynamic_values.ts
@@ -0,0 +1,149 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { useCspIntegrationLink } from '../navigation/use_csp_integration_link';
+import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '../../../common/constants';
+import { BenchmarksCisId } from '../../../common/types/benchmarks/v2';
+
+type BenchmarkDynamicNames =
+ | {
+ integrationType: 'CSPM';
+ integrationName: 'AWS';
+ resourceName: 'Accounts';
+ }
+ | {
+ integrationType: 'CSPM';
+ integrationName: 'GCP';
+ resourceName: 'Projects';
+ }
+ | {
+ integrationType: 'CSPM';
+ integrationName: 'Azure';
+ resourceName: 'Subscriptions';
+ }
+ | {
+ integrationType: 'KSPM';
+ integrationName: 'Kubernetes';
+ resourceName: 'Clusters';
+ }
+ | {
+ integrationType: 'KSPM';
+ integrationName: 'EKS';
+ resourceName: 'Clusters';
+ };
+
+export type BenchmarkDynamicValues = BenchmarkDynamicNames & {
+ resourceCountLabel: string;
+ integrationLink: string;
+ learnMoreLink: string;
+};
+
+export type GetBenchmarkDynamicValues = (
+ benchmarkId: BenchmarksCisId,
+ resourceCount?: number
+) => BenchmarkDynamicValues;
+
+export const useBenchmarkDynamicValues = () => {
+ const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE) || '';
+ const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE) || '';
+
+ /**
+ * Retrieves dynamic benchmark values based on the provided benchmark ID and resource count.
+ *
+ * @param {BenchmarksCisId} benchmarkId - The benchmark ID.
+ * @param {number} [resourceCount] - The count of resources (optional).
+ * @returns {BenchmarkDynamicValues} The dynamic benchmark values including integration details,
+ * resource name, resource count label in plurals/singular, integration link, and learn more link.
+ *
+ * @example
+ * const benchmarkValues = getBenchmarkDynamicValues('cis_aws', 3);
+ * // Returns:
+ * // {
+ * // integrationType: 'CSPM',
+ * // integrationName: 'AWS',
+ * // resourceName: 'Accounts',
+ * // resourceCountLabel: 'accounts',
+ * // integrationLink: 'cspm-integration-link',
+ * // learnMoreLink: 'https://ela.st/cspm-get-started'
+ * // }
+ */
+ const getBenchmarkDynamicValues: GetBenchmarkDynamicValues = (
+ benchmarkId: BenchmarksCisId,
+ resourceCount?: number
+ ): BenchmarkDynamicValues => {
+ switch (benchmarkId) {
+ case 'cis_aws':
+ return {
+ integrationType: 'CSPM',
+ integrationName: 'AWS',
+ resourceName: 'Accounts',
+ resourceCountLabel: i18n.translate('xpack.csp.benchmarkDynamicValues.AwsAccountPlural', {
+ defaultMessage: '{resourceCount, plural, one {account} other {accounts}}',
+ values: { resourceCount: resourceCount || 0 },
+ }),
+ integrationLink: cspmIntegrationLink,
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ };
+ case 'cis_gcp':
+ return {
+ integrationType: 'CSPM',
+ integrationName: 'GCP',
+ resourceName: 'Projects',
+ resourceCountLabel: i18n.translate('xpack.csp.benchmarkDynamicValues.GcpAccountPlural', {
+ defaultMessage: '{resourceCount, plural, one {project} other {projects}}',
+ values: { resourceCount: resourceCount || 0 },
+ }),
+ integrationLink: cspmIntegrationLink,
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ };
+ case 'cis_azure':
+ return {
+ integrationType: 'CSPM',
+ integrationName: 'Azure',
+ resourceName: 'Subscriptions',
+ resourceCountLabel: i18n.translate(
+ 'xpack.csp.benchmarkDynamicValues.AzureAccountPlural',
+ {
+ defaultMessage: '{resourceCount, plural, one {subscription} other {subscriptions}}',
+ values: { resourceCount: resourceCount || 0 },
+ }
+ ),
+ integrationLink: cspmIntegrationLink,
+ learnMoreLink: 'https://ela.st/cspm-get-started',
+ };
+ case 'cis_k8s':
+ return {
+ integrationType: 'KSPM',
+ integrationName: 'Kubernetes',
+ resourceName: 'Clusters',
+ resourceCountLabel: i18n.translate('xpack.csp.benchmarkDynamicValues.K8sAccountPlural', {
+ defaultMessage: '{resourceCount, plural, one {cluster} other {clusters}}',
+ values: { resourceCount: resourceCount || 0 },
+ }),
+ integrationLink: kspmIntegrationLink,
+ learnMoreLink: 'https://ela.st/kspm-get-started',
+ };
+ case 'cis_eks':
+ return {
+ integrationType: 'KSPM',
+ integrationName: 'EKS',
+ resourceName: 'Clusters',
+ resourceCountLabel: i18n.translate('xpack.csp.benchmarkDynamicValues.EksAccountPlural', {
+ defaultMessage: '{resourceCount, plural, one {cluster} other {clusters}}',
+ values: { resourceCount: resourceCount || 0 },
+ }),
+ integrationLink: kspmIntegrationLink,
+ learnMoreLink: 'https://ela.st/kspm-get-started',
+ };
+ default:
+ return {} as BenchmarkDynamicValues;
+ }
+ };
+
+ return { getBenchmarkDynamicValues };
+};
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
index 25690410a9780..32cb0c6f11f98 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/benchmarks/benchmarks_table.tsx
@@ -15,12 +15,14 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
+ EuiButtonEmpty,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { generatePath } from 'react-router-dom';
-import { useKibana } from '@kbn/kibana-react-plugin/public';
+import { FINDINGS_GROUPING_OPTIONS } from '../../common/constants';
+import { useNavigateFindings } from '../../common/hooks/use_navigate_findings';
import type { BenchmarkScore, Benchmark, BenchmarksCisId } from '../../../common/types/latest';
import * as TEST_SUBJ from './test_subjects';
import { isCommonError } from '../../components/cloud_posture_page';
@@ -29,6 +31,11 @@ import { ComplianceScoreBar } from '../../components/compliance_score_bar';
import { getBenchmarkCisName, getBenchmarkApplicableTo } from '../../../common/utils/helpers';
import { CISBenchmarkIcon } from '../../components/cis_benchmark_icon';
import { benchmarksNavigation } from '../../common/navigation/constants';
+import {
+ GetBenchmarkDynamicValues,
+ useBenchmarkDynamicValues,
+} from '../../common/hooks/use_benchmark_dynamic_values';
+import { useKibana } from '../../common/hooks/use_kibana';
export const ERROR_STATE_TEST_SUBJECT = 'benchmark_page_error';
@@ -62,51 +69,6 @@ const BenchmarkButtonLink = ({
);
};
-export const getBenchmarkPlurals = (benchmarkId: string, accountEvaluation: number) => {
- switch (benchmarkId) {
- case 'cis_k8s':
- return (
-
- );
- case 'cis_azure':
- return (
-
- );
- case 'cis_aws':
- return (
-
- );
- case 'cis_eks':
- return (
-
- );
- case 'cis_gcp':
- return (
-
- );
- }
-};
-
const ErrorMessageComponent = (error: { error: unknown }) => (
(
);
-const BENCHMARKS_TABLE_COLUMNS: Array> = [
+const getBenchmarkTableColumns = (
+ getBenchmarkDynamicValues: GetBenchmarkDynamicValues,
+ navToFindings: any
+): Array> => [
{
field: 'id',
name: i18n.translate('xpack.csp.benchmarks.benchmarksTable.integrationBenchmarkCisName', {
@@ -198,7 +163,40 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [
width: '17.5%',
'data-test-subj': TEST_SUBJ.BENCHMARKS_TABLE_COLUMNS.EVALUATED,
render: (benchmarkEvaluation: Benchmark['evaluation'], benchmark: Benchmark) => {
- return getBenchmarkPlurals(benchmark.id, benchmarkEvaluation);
+ const { resourceCountLabel, integrationLink } = getBenchmarkDynamicValues(
+ benchmark.id,
+ benchmarkEvaluation
+ );
+
+ if (benchmarkEvaluation === 0) {
+ return (
+
+ {i18n.translate('xpack.csp.benchmarks.benchmarksTable.addIntegrationTitle', {
+ defaultMessage: 'Add {resourceCountLabel}',
+ values: { resourceCountLabel },
+ })}
+
+ );
+ }
+
+ const isKspmBenchmark = ['cis_k8s', 'cis_eks'].includes(benchmark.id);
+ const groupByField = isKspmBenchmark
+ ? FINDINGS_GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME
+ : FINDINGS_GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME;
+
+ return (
+ {
+ navToFindings({ 'rule.benchmark.id': benchmark.id }, [groupByField]);
+ }}
+ >
+ {i18n.translate('xpack.csp.benchmarks.benchmarksTable.accountsCountTitle', {
+ defaultMessage: '{benchmarkEvaluation} {resourceCountLabel}',
+ values: { benchmarkEvaluation, resourceCountLabel },
+ })}
+
+ );
},
},
{
@@ -215,6 +213,7 @@ const BENCHMARKS_TABLE_COLUMNS: Array> = [
return (
);
+
return (
{
+ const { getBenchmarkDynamicValues } = useBenchmarkDynamicValues();
+ const navToFindings = useNavigateFindings();
+
const pagination: Pagination = {
pageIndex: Math.max(pageIndex - 1, 0),
pageSize,
@@ -261,7 +263,7 @@ export const BenchmarksTable = ({
[item.id, item.version].join('/')}
pagination={pagination}
onChange={onChange}
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx
index 9c311406fe172..ec8d4a653c222 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_counters.tsx
@@ -18,19 +18,13 @@ import { i18n } from '@kbn/i18n';
import { useParams } from 'react-router-dom';
import { Chart, Partition, PartitionLayout, Settings } from '@elastic/charts';
import { FormattedMessage } from '@kbn/i18n-react';
+import { useBenchmarkDynamicValues } from '../../common/hooks/use_benchmark_dynamic_values';
import { getPostureScorePercentage } from '../compliance_dashboard/compliance_charts/compliance_score_chart';
import { RULE_COUNTERS_TEST_SUBJ } from './test_subjects';
import noDataIllustration from '../../assets/illustrations/no_data_illustration.svg';
-import { BenchmarksCisId } from '../../../common/types/benchmarks/v2';
-import { useCspIntegrationLink } from '../../common/navigation/use_csp_integration_link';
import { useNavigateFindings } from '../../common/hooks/use_navigate_findings';
import { cloudPosturePages } from '../../common/navigation/constants';
-import {
- CSPM_POLICY_TEMPLATE,
- KSPM_POLICY_TEMPLATE,
- RULE_FAILED,
- RULE_PASSED,
-} from '../../../common/constants';
+import { RULE_FAILED, RULE_PASSED } from '../../../common/constants';
import { statusColors } from '../../common/constants';
import { useCspBenchmarkIntegrationsV2 } from '../benchmarks/use_csp_benchmark_integrations';
import { CspCounterCard } from '../../components/csp_counter_card';
@@ -98,11 +92,10 @@ export const RulesCounters = ({
setEnabledDisabledItemsFilter: (filterState: string) => void;
}) => {
const { http } = useKibana().services;
+ const { getBenchmarkDynamicValues } = useBenchmarkDynamicValues();
const rulesPageParams = useParams<{ benchmarkId: string; benchmarkVersion: string }>();
const getBenchmarks = useCspBenchmarkIntegrationsV2();
const navToFindings = useNavigateFindings();
- const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE) || '';
- const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE) || '';
const benchmarkRulesStats = getBenchmarks.data?.items.find(
(benchmark) =>
@@ -114,52 +107,7 @@ export const RulesCounters = ({
return <>>;
}
- const benchmarkDynamicValues: Record<
- BenchmarksCisId,
- {
- integrationType: string;
- integrationName: string;
- resourceName: string;
- integrationLink: string;
- learnMoreLink: string;
- }
- > = {
- cis_aws: {
- integrationType: 'CSPM',
- integrationName: 'AWS',
- resourceName: 'Accounts',
- integrationLink: cspmIntegrationLink,
- learnMoreLink: 'https://ela.st/cspm-get-started',
- },
- cis_gcp: {
- integrationType: 'CSPM',
- integrationName: 'GCP',
- resourceName: 'Projects',
- integrationLink: cspmIntegrationLink,
- learnMoreLink: 'https://ela.st/cspm-get-started',
- },
- cis_azure: {
- integrationType: 'CSPM',
- integrationName: 'Azure',
- resourceName: 'Subscriptions',
- integrationLink: cspmIntegrationLink,
- learnMoreLink: 'https://ela.st/cspm-get-started',
- },
- cis_k8s: {
- integrationType: 'KSPM',
- integrationName: 'Kubernetes',
- resourceName: 'Clusters',
- integrationLink: kspmIntegrationLink,
- learnMoreLink: 'https://ela.st/kspm-get-started',
- },
- cis_eks: {
- integrationType: 'KSPM',
- integrationName: 'EKS',
- resourceName: 'Clusters',
- integrationLink: kspmIntegrationLink,
- learnMoreLink: 'https://ela.st/kspm-get-started',
- },
- };
+ const benchmarkValues = getBenchmarkDynamicValues(benchmarkRulesStats.id);
if (benchmarkRulesStats.score.totalFindings === 0) {
return (
@@ -182,10 +130,8 @@ export const RulesCounters = ({
id="xpack.csp.rulesPage.rulesCounterEmptyState.emptyStateTitle"
defaultMessage="Add {integrationResourceName} to get started"
values={{
- integrationResourceName: `${
- benchmarkDynamicValues[benchmarkRulesStats.id].integrationName
- }
- ${benchmarkDynamicValues[benchmarkRulesStats.id].resourceName}`,
+ integrationResourceName: `${benchmarkValues.integrationName}
+ ${benchmarkValues.resourceName}`,
}}
/>
@@ -196,32 +142,23 @@ export const RulesCounters = ({
id="xpack.csp.rulesPage.rulesCounterEmptyState.emptyStateDescription"
defaultMessage="Add your {resourceName} in {integrationType} to begin detecing misconfigurations"
values={{
- resourceName:
- benchmarkDynamicValues[benchmarkRulesStats.id].resourceName.toLowerCase(),
- integrationType: benchmarkDynamicValues[benchmarkRulesStats.id].integrationType,
+ resourceName: benchmarkValues.resourceName.toLowerCase(),
+ integrationType: benchmarkValues.integrationType,
}}
/>
}
actions={[
-
+
,
-
+
{i18n.translate('xpack.csp.rulesCounters.accountsEvaluatedButton', {
defaultMessage: 'Add more {resourceName}',
values: {
- resourceName:
- benchmarkDynamicValues[benchmarkRulesStats.id].resourceName.toLowerCase(),
+ resourceName: benchmarkValues.resourceName.toLowerCase(),
},
})}
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json
index de90e90f58e0a..5c830f3ff1702 100644
--- a/x-pack/plugins/translations/translations/fr-FR.json
+++ b/x-pack/plugins/translations/translations/fr-FR.json
@@ -42811,4 +42811,4 @@
"xpack.serverlessObservability.nav.projectSettings": "Paramètres de projet",
"xpack.serverlessObservability.nav.visualizations": "Visualisations"
}
-}
\ No newline at end of file
+}
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index deb0b7bceb6d1..6f31a8d2dd91a 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -42803,4 +42803,4 @@
"xpack.serverlessObservability.nav.projectSettings": "プロジェクト設定",
"xpack.serverlessObservability.nav.visualizations": "ビジュアライゼーション"
}
-}
\ No newline at end of file
+}
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 2393a8e4391db..85e80203cc509 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -42783,4 +42783,4 @@
"xpack.serverlessObservability.nav.projectSettings": "项目设置",
"xpack.serverlessObservability.nav.visualizations": "可视化"
}
-}
\ No newline at end of file
+}
From 788745e810487a48d905e813c711891ce9e58de7 Mon Sep 17 00:00:00 2001
From: Nathan Reese
Date: Mon, 12 Feb 2024 10:40:34 -0700
Subject: [PATCH 12/83] decouple dashboard drilldown from Embeddables framework
(#176188)
part of https://github.com/elastic/kibana/issues/175138
PR decouples FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction, and
EmbeddableToDashboardDrilldown from Embeddable framework.
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../interfaces/presentation_container.ts | 16 +-
.../presentation_containers/mocks.ts | 2 +
.../presentation_publishing/index.ts | 4 +
.../interfaces/has_supported_triggers.ts | 15 ++
.../locator/get_dashboard_locator_params.ts | 33 ++-
.../create/create_dashboard.test.ts | 2 +
.../embeddable/create/create_dashboard.ts | 45 +++-
.../public/lib/triggers/triggers.ts | 3 +
.../dashboard_link_component.tsx | 8 +-
src/plugins/links/tsconfig.json | 3 +-
.../plugins/dashboard_enhanced/kibana.jsonc | 3 +-
.../drilldowns/actions/drilldown_shared.ts | 43 ++--
.../flyout_create_drilldown.test.tsx | 220 +++++++++---------
.../flyout_create_drilldown.tsx | 109 ++++-----
.../flyout_edit_drilldown.test.tsx | 200 ++++++++--------
.../flyout_edit_drilldown.tsx | 79 +++----
.../drilldowns/actions/test_helpers.ts | 49 ----
...embeddable_to_dashboard_drilldown.test.tsx | 22 +-
.../embeddable_to_dashboard_drilldown.tsx | 8 +-
.../plugins/dashboard_enhanced/tsconfig.json | 4 +-
.../public/actions/drilldown_grouping.ts | 5 +-
.../interfaces/has_dynamic_actions.ts | 20 ++
.../embeddable_enhanced/public/index.ts | 4 +
.../embeddable_enhanced/public/types.ts | 3 +
24 files changed, 463 insertions(+), 437 deletions(-)
create mode 100644 packages/presentation/presentation_publishing/interfaces/has_supported_triggers.ts
delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts
create mode 100644 x-pack/plugins/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts
diff --git a/packages/presentation/presentation_containers/interfaces/presentation_container.ts b/packages/presentation/presentation_containers/interfaces/presentation_container.ts
index a92c5af7cbd0a..b0c7c3cdb64da 100644
--- a/packages/presentation/presentation_containers/interfaces/presentation_container.ts
+++ b/packages/presentation/presentation_containers/interfaces/presentation_container.ts
@@ -27,16 +27,18 @@ export type PresentationContainer = Partial &
removePanel: (panelId: string) => void;
canRemovePanels?: () => boolean;
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise;
+ getChildIds: () => string[];
+ getChild: (childId: string) => unknown;
};
-export const apiIsPresentationContainer = (
- unknownApi: unknown | null
-): unknownApi is PresentationContainer => {
+export const apiIsPresentationContainer = (api: unknown | null): api is PresentationContainer => {
return Boolean(
- (unknownApi as PresentationContainer)?.removePanel !== undefined &&
- (unknownApi as PresentationContainer)?.registerPanelApi !== undefined &&
- (unknownApi as PresentationContainer)?.replacePanel !== undefined &&
- (unknownApi as PresentationContainer)?.addNewPanel !== undefined
+ typeof (api as PresentationContainer)?.removePanel === 'function' &&
+ typeof (api as PresentationContainer)?.registerPanelApi === 'function' &&
+ typeof (api as PresentationContainer)?.replacePanel === 'function' &&
+ typeof (api as PresentationContainer)?.addNewPanel === 'function' &&
+ typeof (api as PresentationContainer)?.getChildIds === 'function' &&
+ typeof (api as PresentationContainer)?.getChild === 'function'
);
};
diff --git a/packages/presentation/presentation_containers/mocks.ts b/packages/presentation/presentation_containers/mocks.ts
index 6d4610075c9d5..ac84c8cc5fd4b 100644
--- a/packages/presentation/presentation_containers/mocks.ts
+++ b/packages/presentation/presentation_containers/mocks.ts
@@ -17,5 +17,7 @@ export const getMockPresentationContainer = (): PresentationContainer => {
registerPanelApi: jest.fn(),
lastSavedState: new Subject(),
getLastSavedStateForChild: jest.fn(),
+ getChildIds: jest.fn(),
+ getChild: jest.fn(),
};
};
diff --git a/packages/presentation/presentation_publishing/index.ts b/packages/presentation/presentation_publishing/index.ts
index 80d2c5870efbe..10c2f644b16b1 100644
--- a/packages/presentation/presentation_publishing/index.ts
+++ b/packages/presentation/presentation_publishing/index.ts
@@ -89,6 +89,10 @@ export {
} from './interfaces/publishes_saved_object_id';
export { apiHasUniqueId, type HasUniqueId } from './interfaces/has_uuid';
export { apiHasDisableTriggers, type HasDisableTriggers } from './interfaces/has_disable_triggers';
+export {
+ apiHasSupportedTriggers,
+ type HasSupportedTriggers,
+} from './interfaces/has_supported_triggers';
export {
apiPublishesViewMode,
apiPublishesWritableViewMode,
diff --git a/packages/presentation/presentation_publishing/interfaces/has_supported_triggers.ts b/packages/presentation/presentation_publishing/interfaces/has_supported_triggers.ts
new file mode 100644
index 0000000000000..d3a04522abb87
--- /dev/null
+++ b/packages/presentation/presentation_publishing/interfaces/has_supported_triggers.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export interface HasSupportedTriggers {
+ supportedTriggers: () => string[];
+}
+
+export const apiHasSupportedTriggers = (api: unknown | null): api is HasSupportedTriggers => {
+ return Boolean(api && typeof (api as HasSupportedTriggers).supportedTriggers === 'function');
+};
diff --git a/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts
index ff078be8f62f1..0843029ac3fc0 100644
--- a/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts
+++ b/src/plugins/dashboard/public/dashboard_app/locator/get_dashboard_locator_params.ts
@@ -6,41 +6,34 @@
* Side Public License, v 1.
*/
-import { isQuery, isTimeRange } from '@kbn/data-plugin/common';
-import { Filter, isFilterPinned, Query, TimeRange } from '@kbn/es-query';
-import { EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public';
-import { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public';
-import { DashboardLocatorParams } from '../../dashboard_container';
-
-interface EmbeddableQueryInput extends EmbeddableInput {
- query?: Query;
- filters?: Filter[];
- timeRange?: TimeRange;
-}
+import { isFilterPinned, type Query } from '@kbn/es-query';
+import type { HasParentApi, PublishesLocalUnifiedSearch } from '@kbn/presentation-publishing';
+import type { DashboardDrilldownOptions } from '@kbn/presentation-util-plugin/public';
+import type { DashboardLocatorParams } from '../../dashboard_container';
export const getDashboardLocatorParamsFromEmbeddable = (
- source: IEmbeddable,
+ api: Partial>>,
options: DashboardDrilldownOptions
): Partial => {
const params: DashboardLocatorParams = {};
- const input = source.getInput();
- if (isQuery(input.query) && options.useCurrentFilters) {
- params.query = input.query;
+ const query = api.parentApi?.localQuery?.value;
+ if (query && options.useCurrentFilters) {
+ params.query = query as Query;
}
// if useCurrentDashboardDataRange is enabled, then preserve current time range
// if undefined is passed, then destination dashboard will figure out time range itself
// for brush event this time range would be overwritten
- if (isTimeRange(input.timeRange) && options.useCurrentDateRange) {
- params.timeRange = input.timeRange;
+ const timeRange = api.localTimeRange?.value ?? api.parentApi?.localTimeRange?.value;
+ if (timeRange && options.useCurrentDateRange) {
+ params.timeRange = timeRange;
}
// if useCurrentDashboardFilters enabled, then preserve all the filters (pinned, unpinned, and from controls)
// otherwise preserve only pinned
- params.filters = options.useCurrentFilters
- ? input.filters
- : input.filters?.filter((f) => isFilterPinned(f));
+ const filters = api.parentApi?.localFilters?.value ?? [];
+ params.filters = options.useCurrentFilters ? filters : filters?.filter((f) => isFilterPinned(f));
return params;
};
diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts
index 5ae88b7c3bcd3..12eebe31da173 100644
--- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts
+++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts
@@ -428,7 +428,9 @@ test('creates a control group from the control group factory and waits for it to
untilInitialized: jest.fn(),
getInput: jest.fn().mockReturnValue({}),
getInput$: jest.fn().mockReturnValue(new Observable()),
+ getOutput: jest.fn().mockReturnValue({}),
getOutput$: jest.fn().mockReturnValue(new Observable()),
+ onFiltersPublished$: new Observable(),
unsavedChanges: new BehaviorSubject(undefined),
} as unknown as ControlGroupContainer;
const mockControlGroupFactory = {
diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
index 93b849a122ce0..d7d34fa42f078 100644
--- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
+++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
@@ -23,11 +23,13 @@ import {
reactEmbeddableRegistryHasKey,
ViewMode,
} from '@kbn/embeddable-plugin/public';
-import { TimeRange } from '@kbn/es-query';
+import { compareFilters, Filter, TimeRange } from '@kbn/es-query';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { cloneDeep, identity, omit, pickBy } from 'lodash';
-import { Subject } from 'rxjs';
+import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
+import { map, distinctUntilChanged, startWith } from 'rxjs/operators';
import { v4 } from 'uuid';
+import { combineDashboardFiltersWithControlGroupFilters } from './controls/dashboard_control_group_integration';
import { DashboardContainerInput, DashboardPanelState } from '../../../../common';
import {
DEFAULT_DASHBOARD_INPUT,
@@ -462,5 +464,44 @@ export const initializeDashboard = async ({
setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500)
);
+ // --------------------------------------------------------------------------------------
+ // Set parentApi.localFilters to include dashboardContainer filters and control group filters
+ // --------------------------------------------------------------------------------------
+ untilDashboardReady().then((dashboardContainer) => {
+ if (!dashboardContainer.controlGroup) {
+ return;
+ }
+
+ function getCombinedFilters() {
+ return combineDashboardFiltersWithControlGroupFilters(
+ dashboardContainer.getInput().filters ?? [],
+ dashboardContainer.controlGroup!
+ );
+ }
+
+ const localFilters = new BehaviorSubject(getCombinedFilters());
+ dashboardContainer.localFilters = localFilters;
+
+ const inputFilters$ = dashboardContainer.getInput$().pipe(
+ startWith(dashboardContainer.getInput()),
+ map((input) => input.filters),
+ distinctUntilChanged((previous, current) => {
+ return compareFilters(previous ?? [], current ?? []);
+ })
+ );
+
+ // Can not use onFiltersPublished$ directly since it does not have an intial value and
+ // combineLatest will not emit until each observable emits at least one value
+ const controlGroupFilters$ = dashboardContainer.controlGroup.onFiltersPublished$.pipe(
+ startWith(dashboardContainer.controlGroup.getOutput().filters)
+ );
+
+ dashboardContainer.integrationSubscriptions.add(
+ combineLatest([inputFilters$, controlGroupFilters$]).subscribe(() => {
+ localFilters.next(getCombinedFilters());
+ })
+ );
+ });
+
return { input: initialDashboardInput, searchSessionId: initialSearchSessionId };
};
diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts
index e60e6a2146a7c..edefdb191e123 100644
--- a/src/plugins/embeddable/public/lib/triggers/triggers.ts
+++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts
@@ -13,6 +13,9 @@ import { Trigger, RowClickContext } from '@kbn/ui-actions-plugin/public';
import { BooleanRelation } from '@kbn/es-query';
import { IEmbeddable } from '..';
+/**
+ * @deprecated use `EmbeddableApiContext` from `@kbn/presentation-publishing`
+ */
export interface EmbeddableContext {
embeddable: T;
}
diff --git a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx
index b6bbe8fa5f68b..90bf4a03002b7 100644
--- a/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx
+++ b/src/plugins/links/public/components/dashboard_link/dashboard_link_component.tsx
@@ -22,6 +22,7 @@ import {
DashboardDrilldownOptions,
DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
} from '@kbn/presentation-util-plugin/public';
+import type { HasParentApi, PublishesLocalUnifiedSearch } from '@kbn/presentation-publishing';
import {
DASHBOARD_LINK_TYPE,
@@ -115,7 +116,12 @@ export const DashboardLinkComponent = ({
const params: DashboardLocatorParams = {
dashboardId: link.destination,
- ...getDashboardLocatorParamsFromEmbeddable(linksEmbeddable, linkOptions),
+ ...getDashboardLocatorParamsFromEmbeddable(
+ linksEmbeddable as Partial<
+ PublishesLocalUnifiedSearch & HasParentApi>
+ >,
+ linkOptions
+ ),
};
const locator = dashboardContainer.locator;
diff --git a/src/plugins/links/tsconfig.json b/src/plugins/links/tsconfig.json
index 0b4430d5acc59..3431fdfd78a65 100644
--- a/src/plugins/links/tsconfig.json
+++ b/src/plugins/links/tsconfig.json
@@ -31,7 +31,8 @@
"@kbn/usage-collection-plugin",
"@kbn/visualizations-plugin",
"@kbn/core-mount-utils-browser",
- "@kbn/presentation-containers"
+ "@kbn/presentation-containers",
+ "@kbn/presentation-publishing"
],
"exclude": ["target/**/*"]
}
diff --git a/x-pack/plugins/dashboard_enhanced/kibana.jsonc b/x-pack/plugins/dashboard_enhanced/kibana.jsonc
index 88bb64bb00503..c37509d0a669a 100644
--- a/x-pack/plugins/dashboard_enhanced/kibana.jsonc
+++ b/x-pack/plugins/dashboard_enhanced/kibana.jsonc
@@ -21,7 +21,8 @@
"kibanaReact",
"kibanaUtils",
"imageEmbeddable",
- "presentationUtil"
+ "presentationUtil",
+ "uiActions"
]
}
}
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts
index b9cfe79e19fa9..65b47346e50aa 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts
@@ -6,13 +6,17 @@
*/
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
+import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import {
- SELECT_RANGE_TRIGGER,
- VALUE_CLICK_TRIGGER,
- IEmbeddable,
- Container as EmbeddableContainer,
-} from '@kbn/embeddable-plugin/public';
-import { isEnhancedEmbeddable } from '@kbn/embeddable-enhanced-plugin/public';
+ apiIsPresentationContainer,
+ type PresentationContainer,
+} from '@kbn/presentation-containers';
+import {
+ PublishesPanelTitle,
+ type HasUniqueId,
+ type HasParentApi,
+} from '@kbn/presentation-publishing';
+import { apiHasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public';
import { UiActionsEnhancedDrilldownTemplate as DrilldownTemplate } from '@kbn/ui-actions-enhanced-plugin/public';
/**
@@ -36,31 +40,22 @@ export function ensureNestedTriggers(triggers: string[]): string[] {
return triggers;
}
-const isEmbeddableContainer = (x: unknown): x is EmbeddableContainer =>
- x instanceof EmbeddableContainer;
-
/**
* Given a dashboard panel embeddable, it will find the parent (dashboard
* container embeddable), then iterate through all the dashboard panels and
* generate DrilldownTemplate for each existing drilldown.
*/
export const createDrilldownTemplatesFromSiblings = (
- embeddable: IEmbeddable
+ embeddable: Partial & HasParentApi>
): DrilldownTemplate[] => {
- const templates: DrilldownTemplate[] = [];
- const embeddableId = embeddable.id;
-
- const container = embeddable.getRoot();
+ const parentApi = embeddable.parentApi;
+ if (!apiIsPresentationContainer(parentApi)) return [];
- if (!container) return templates;
- if (!isEmbeddableContainer(container)) return templates;
-
- const childrenIds = (container as EmbeddableContainer).getChildIds();
-
- for (const childId of childrenIds) {
- const child = (container as EmbeddableContainer).getChild(childId);
- if (child.id === embeddableId) continue;
- if (!isEnhancedEmbeddable(child)) continue;
+ const templates: DrilldownTemplate[] = [];
+ for (const childId of parentApi.getChildIds()) {
+ const child = parentApi.getChild(childId) as Partial;
+ if (childId === embeddable.uuid) continue;
+ if (!apiHasDynamicActions(child)) continue;
const events = child.enhancements.dynamicActions.state.get().events;
for (const event of events) {
@@ -68,7 +63,7 @@ export const createDrilldownTemplatesFromSiblings = (
id: event.eventId,
name: event.action.name,
icon: 'dashboardApp',
- description: child.getTitle() || child.id,
+ description: child.panelTitle?.value ?? child.uuid ?? '',
config: event.action.config,
factoryId: event.action.factoryId,
triggers: event.triggers,
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx
index 165f80cdf51e8..50fe2e570b9a6 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx
@@ -5,164 +5,152 @@
* 2.0.
*/
-import { Subject } from 'rxjs';
+import { BehaviorSubject, Subject } from 'rxjs';
+import {
+ UiActionsEnhancedMemoryActionStorage as MemoryActionStorage,
+ UiActionsEnhancedDynamicActionManager as DynamicActionManager,
+} from '@kbn/ui-actions-enhanced-plugin/public';
+import type { ViewMode } from '@kbn/presentation-publishing';
import {
FlyoutCreateDrilldownAction,
OpenFlyoutAddDrilldownParams,
} from './flyout_create_drilldown';
import { coreMock } from '@kbn/core/public/mocks';
-import { ViewMode } from '@kbn/embeddable-plugin/public';
-import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers';
import { uiActionsEnhancedPluginMock } from '@kbn/ui-actions-enhanced-plugin/public/mocks';
import { UiActionsEnhancedActionFactory } from '@kbn/ui-actions-enhanced-plugin/public';
-const overlays = coreMock.createStart().overlays;
-const uiActionsEnhanced = uiActionsEnhancedPluginMock.createStartContract();
-
-const actionParams: OpenFlyoutAddDrilldownParams = {
- start: () => ({
- core: {
- overlays,
- application: {
- currentAppId$: new Subject(),
+function createAction(
+ allPossibleTriggers = ['VALUE_CLICK_TRIGGER'],
+ overlays = coreMock.createStart().overlays
+) {
+ const uiActionsEnhanced = uiActionsEnhancedPluginMock.createStartContract();
+ const params: OpenFlyoutAddDrilldownParams = {
+ start: () => ({
+ core: {
+ overlays,
+ application: {
+ currentAppId$: new Subject(),
+ },
+ theme: {
+ theme$: new Subject(),
+ },
+ } as any,
+ plugins: {
+ uiActionsEnhanced,
},
- theme: {
- theme$: new Subject(),
- },
- } as any,
- plugins: {
- uiActionsEnhanced,
- },
- self: {},
- }),
-};
+ self: {},
+ }),
+ };
-test('should create', () => {
- expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow();
-});
+ uiActionsEnhanced.getActionFactories.mockImplementation(() => [
+ {
+ supportedTriggers: () => allPossibleTriggers,
+ isCompatibleLicense: () => true,
+ } as unknown as UiActionsEnhancedActionFactory,
+ ]);
+ return new FlyoutCreateDrilldownAction(params);
+}
+
+const compatibleEmbeddableApi = {
+ enhancements: {
+ dynamicActions: new DynamicActionManager({
+ storage: new MemoryActionStorage(),
+ isCompatible: async () => true,
+ uiActions: uiActionsEnhancedPluginMock.createStartContract(),
+ }),
+ },
+ parentApi: {
+ type: 'dashboard',
+ },
+ supportedTriggers: () => {
+ return ['VALUE_CLICK_TRIGGER'];
+ },
+ viewMode: new BehaviorSubject('edit'),
+};
test('title is a string', () => {
- expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe(
- true
- );
+ expect(typeof createAction().getDisplayName() === 'string').toBe(true);
});
test('icon exists', () => {
- expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe(
- true
- );
+ expect(typeof createAction().getIconType() === 'string').toBe(true);
});
-interface CompatibilityParams {
- isEdit?: boolean;
- isValueClickTriggerSupported?: boolean;
- isEmbeddableEnhanced?: boolean;
- rootType?: string;
- actionFactoriesTriggers?: string[];
-}
-
describe('isCompatible', () => {
- const drilldownAction = new FlyoutCreateDrilldownAction(actionParams);
-
- async function assertCompatibility(
- {
- isEdit = true,
- isValueClickTriggerSupported = true,
- isEmbeddableEnhanced = true,
- rootType = 'dashboard',
- actionFactoriesTriggers = ['VALUE_CLICK_TRIGGER'],
- }: CompatibilityParams,
- expectedResult: boolean = true
- ): Promise {
- uiActionsEnhanced.getActionFactories.mockImplementation(() => [
- {
- supportedTriggers: () => actionFactoriesTriggers,
- isCompatibleLicense: () => true,
- } as unknown as UiActionsEnhancedActionFactory,
- ]);
-
- let embeddable = new MockEmbeddable(
- { id: '', viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW },
- {
- supportedTriggers: isValueClickTriggerSupported ? ['VALUE_CLICK_TRIGGER'] : [],
- }
- );
-
- embeddable.rootType = rootType;
-
- if (isEmbeddableEnhanced) {
- embeddable = enhanceEmbeddable(embeddable);
- }
-
- const result = await drilldownAction.isCompatible({
- embeddable,
- });
-
- expect(result).toBe(expectedResult);
- }
-
- const assertNonCompatibility = (params: CompatibilityParams) =>
- assertCompatibility(params, false);
-
test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => {
- await assertCompatibility({});
+ const action = createAction();
+ expect(await action.isCompatible({ embeddable: compatibleEmbeddableApi })).toBe(true);
});
test('not compatible if embeddable is not enhanced', async () => {
- await assertNonCompatibility({
- isEmbeddableEnhanced: false,
- });
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ enhancements: undefined,
+ };
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => {
- await assertNonCompatibility({
- isValueClickTriggerSupported: false,
- });
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ supportedTriggers: () => {
+ return [];
+ },
+ };
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
test('not compatible if in view mode', async () => {
- await assertNonCompatibility({
- isEdit: false,
- });
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ viewMode: new BehaviorSubject('view'),
+ };
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
- test('not compatible if root embeddable is not "dashboard"', async () => {
- await assertNonCompatibility({
- rootType: 'visualization',
- });
+ test('not compatible if parent embeddable is not "dashboard"', async () => {
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ parentApi: {
+ type: 'visualization',
+ },
+ };
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
test('not compatible if no triggers intersect', async () => {
- await assertNonCompatibility({
- actionFactoriesTriggers: [],
- });
- await assertNonCompatibility({
- actionFactoriesTriggers: ['SELECT_RANGE_TRIGGER'],
- });
+ expect(await createAction([]).isCompatible({ embeddable: compatibleEmbeddableApi })).toBe(
+ false
+ );
+ expect(
+ await createAction(['SELECT_RANGE_TRIGGER']).isCompatible({
+ embeddable: compatibleEmbeddableApi,
+ })
+ ).toBe(false);
});
});
describe('execute', () => {
- const drilldownAction = new FlyoutCreateDrilldownAction(actionParams);
-
- test('throws error if no dynamicUiActions', async () => {
+ test('throws if no dynamicUiActions', async () => {
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ enhancements: undefined,
+ };
await expect(
- drilldownAction.execute({
- embeddable: new MockEmbeddable({ id: '' }, {}),
- })
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction."`
- );
+ action.execute({ embeddable: embeddableApi })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action is incompatible"`);
});
test('should open flyout', async () => {
+ const overlays = coreMock.createStart().overlays;
const spy = jest.spyOn(overlays, 'openFlyout');
- const embeddable = enhanceEmbeddable(new MockEmbeddable({ id: '' }, {}));
-
- await drilldownAction.execute({
- embeddable,
- });
-
+ const action = createAction(['VALUE_CLICK_TRIGGER'], overlays);
+ await action.execute({ embeddable: compatibleEmbeddableApi });
expect(spy).toBeCalled();
});
});
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx
index 5bd16597c0b54..2c6bd8074a689 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx
@@ -5,20 +5,37 @@
* 2.0.
*/
-import React from 'react';
-import { i18n } from '@kbn/i18n';
-import { distinctUntilChanged, filter, map, skip, take, takeUntil } from 'rxjs/operators';
-import { Subject } from 'rxjs';
-import { Action } from '@kbn/ui-actions-plugin/public';
-import { toMountPoint } from '@kbn/kibana-react-plugin/public';
-import { CONTEXT_MENU_TRIGGER, EmbeddableContext, ViewMode } from '@kbn/embeddable-plugin/public';
import {
- isEnhancedEmbeddable,
+ apiHasDynamicActions,
embeddableEnhancedDrilldownGrouping,
+ type HasDynamicActions,
} from '@kbn/embeddable-enhanced-plugin/public';
+import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
+import { i18n } from '@kbn/i18n';
+import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public';
+import {
+ tracksOverlays,
+ type PresentationContainer,
+ type TracksOverlays,
+} from '@kbn/presentation-containers';
+import {
+ apiCanAccessViewMode,
+ apiHasParentApi,
+ apiHasSupportedTriggers,
+ apiIsOfType,
+ getInheritedViewMode,
+ type CanAccessViewMode,
+ type EmbeddableApiContext,
+ type HasUniqueId,
+ type HasParentApi,
+ type HasSupportedTriggers,
+ type HasType,
+} from '@kbn/presentation-publishing';
+import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
+import React from 'react';
import { StartDependencies } from '../../../../plugin';
-import { ensureNestedTriggers, createDrilldownTemplatesFromSiblings } from '../drilldown_shared';
+import { createDrilldownTemplatesFromSiblings, ensureNestedTriggers } from '../drilldown_shared';
export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN';
@@ -26,7 +43,19 @@ export interface OpenFlyoutAddDrilldownParams {
start: StartServicesGetter>;
}
-export class FlyoutCreateDrilldownAction implements Action {
+export type FlyoutCreateDrilldownActionApi = CanAccessViewMode &
+ HasDynamicActions &
+ HasParentApi> &
+ HasSupportedTriggers &
+ Partial;
+
+const isApiCompatible = (api: unknown | null): api is FlyoutCreateDrilldownActionApi =>
+ apiHasDynamicActions(api) &&
+ apiHasParentApi(api) &&
+ apiCanAccessViewMode(api) &&
+ apiHasSupportedTriggers(api);
+
+export class FlyoutCreateDrilldownAction implements Action {
public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN;
public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN;
public order = 12;
@@ -44,13 +73,15 @@ export class FlyoutCreateDrilldownAction implements Action {
return 'plusInCircle';
}
- private isEmbeddableCompatible(context: EmbeddableContext): boolean {
- if (!isEnhancedEmbeddable(context.embeddable)) return false;
- if (context.embeddable.getRoot().type !== 'dashboard') return false;
- const supportedTriggers = [
- CONTEXT_MENU_TRIGGER,
- ...(context.embeddable.supportedTriggers() || []),
- ];
+ public async isCompatible({ embeddable }: EmbeddableApiContext) {
+ if (!isApiCompatible(embeddable)) return false;
+ if (
+ getInheritedViewMode(embeddable) !== 'edit' ||
+ !apiIsOfType(embeddable.parentApi, 'dashboard')
+ )
+ return false;
+
+ const supportedTriggers = [CONTEXT_MENU_TRIGGER, ...embeddable.supportedTriggers()];
/**
* Check if there is an intersection between all registered drilldowns possible triggers that they could be attached to
@@ -67,36 +98,22 @@ export class FlyoutCreateDrilldownAction implements Action {
);
}
- public async isCompatible(context: EmbeddableContext) {
- if (!context.embeddable?.getInput) return false;
- const isEditMode = context.embeddable.getInput().viewMode === 'edit';
- return isEditMode && this.isEmbeddableCompatible(context);
- }
-
- public async execute(context: EmbeddableContext) {
+ public async execute({ embeddable }: EmbeddableApiContext) {
+ if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
const { core, plugins } = this.params.start();
- const { embeddable } = context;
-
- if (!isEnhancedEmbeddable(embeddable)) {
- throw new Error(
- 'Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction.'
- );
- }
const templates = createDrilldownTemplatesFromSiblings(embeddable);
- const closed$ = new Subject();
+ const overlayTracker = tracksOverlays(embeddable.parentApi) ? embeddable.parentApi : undefined;
const close = () => {
- closed$.next(true);
+ if (overlayTracker) overlayTracker.clearOverlays();
handle.close();
};
- const closeFlyout = () => {
- close();
- };
const triggers = [
...ensureNestedTriggers(embeddable.supportedTriggers()),
CONTEXT_MENU_TRIGGER,
];
+
const handle = core.overlays.openFlyout(
toMountPoint(
{
{
ownFocus: true,
'data-test-subj': 'createDrilldownFlyout',
+ onClose: () => {
+ close();
+ },
}
);
- // Close flyout on application change.
- core.application.currentAppId$
- .pipe(takeUntil(closed$), skip(1), take(1))
- .subscribe(closeFlyout);
-
- // Close flyout on dashboard switch to "view" mode or on embeddable destroy.
- embeddable
- .getInput$()
- .pipe(
- takeUntil(closed$),
- map((input) => input.viewMode),
- distinctUntilChanged(),
- filter((mode) => mode !== ViewMode.EDIT),
- take(1)
- )
- .subscribe({ next: closeFlyout, complete: closeFlyout });
+ overlayTracker?.openOverlay(handle);
}
}
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx
index fe0d6df5972c4..1aa64bd6e5a9e 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx
@@ -5,152 +5,136 @@
* 2.0.
*/
-import { Subject } from 'rxjs';
+import { BehaviorSubject, Subject } from 'rxjs';
import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown';
import { coreMock } from '@kbn/core/public/mocks';
-import { ViewMode } from '@kbn/embeddable-plugin/public';
+import type { ViewMode } from '@kbn/presentation-publishing';
+import {
+ UiActionsEnhancedMemoryActionStorage as MemoryActionStorage,
+ UiActionsEnhancedDynamicActionManager as DynamicActionManager,
+} from '@kbn/ui-actions-enhanced-plugin/public';
import { uiActionsEnhancedPluginMock } from '@kbn/ui-actions-enhanced-plugin/public/mocks';
-import { EnhancedEmbeddable } from '@kbn/embeddable-enhanced-plugin/public';
-import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers';
-const overlays = coreMock.createStart().overlays;
-const uiActionsPlugin = uiActionsEnhancedPluginMock.createPlugin();
-const uiActions = uiActionsPlugin.doStart();
-
-uiActionsPlugin.setup.registerDrilldown({
- id: 'foo',
- CollectConfig: {} as any,
- createConfig: () => ({}),
- isConfigValid: () => true,
- execute: async () => {},
- getDisplayName: () => 'test',
- supportedTriggers() {
+function createAction(overlays = coreMock.createStart().overlays) {
+ const uiActionsPlugin = uiActionsEnhancedPluginMock.createPlugin();
+ const uiActions = uiActionsPlugin.doStart();
+ const params: FlyoutEditDrilldownParams = {
+ start: () => ({
+ core: {
+ overlays,
+ application: {
+ currentAppId$: new Subject(),
+ },
+ theme: {
+ theme$: new Subject(),
+ },
+ } as any,
+ plugins: {
+ uiActionsEnhanced: uiActions,
+ },
+ self: {},
+ }),
+ };
+ return new FlyoutEditDrilldownAction(params);
+}
+
+const compatibleEmbeddableApi = {
+ enhancements: {
+ dynamicActions: new DynamicActionManager({
+ storage: new MemoryActionStorage(),
+ isCompatible: async () => true,
+ uiActions: uiActionsEnhancedPluginMock.createStartContract(),
+ }),
+ },
+ supportedTriggers: () => {
return ['VALUE_CLICK_TRIGGER'];
},
-});
-
-const actionParams: FlyoutEditDrilldownParams = {
- start: () => ({
- core: {
- overlays,
- application: {
- currentAppId$: new Subject(),
- },
- theme: {
- theme$: new Subject(),
- },
- } as any,
- plugins: {
- uiActionsEnhanced: uiActions,
- },
- self: {},
- }),
+ viewMode: new BehaviorSubject('edit'),
};
-test('should create', () => {
- expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow();
+beforeAll(async () => {
+ await compatibleEmbeddableApi.enhancements.dynamicActions.createEvent(
+ {
+ config: {},
+ factoryId: 'foo',
+ name: '',
+ },
+ ['VALUE_CLICK_TRIGGER']
+ );
});
test('title is a string', () => {
- expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe(
- true
- );
+ expect(typeof createAction().getDisplayName() === 'string').toBe(true);
});
test('icon exists', () => {
- expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true);
+ expect(typeof createAction().getIconType() === 'string').toBe(true);
});
test('MenuItem exists', () => {
- expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined();
+ expect(createAction().MenuItem).toBeDefined();
});
describe('isCompatible', () => {
- function setupIsCompatible({
- isEdit = true,
- isEmbeddableEnhanced = true,
- }: {
- isEdit?: boolean;
- isEmbeddableEnhanced?: boolean;
- } = {}) {
- const action = new FlyoutEditDrilldownAction(actionParams);
- const input = {
- id: '',
- viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW,
- };
- const embeddable = new MockEmbeddable(input, {});
- const context = {
- embeddable: (isEmbeddableEnhanced
- ? enhanceEmbeddable(embeddable, uiActions)
- : embeddable) as EnhancedEmbeddable,
- };
-
- return {
- action,
- context,
- };
- }
+ test("compatible if dynamicUiActions enabled (with event), 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => {
+ const action = createAction();
+ expect(await action.isCompatible({ embeddable: compatibleEmbeddableApi })).toBe(true);
+ });
test('not compatible if no drilldowns', async () => {
- const { action, context } = setupIsCompatible();
- expect(await action.isCompatible(context)).toBe(false);
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ enhancements: {
+ dynamicActions: new DynamicActionManager({
+ storage: new MemoryActionStorage(),
+ isCompatible: async () => true,
+ uiActions: uiActionsEnhancedPluginMock.createStartContract(),
+ }),
+ },
+ };
+ const action = createAction();
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
test('not compatible if embeddable is not enhanced', async () => {
- const { action, context } = setupIsCompatible({ isEmbeddableEnhanced: false });
- expect(await action.isCompatible(context)).toBe(false);
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ enhancements: undefined,
+ };
+ const action = createAction();
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
- describe('when has at least one drilldown', () => {
- test('is compatible in edit mode', async () => {
- const { action, context } = setupIsCompatible();
-
- await context.embeddable.enhancements.dynamicActions.createEvent(
- {
- config: {},
- factoryId: 'foo',
- name: '',
- },
- ['VALUE_CLICK_TRIGGER']
- );
-
- expect(await action.isCompatible(context)).toBe(true);
- });
-
- test('not compatible in view mode', async () => {
- const { action, context } = setupIsCompatible({ isEdit: false });
-
- await context.embeddable.enhancements.dynamicActions.createEvent(
- {
- config: {},
- factoryId: 'foo',
- name: '',
- },
- ['VALUE_CLICK_TRIGGER']
- );
-
- expect(await action.isCompatible(context)).toBe(false);
- });
+ test('not compatible in view mode', async () => {
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ viewMode: new BehaviorSubject('view'),
+ };
+ const action = createAction();
+ expect(await action.isCompatible({ embeddable: embeddableApi })).toBe(false);
});
});
describe('execute', () => {
- const drilldownAction = new FlyoutEditDrilldownAction(actionParams);
-
test('throws error if no dynamicUiActions', async () => {
+ const action = createAction();
+ const embeddableApi = {
+ ...compatibleEmbeddableApi,
+ enhancements: undefined,
+ };
await expect(
- drilldownAction.execute({
- embeddable: new MockEmbeddable({ id: '' }, {}),
+ action.execute({
+ embeddable: embeddableApi,
})
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction."`
- );
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action is incompatible"`);
});
test('should open flyout', async () => {
+ const overlays = coreMock.createStart().overlays;
const spy = jest.spyOn(overlays, 'openFlyout');
- await drilldownAction.execute({
- embeddable: enhanceEmbeddable(new MockEmbeddable({ id: '' }, {})),
+ const action = createAction(overlays);
+ await action.execute({
+ embeddable: compatibleEmbeddableApi,
});
expect(spy).toBeCalled();
});
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx
index aed94cd1c4adc..ebd4a6a3441e9 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx
@@ -6,14 +6,28 @@
*/
import React from 'react';
-import { distinctUntilChanged, filter, map, skip, take, takeUntil } from 'rxjs/operators';
-import { Subject } from 'rxjs';
-import { Action } from '@kbn/ui-actions-plugin/public';
+import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
-import { EmbeddableContext, ViewMode, CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import {
- isEnhancedEmbeddable,
+ tracksOverlays,
+ type PresentationContainer,
+ type TracksOverlays,
+} from '@kbn/presentation-containers';
+import {
+ apiCanAccessViewMode,
+ apiHasSupportedTriggers,
+ getInheritedViewMode,
+ type CanAccessViewMode,
+ type EmbeddableApiContext,
+ type HasUniqueId,
+ type HasParentApi,
+ type HasSupportedTriggers,
+} from '@kbn/presentation-publishing';
+import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
+import {
+ apiHasDynamicActions,
embeddableEnhancedDrilldownGrouping,
+ type HasDynamicActions,
} from '@kbn/embeddable-enhanced-plugin/public';
import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public';
import { txtDisplayName } from './i18n';
@@ -27,7 +41,16 @@ export interface FlyoutEditDrilldownParams {
start: StartServicesGetter>;
}
-export class FlyoutEditDrilldownAction implements Action {
+export type FlyoutEditDrilldownActionApi = CanAccessViewMode &
+ HasDynamicActions &
+ HasParentApi> &
+ HasSupportedTriggers &
+ Partial;
+
+const isApiCompatible = (api: unknown | null): api is FlyoutEditDrilldownActionApi =>
+ apiHasDynamicActions(api) && apiCanAccessViewMode(api) && apiHasSupportedTriggers(api);
+
+export class FlyoutEditDrilldownAction implements Action {
public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN;
public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN;
public order = 10;
@@ -45,32 +68,22 @@ export class FlyoutEditDrilldownAction implements Action {
public readonly MenuItem = MenuItem as any;
- public async isCompatible({ embeddable }: EmbeddableContext) {
- if (!embeddable?.getInput) return false;
- if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false;
- if (!isEnhancedEmbeddable(embeddable)) return false;
+ public async isCompatible({ embeddable }: EmbeddableApiContext) {
+ if (!isApiCompatible(embeddable)) return false;
+ if (getInheritedViewMode(embeddable) !== 'edit') return false;
return embeddable.enhancements.dynamicActions.state.get().events.length > 0;
}
- public async execute(context: EmbeddableContext) {
+ public async execute({ embeddable }: EmbeddableApiContext) {
+ if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
const { core, plugins } = this.params.start();
- const { embeddable } = context;
-
- if (!isEnhancedEmbeddable(embeddable)) {
- throw new Error(
- 'Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction.'
- );
- }
const templates = createDrilldownTemplatesFromSiblings(embeddable);
- const closed$ = new Subject();
+ const overlayTracker = tracksOverlays(embeddable.parentApi) ? embeddable.parentApi : undefined;
const close = () => {
- closed$.next(true);
+ if (overlayTracker) overlayTracker.clearOverlays();
handle.close();
};
- const closeFlyout = () => {
- close();
- };
const handle = core.overlays.openFlyout(
toMountPoint(
@@ -87,24 +100,12 @@ export class FlyoutEditDrilldownAction implements Action {
{
ownFocus: true,
'data-test-subj': 'editDrilldownFlyout',
+ onClose: () => {
+ close();
+ },
}
);
- // Close flyout on application change.
- core.application.currentAppId$
- .pipe(takeUntil(closed$), skip(1), take(1))
- .subscribe(closeFlyout);
-
- // Close flyout on dashboard switch to "view" mode or on embeddable destroy.
- embeddable
- .getInput$()
- .pipe(
- takeUntil(closed$),
- map((input) => input.viewMode),
- distinctUntilChanged(),
- filter((mode) => mode !== ViewMode.EDIT),
- take(1)
- )
- .subscribe({ next: closeFlyout, complete: closeFlyout });
+ overlayTracker?.openOverlay(handle);
}
}
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts
deleted file mode 100644
index 067b27fd12e5b..0000000000000
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import { Embeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public';
-import { EnhancedEmbeddable } from '@kbn/embeddable-enhanced-plugin/public';
-import {
- UiActionsEnhancedMemoryActionStorage as MemoryActionStorage,
- UiActionsEnhancedDynamicActionManager as DynamicActionManager,
- AdvancedUiActionsStart,
-} from '@kbn/ui-actions-enhanced-plugin/public';
-import { uiActionsEnhancedPluginMock } from '@kbn/ui-actions-enhanced-plugin/public/mocks';
-
-export class MockEmbeddable extends Embeddable {
- public rootType = 'dashboard';
- public readonly type = 'mock';
- private readonly triggers: string[] = [];
- constructor(initialInput: EmbeddableInput, params: { supportedTriggers?: string[] }) {
- super(initialInput, {}, undefined);
- this.triggers = params.supportedTriggers ?? [];
- }
- public render(node: HTMLElement) {}
- public reload() {}
- public supportedTriggers(): string[] {
- return this.triggers;
- }
- public getRoot() {
- return {
- type: this.rootType,
- } as Embeddable;
- }
-}
-
-export const enhanceEmbeddable = (
- embeddable: E,
- uiActions: AdvancedUiActionsStart = uiActionsEnhancedPluginMock.createStartContract()
-): EnhancedEmbeddable => {
- (embeddable as EnhancedEmbeddable).enhancements = {
- dynamicActions: new DynamicActionManager({
- storage: new MemoryActionStorage(),
- isCompatible: async () => true,
- uiActions,
- }),
- };
- return embeddable as EnhancedEmbeddable;
-};
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx
index e3bf4a4d468c8..00ca768a04848 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx
@@ -6,15 +6,14 @@
*/
import { Filter, RangeFilter, FilterStateStore, Query, TimeRange } from '@kbn/es-query';
-import { EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown';
+import { type Context, EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown';
import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown';
import { savedObjectsServiceMock } from '@kbn/core/public/mocks';
-import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public';
import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public';
import { StartDependencies } from '../../../plugin';
import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public/core';
-import { EnhancedEmbeddableContext } from '@kbn/embeddable-enhanced-plugin/public';
import { DashboardAppLocatorDefinition } from '@kbn/dashboard-plugin/public/dashboard_app/locator/locator';
+import { BehaviorSubject } from 'rxjs';
describe('.isConfigValid()', () => {
const drilldown = new EmbeddableToDashboardDrilldown({} as any);
@@ -118,15 +117,18 @@ describe('.execute() & getHref', () => {
const context = {
filters: filtersFromEvent,
embeddable: {
- getInput: () => ({
- filters: [],
- timeRange: { from: 'now-15m', to: 'now' },
- query: { query: 'test', language: 'kuery' },
- ...embeddableInput,
- }),
+ parentApi: {
+ localFilters: new BehaviorSubject(embeddableInput.filters ? embeddableInput.filters : []),
+ localQuery: new BehaviorSubject(
+ embeddableInput.query ? embeddableInput.query : { query: 'test', language: 'kuery' }
+ ),
+ localTimeRange: new BehaviorSubject(
+ embeddableInput.timeRange ? embeddableInput.timeRange : { from: 'now-15m', to: 'now' }
+ ),
+ },
},
timeFieldName,
- } as unknown as ApplyGlobalFilterActionContext & EnhancedEmbeddableContext;
+ } as Context;
await drilldown.execute(completeConfig, context);
diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx
index 0838297ee72e3..87790201b5099 100644
--- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx
+++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx
@@ -5,6 +5,7 @@
* 2.0.
*/
import { extractTimeRange, isFilterPinned } from '@kbn/es-query';
+import type { HasParentApi, PublishesLocalUnifiedSearch } from '@kbn/presentation-publishing';
import type { KibanaLocation } from '@kbn/share-plugin/public';
import {
cleanEmptyKeys,
@@ -14,7 +15,6 @@ import {
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public';
-import { EnhancedEmbeddableContext } from '@kbn/embeddable-enhanced-plugin/public';
import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public';
import {
AbstractDashboardDrilldown,
@@ -24,7 +24,11 @@ import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants';
import { createExtract, createInject } from '../../../../common';
import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown';
-type Context = EnhancedEmbeddableContext & ApplyGlobalFilterActionContext;
+export type Context = ApplyGlobalFilterActionContext & {
+ embeddable: Partial<
+ PublishesLocalUnifiedSearch & HasParentApi>
+ >;
+};
export type Params = AbstractDashboardDrilldownParams;
/**
diff --git a/x-pack/plugins/dashboard_enhanced/tsconfig.json b/x-pack/plugins/dashboard_enhanced/tsconfig.json
index 4c08a46b6e2d6..0125f8c463513 100644
--- a/x-pack/plugins/dashboard_enhanced/tsconfig.json
+++ b/x-pack/plugins/dashboard_enhanced/tsconfig.json
@@ -19,7 +19,9 @@
"@kbn/unified-search-plugin",
"@kbn/ui-actions-plugin",
"@kbn/image-embeddable-plugin",
- "@kbn/presentation-util-plugin"
+ "@kbn/presentation-util-plugin",
+ "@kbn/presentation-containers",
+ "@kbn/presentation-publishing"
],
"exclude": ["target/**/*"]
}
diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/drilldown_grouping.ts b/x-pack/plugins/embeddable_enhanced/public/actions/drilldown_grouping.ts
index 86ca2030d2ae7..1e896817b3242 100644
--- a/x-pack/plugins/embeddable_enhanced/public/actions/drilldown_grouping.ts
+++ b/x-pack/plugins/embeddable_enhanced/public/actions/drilldown_grouping.ts
@@ -6,12 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
-import { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { UiActionsPresentableGrouping as PresentableGrouping } from '@kbn/ui-actions-plugin/public';
-export const drilldownGrouping: PresentableGrouping<{
- embeddable?: IEmbeddable;
-}> = [
+export const drilldownGrouping: PresentableGrouping = [
{
id: 'drilldowns',
getDisplayName: () =>
diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts
new file mode 100644
index 0000000000000..d92fd2138b56b
--- /dev/null
+++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/interfaces/has_dynamic_actions.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '@kbn/ui-actions-enhanced-plugin/public';
+
+export interface HasDynamicActions {
+ enhancements: {
+ dynamicActions: DynamicActionManager;
+ };
+}
+
+export const apiHasDynamicActions = (api: unknown): api is HasDynamicActions => {
+ return Boolean(
+ api && typeof (api as HasDynamicActions).enhancements?.dynamicActions === 'object'
+ );
+};
diff --git a/x-pack/plugins/embeddable_enhanced/public/index.ts b/x-pack/plugins/embeddable_enhanced/public/index.ts
index 63bd6d17ddcb9..53fec5c83522a 100644
--- a/x-pack/plugins/embeddable_enhanced/public/index.ts
+++ b/x-pack/plugins/embeddable_enhanced/public/index.ts
@@ -21,4 +21,8 @@ export function plugin(context: PluginInitializerContext) {
export type { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types';
export { isEnhancedEmbeddable } from './embeddables';
+export {
+ type HasDynamicActions,
+ apiHasDynamicActions,
+} from './embeddables/interfaces/has_dynamic_actions';
export { drilldownGrouping as embeddableEnhancedDrilldownGrouping } from './actions';
diff --git a/x-pack/plugins/embeddable_enhanced/public/types.ts b/x-pack/plugins/embeddable_enhanced/public/types.ts
index 7500a37e120cc..fd5a0f689f74b 100644
--- a/x-pack/plugins/embeddable_enhanced/public/types.ts
+++ b/x-pack/plugins/embeddable_enhanced/public/types.ts
@@ -17,6 +17,9 @@ export type EnhancedEmbeddable = E & {
};
};
+/**
+ * @deprecated use `EmbeddableApiContext` from `@kbn/presentation-publishing`
+ */
export interface EnhancedEmbeddableContext {
embeddable: EnhancedEmbeddable;
}
From 956fa0af4e1d6ea6e9da6baf45738ecd84be2af5 Mon Sep 17 00:00:00 2001
From: Faisal Kanout
Date: Mon, 12 Feb 2024 20:50:43 +0300
Subject: [PATCH 13/83] =?UTF-8?q?[OBS-UX-MNGMT]=20Remove=20the=20Beta=20ba?=
=?UTF-8?q?dge=20and=20GA=20the=20Custom=20Threshold=20rule=20=F0=9F=8E=89?=
=?UTF-8?q?=20(#176514)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
It closes https://github.com/elastic/kibana/issues/172942 by removing
the Beta badge.
Related to https://github.com/elastic/observability-docs/issues/3549
## Release note:
We are excited to announce the general availability of the Custom
Threshold rule. The Custom Threshold rule is an Observability alerting
rule that allows users to define and alert on custom threshold values.
The rule can be customized to act on particular data views, using
specific aggregations (like sum, average, min, max, percentile, rate,
etc.) and via the use of basic math or boolean logic to define the
custom thresholds. For more information, please refer to the [user
guide](https://www.elastic.co/guide/en/observability/current/custom-threshold-alert.html).
---
.../components/custom_threshold/mocks/custom_threshold_rule.ts | 2 +-
.../custom_threshold/register_custom_threshold_rule_type.ts | 2 +-
.../observability/custom_threshold_rule/avg_pct_fired.ts | 2 +-
.../observability/custom_threshold_rule/avg_pct_no_data.ts | 2 +-
.../observability/custom_threshold_rule/avg_us_fired.ts | 2 +-
.../custom_threshold_rule/custom_eq_avg_bytes_fired.ts | 2 +-
.../custom_threshold_rule/documents_count_fired.ts | 2 +-
.../observability/custom_threshold_rule/group_by_fired.ts | 2 +-
.../observability/custom_threshold_rule/p99_pct_fired.ts | 2 +-
.../observability/custom_threshold_rule/rate_bytes_fired.ts | 2 +-
.../observability/custom_threshold_rule/avg_pct_fired.ts | 2 +-
.../observability/custom_threshold_rule/avg_pct_no_data.ts | 2 +-
.../custom_threshold_rule/custom_eq_avg_bytes_fired.ts | 2 +-
.../custom_threshold_rule/documents_count_fired.ts | 2 +-
.../observability/custom_threshold_rule/group_by_fired.ts | 2 +-
.../observability/custom_threshold_rule/p99_bytes_fired.ts | 2 +-
16 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/x-pack/plugins/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts b/x-pack/plugins/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
index 7616b1b9e2953..4961943f79712 100644
--- a/x-pack/plugins/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
+++ b/x-pack/plugins/observability/public/components/custom_threshold/mocks/custom_threshold_rule.ts
@@ -247,7 +247,7 @@ export const buildCustomThresholdAlert = (
},
'kibana.alert.evaluation.values': [2500, 5],
'kibana.alert.group': [{ field: 'host.name', value: 'host-1' }],
- 'kibana.alert.rule.category': 'Custom threshold (Beta)',
+ 'kibana.alert.rule.category': 'Custom threshold',
'kibana.alert.rule.consumer': 'alerts',
'kibana.alert.rule.execution.uuid': '62dd07ef-ead9-4b1f-a415-7c83d03925f7',
'kibana.alert.rule.name': 'One condition',
diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts
index df64a67ca8e4a..93286a2988e04 100644
--- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts
+++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts
@@ -129,7 +129,7 @@ export function thresholdRuleType(
return {
id: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
name: i18n.translate('xpack.observability.threshold.ruleName', {
- defaultMessage: 'Custom threshold (Beta)',
+ defaultMessage: 'Custom threshold',
}),
fieldsForAAD: CUSTOM_THRESHOLD_AAD_FIELDS,
validate: {
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts
index 929913f15f39f..6120c7eb93087 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_fired.ts
@@ -185,7 +185,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_no_data.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_no_data.ts
index 1b662f2af5aee..9c265655dbe53 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_no_data.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_pct_no_data.ts
@@ -153,7 +153,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_us_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_us_fired.ts
index 1a32a0af3612e..74b8d2e178a37 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_us_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/avg_us_fired.ts
@@ -163,7 +163,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
index d1c8a2927837c..b402f80c399ac 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
@@ -182,7 +182,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts
index 43029573defd8..c3fa31140b803 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts
@@ -182,7 +182,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts
index 55f2d61be2b0e..d28525f01118e 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts
@@ -182,7 +182,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts
index 942d025dd11f3..05c86f07d8efb 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts
@@ -180,7 +180,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts
index b76dd0c2581fc..dc41b1b91f29b 100644
--- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts
+++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts
@@ -178,7 +178,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts
index 47ed06c1ad440..8141ebd43bbc4 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_fired.ts
@@ -167,7 +167,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts
index 0411c6948eae1..e8cff15170cc9 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/avg_pct_no_data.ts
@@ -138,7 +138,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
index 8b6d76a380748..c236b4cc93261 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/custom_eq_avg_bytes_fired.ts
@@ -176,7 +176,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts
index 15c34602e0a78..2353fe0c82bf0 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/documents_count_fired.ts
@@ -167,7 +167,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts
index 264057cacff1c..c0694cf59c435 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts
@@ -185,7 +185,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts
index 70f3192c117e9..c00139c36380d 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts
@@ -152,7 +152,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
- 'Custom threshold (Beta)'
+ 'Custom threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
From e93add06a47305f916cdcb7de1175f8ffd531e27 Mon Sep 17 00:00:00 2001
From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com>
Date: Mon, 12 Feb 2024 12:57:15 -0500
Subject: [PATCH 14/83] [Security Solution] Per-field diffs test plan (#176474)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Resolves https://github.com/elastic/kibana/issues/176473
This PR introduces a test plan for the per-field diff preview. This
preview is displayed in the upgrade prebuilt rule flyout under the
`Updates` tab.
---
.../installation_and_upgrade.md | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md
index b60609b45be9d..41e379906eb42 100644
--- a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md
+++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md
@@ -64,6 +64,11 @@ Status: `in progress`. The current test plan matches `Milestone 2` of the [Rule
- [**Scenario: Properties with semantically equal values should not be shown as modified**](#scenario-properties-with-semantically-equal-values-should-not-be-shown-as-modified)
- [**Scenario: Unchanged sections of a rule should be hidden by default**](#scenario-unchanged-sections-of-a-rule-should-be-hidden-by-default)
- [**Scenario: Properties should be sorted alphabetically**](#scenario-properties-should-be-sorted-alphabetically)
+ - [Rule upgrade workflow: viewing rule changes in per-field diff view](#rule-upgrade-workflow-viewing-rule-changes-in-per-field-diff-view)
+ - [**Scenario: User can see changes in a side-by-side per-field diff view**](#scenario-user-can-see-changes-in-a-side-by-side-per-field-diff-view)
+ - [**Scenario: Field groupings should be rendered together in the same accordion panel**](#scenario-field-groupings-should-be-rendered-together-in-the-same-accordion-panel)
+ - [**Scenario: Undefined values are displayed with empty diffs**](#scenario-undefined-values-are-displayed-with-empty-diffs)
+ - [**Scenario: Field diff components have the same grouping and order as in rule details overview**](#scenario-field-diff-components-have-the-same-grouping-and-order-as-in-rule-details-overview)
- [Rule upgrade workflow: preserving rule bound data](#rule-upgrade-workflow-preserving-rule-bound-data)
- [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-the-same-rule-type)
- [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with a different rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-a-different-rule-type)
@@ -953,6 +958,99 @@ When a user expands all hidden sections
Then all properties of the rule should be sorted alphabetically
```
+### Rule upgrade workflow: viewing rule changes in per-field diff view
+
+#### **Scenario: User can see changes in a side-by-side per-field diff view**
+
+**Automation**: 1 e2e test
+
+```Gherkin
+Given X prebuilt rules are installed in Kibana
+And for Y of these rules new versions are available
+When user opens the Rule Updates table and selects a rule
+Then the per-field upgrade preview should open
+And rule changes should be displayed in a two-column diff view with each field in its own accordion component
+And all field diff accordions should be open by default
+And correct rule version numbers should be displayed in their respective columns
+When the user selects another rule without closing the preview
+Then the preview should display the changes for the newly selected rule
+```
+
+#### **Scenario: User can see changes when updated rule is a different rule type**
+
+**Automation**: 1 UI integration test
+
+```Gherkin
+Given a prebuilt rule is installed in Kibana
+And this rule has an update available that changes the rule type
+When user opens the upgrade preview
+Then the rule type changes should be displayed in grouped field diffs with corresponding query fields
+And a tooltip is displayed with information about changing rule types
+```
+
+#### **Scenario: Field groupings should be rendered together in the same accordion panel**
+
+**Automation**: 1 UI integration test
+
+```Gherkin
+Given a prebuilt rule is installed in Kibana
+And this rule contains one or more values
+When user opens the upgrade preview
+The diff accordion panel should display its grouped rule properties
+And each property should have its name displayed inside the panel above its value
+
+Examples:
+| field |
+| data_source |
+| kql_query |
+| eql_query |
+| esql_query |
+| threat_query |
+| rule_schedule |
+| rule_name_override |
+| timestamp_override |
+| timeline_template |
+| building_block |
+| threshold |
+```
+
+#### **Scenario: Undefined values are displayed with empty diffs**
+
+**Automation**: 1 UI integration test
+
+```Gherkin
+Given a prebuilt rule is installed in Kibana
+And this rule has field in the version that didn't exist in the version
+When a user opens the upgrade preview
+Then the preview should open
+And the old/new field should render an empty panel
+
+Examples:
+| version_one | version_two |
+| target | current |
+| current | target |
+```
+
+#### **Scenario: Field diff components have the same grouping and order as in rule details overview**
+
+**Automation**: 1 UI integration test
+
+```Gherkin
+Given a prebuilt rule is installed in Kibana
+And this rule has multiple fields that are different between the current and target version
+When a user opens the upgrade preview
+Then the multiple field diff accordions should be sorted in the same order as on the rule details overview tab
+And the field diff accordions should be grouped inside its corresponding accordion
+And any accordion that doesn't have fields inside it shouldn't be displayed
+
+Examples:
+| section |
+| About |
+| Definition |
+| Schedule |
+| Setup Guide |
+```
+
### Rule upgrade workflow: preserving rule bound data
#### **Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type**
From 0f31c0bff36a375f47aefc41d604551e56fac181 Mon Sep 17 00:00:00 2001
From: Julia Rechkunova
Date: Mon, 12 Feb 2024 19:21:00 +0100
Subject: [PATCH 15/83] [Discover] Unskip update data view test (#176508)
- Closes https://github.com/elastic/kibana/issues/174066
95x
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5099
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
---
.../data_views/_data_view_create_delete.ts | 3 +--
test/functional/page_objects/settings_page.ts | 16 ++++++++++++----
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/test/functional/apps/management/data_views/_data_view_create_delete.ts b/test/functional/apps/management/data_views/_data_view_create_delete.ts
index 651dbce7ada02..e3bc2240887ad 100644
--- a/test/functional/apps/management/data_views/_data_view_create_delete.ts
+++ b/test/functional/apps/management/data_views/_data_view_create_delete.ts
@@ -20,8 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const es = getService('es');
const PageObjects = getPageObjects(['settings', 'common', 'header']);
- // FLAKY: https://github.com/elastic/kibana/issues/174066
- describe.skip('creating and deleting default data view', function describeIndexTests() {
+ describe('creating and deleting default data view', function describeIndexTests() {
before(async function () {
await esArchiver.emptyKibanaIndex();
await esArchiver.loadIfNeeded(
diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts
index 3208fb782e272..12ff79829fa7e 100644
--- a/test/functional/page_objects/settings_page.ts
+++ b/test/functional/page_objects/settings_page.ts
@@ -215,14 +215,14 @@ export class SettingsPageObject extends FtrService {
}
async getSaveDataViewButtonActive() {
- await this.retry.try(async () => {
- expect(
+ await this.retry.waitFor('active save button', async () => {
+ return (
(
await this.find.allByCssSelector(
'[data-test-subj="saveIndexPatternButton"]:not(.euiButton-isDisabled)'
)
- ).length
- ).to.be(1);
+ ).length === 1
+ );
});
return await this.testSubjects.find('saveIndexPatternButton');
}
@@ -615,7 +615,13 @@ export class SettingsPageObject extends FtrService {
await this.clickEditIndexButton();
await this.header.waitUntilLoadingHasFinished();
+ let hasSubmittedTheForm = false;
+
await this.retry.try(async () => {
+ if (hasSubmittedTheForm && !(await this.testSubjects.exists('indexPatternEditorFlyout'))) {
+ // the flyout got closed
+ return;
+ }
if (dataViewName) {
await this.setNameField(dataViewName);
}
@@ -627,6 +633,8 @@ export class SettingsPageObject extends FtrService {
const indexPatternSaveBtn = await this.getSaveIndexPatternButton();
await indexPatternSaveBtn.click();
+ hasSubmittedTheForm = true;
+
const form = await this.testSubjects.findAll('indexPatternEditorForm');
const hasValidationErrors =
form.length !== 0 && (await form[0].getAttribute('data-validation-error')) === '1';
From 7a8d328cfa0f59516124a760710d0d5831680abf Mon Sep 17 00:00:00 2001
From: Jill Guyonnet
Date: Mon, 12 Feb 2024 18:21:36 +0000
Subject: [PATCH 16/83] [Fleet] Fix flaky serverless API integration tests
(#176614)
## Summary
Closes https://github.com/elastic/kibana/issues/176352
Closes https://github.com/elastic/kibana/issues/176399
https://github.com/elastic/kibana/pull/175315 added the possibility to
configure new Fleet Server hosts in serverless, with the constraint that
the host URL must match the default URL. The API integration tests
written to test this have been flaky, probably due to request timeout
when fetching all Fleet Server hosts. This PR improves this by directly
retrieving the default Fleet Server host by id.
This fix has been tested using the Flaky Test Runner Pipeline, with 25
test runs for observability and security project types:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5140
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
---
.../routes/fleet_server_hosts/handler.test.ts | 34 ++++++++-----------
.../routes/fleet_server_hosts/handler.ts | 22 ++++--------
.../test_suites/observability/fleet/fleet.ts | 3 +-
.../test_suites/security/fleet/fleet.ts | 3 +-
4 files changed, 23 insertions(+), 39 deletions(-)
diff --git a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts
index e65942a50c934..3d7b35682dc6e 100644
--- a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts
+++ b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.test.ts
@@ -33,14 +33,10 @@ describe('fleet server hosts handler', () => {
jest
.spyOn(fleetServerService, 'updateFleetServerHost')
.mockResolvedValue({ id: 'host1' } as any);
- jest.spyOn(fleetServerService, 'listFleetServerHosts').mockResolvedValue({
- items: [
- { id: SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID, host_urls: ['http://elasticsearch:9200'] },
- ] as any,
- total: 1,
- page: 1,
- perPage: 1,
- });
+ jest.spyOn(fleetServerService, 'getFleetServerHost').mockResolvedValue({
+ id: SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID,
+ host_urls: ['http://elasticsearch:9200'],
+ } as any);
jest
.spyOn(agentPolicyService, 'bumpAllAgentPoliciesForFleetServerHosts')
.mockResolvedValue({} as any);
@@ -118,17 +114,17 @@ describe('fleet server hosts handler', () => {
expect(res).toEqual({ body: { item: { id: 'host1' } } });
});
- // it('should return ok on put in stateful if host url is different from default', async () => {
- // jest
- // .spyOn(appContextService, 'getCloud')
- // .mockReturnValue({ isServerlessEnabled: false } as any);
+ it('should return ok on put in stateful if host url is different from default', async () => {
+ jest
+ .spyOn(appContextService, 'getCloud')
+ .mockReturnValue({ isServerlessEnabled: false } as any);
- // const res = await putFleetServerHostHandler(
- // mockContext,
- // { body: { host_urls: ['http://localhost:8080'] }, params: { outputId: 'host1' } } as any,
- // mockResponse as any
- // );
+ const res = await putFleetServerHostHandler(
+ mockContext,
+ { body: { host_urls: ['http://localhost:8080'] }, params: { outputId: 'host1' } } as any,
+ mockResponse as any
+ );
- // expect(res).toEqual({ body: { item: { id: 'host1' } } });
- // });
+ expect(res).toEqual({ body: { item: { id: 'host1' } } });
+ });
});
diff --git a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts
index eddce8df7c3e0..7e6c14506f3da 100644
--- a/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts
+++ b/x-pack/plugins/fleet/server/routes/fleet_server_hosts/handler.ts
@@ -36,27 +36,17 @@ async function checkFleetServerHostsWriteAPIsAllowed(
return;
}
- const defaultFleetServerHost = await getDefaultFleetServerHost(soClient);
- if (
- defaultFleetServerHost === undefined ||
- !isEqual(hostUrls, defaultFleetServerHost.host_urls)
- ) {
+ const serverlessDefaultFleetServerHost = await getFleetServerHost(
+ soClient,
+ SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID
+ );
+ if (!isEqual(hostUrls, serverlessDefaultFleetServerHost.host_urls)) {
throw new FleetServerHostUnauthorizedError(
- `Fleet server host must have default URL in serverless${
- defaultFleetServerHost ? ': ' + defaultFleetServerHost.host_urls : ''
- }`
+ `Fleet server host must have default URL in serverless: ${serverlessDefaultFleetServerHost.host_urls}`
);
}
}
-async function getDefaultFleetServerHost(soClient: SavedObjectsClientContract) {
- const res = await listFleetServerHosts(soClient);
- const fleetServerHosts = res.items;
- return fleetServerHosts.find(
- (fleetServerHost) => fleetServerHost.id === SERVERLESS_DEFAULT_FLEET_SERVER_HOST_ID
- );
-}
-
export const postFleetServerHost: RequestHandler<
undefined,
undefined,
diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/fleet/fleet.ts b/x-pack/test_serverless/api_integration/test_suites/observability/fleet/fleet.ts
index 73f582f9aa9cb..98866bc50f431 100644
--- a/x-pack/test_serverless/api_integration/test_suites/observability/fleet/fleet.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/observability/fleet/fleet.ts
@@ -12,8 +12,7 @@ export default function ({ getService }: FtrProviderContext) {
const svlCommonApi = getService('svlCommonApi');
const supertest = getService('supertest');
- // Failing: See https://github.com/elastic/kibana/issues/176352
- describe.skip('fleet', function () {
+ describe('fleet', function () {
it('rejects request to create a new fleet server hosts if host url is different from default', async () => {
const { body, status } = await supertest
.post('/api/fleet/fleet_server_hosts')
diff --git a/x-pack/test_serverless/api_integration/test_suites/security/fleet/fleet.ts b/x-pack/test_serverless/api_integration/test_suites/security/fleet/fleet.ts
index ba184e7687794..98866bc50f431 100644
--- a/x-pack/test_serverless/api_integration/test_suites/security/fleet/fleet.ts
+++ b/x-pack/test_serverless/api_integration/test_suites/security/fleet/fleet.ts
@@ -12,8 +12,7 @@ export default function ({ getService }: FtrProviderContext) {
const svlCommonApi = getService('svlCommonApi');
const supertest = getService('supertest');
- // FLAKY: https://github.com/elastic/kibana/issues/176399
- describe.skip('fleet', function () {
+ describe('fleet', function () {
it('rejects request to create a new fleet server hosts if host url is different from default', async () => {
const { body, status } = await supertest
.post('/api/fleet/fleet_server_hosts')
From 30f333e392e38d4702356cefb1766ec92297afcc Mon Sep 17 00:00:00 2001
From: Melissa Alvarez
Date: Mon, 12 Feb 2024 11:54:58 -0700
Subject: [PATCH 17/83] [ML] Data Frame Analytics functional tests: ensure job
state is stopped before attempting to create custom url (#176622)
## Summary
Fixes https://github.com/elastic/kibana/issues/164224
Extends timeout to wait for job to be in 'stopped' state before
attempting to create custom url.
Flaky test run
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5120
### Checklist
Delete any items that are not applicable to this PR.
- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../functional/apps/ml/data_frame_analytics/custom_urls.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/custom_urls.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/custom_urls.ts
index 17f07c0d26297..ab1b46796dad4 100644
--- a/x-pack/test/functional/apps/ml/data_frame_analytics/custom_urls.ts
+++ b/x-pack/test/functional/apps/ml/data_frame_analytics/custom_urls.ts
@@ -38,8 +38,7 @@ export default function ({ getService }: FtrProviderContext) {
const ml = getService('ml');
const browser = getService('browser');
- // FLAKY: https://github.com/elastic/kibana/issues/164224
- describe.skip('custom urls', function () {
+ describe('custom urls', function () {
const dfaJobId = `fq_regression_${Date.now()}`;
const generateDestinationIndex = (analyticsId: string) => `user-${analyticsId}`;
let testDashboardId: string | null = null;
@@ -74,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) {
await ml.testResources.createDataViewIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.setKibanaTimeZoneToUTC();
await ml.securityUI.loginAsMlPowerUser();
- await ml.api.createAndRunDFAJob(dfaJobConfig);
+ await ml.api.createAndRunDFAJob(dfaJobConfig, 3 * 60 * 1000);
});
after(async () => {
From c1f39beb379ba0d4d6adc307a48ddbca5a447b72 Mon Sep 17 00:00:00 2001
From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com>
Date: Mon, 12 Feb 2024 11:00:00 -0800
Subject: [PATCH 18/83] [RAM] Add new API to allow for bulk untrack alerts
using query DSL (#176264)
## Summary
Resolves: https://github.com/elastic/kibana/issues/174640
This PR adds a new API:
`/internal/alerting/alerts/_bulk_untrack_by_query` that allows active
alerts to be untracked using ES query DSL. This PR also allows alerts to
be bulk untracked by query using the alerts table.
![image](https://github.com/elastic/kibana/assets/74562234/242934d1-a034-401f-80b7-2c59473974b8)
### To Test:
1. Create 2 rule that generate alerts
2. Navigate to the alerts table, create a filter using the alerts table
search bar to filter 1 of the alerts
3. Select the resulting alert, click "select all alerts"
4. Select "Selected 1 alert" and click "Mark as Untracked"
5. Assert the alert has been untracked
6. Let the rule run again
7. Assert a new active alert should be created
8. Retry steps 1-7 but with a recovered alert. Assert the recover alert
does not get untracked
### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
---------
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../rule/apis/bulk_untrack_by_query/index.ts | 12 +
.../bulk_untrack_by_query/schemas/latest.ts | 7 +
.../apis/bulk_untrack_by_query/schemas/v1.ts | 13 +
.../bulk_untrack_by_query/types/latest.ts | 8 +
.../apis/bulk_untrack_by_query/types/v1.ts | 10 +
.../server/alerts_service/alerts_service.ts | 4 +-
.../lib/set_alerts_to_untracked.test.ts | 170 ++++++++++++
.../lib/set_alerts_to_untracked.ts | 260 +++++++++++-------
.../bulk_untrack/bulk_untrack_alerts.test.ts | 2 +
.../bulk_untrack/bulk_untrack_alerts.ts | 14 +-
.../schemas/bulk_untrack_body_schema.ts | 7 +-
.../plugins/alerting/server/routes/index.ts | 6 +-
.../bulk_untrack_alerts_route.test.ts | 62 +++++
..._route.ts => bulk_untrack_alerts_route.ts} | 9 +-
.../routes/rule/apis/bulk_untrack/index.ts | 2 +-
.../apis/bulk_untrack/transforms/index.ts | 5 +-
.../latest.ts | 2 +-
.../v1.ts | 7 +-
...bulk_untrack_alerts_by_query_route.test.ts | 72 +++++
.../bulk_untrack_alerts_by_query_route.ts | 48 ++++
.../rule/apis/bulk_untrack_by_query/index.ts | 8 +
.../bulk_untrack_by_query/transforms/index.ts | 10 +
.../latest.ts | 8 +
.../v1.ts | 16 ++
.../hooks/use_bulk_actions.test.tsx | 19 +-
.../alerts_table/hooks/use_bulk_actions.ts | 23 +-
.../use_bulk_untrack_alerts_by_query.tsx | 62 +++++
.../tests/alerting/bulk_untrack_by_query.ts | 175 ++++++++++++
.../group1/tests/alerting/index.ts | 1 +
29 files changed, 910 insertions(+), 132 deletions(-)
create mode 100644 x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/index.ts
create mode 100644 x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/latest.ts
create mode 100644 x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/v1.ts
create mode 100644 x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/latest.ts
create mode 100644 x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/v1.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.test.ts
rename x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/{bulk_untrack_alert_route.ts => bulk_untrack_alerts_route.ts} (84%)
rename x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/{transform_request_body_to_application => transform_bulk_untrack_alerts_body}/latest.ts (81%)
rename x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/{transform_request_body_to_application => transform_bulk_untrack_alerts_body}/v1.ts (56%)
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.test.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/index.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/index.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/latest.ts
create mode 100644 x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/v1.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_untrack_alerts_by_query.tsx
create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_untrack_by_query.ts
diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/index.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/index.ts
new file mode 100644
index 0000000000000..2ee7980b3dab5
--- /dev/null
+++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/index.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { bulkUntrackByQueryBodySchema } from './schemas/latest';
+export { bulkUntrackByQueryBodySchema as bulkUntrackByQueryBodySchemaV1 } from './schemas/v1';
+
+export type { BulkUntrackByQueryRequestBody } from './types/latest';
+export type { BulkUntrackByQueryRequestBody as BulkUntrackByQueryRequestBodyV1 } from './types/v1';
diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/latest.ts
new file mode 100644
index 0000000000000..614c13b141edb
--- /dev/null
+++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/latest.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+export { bulkUntrackByQueryBodySchema } from './v1';
diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/v1.ts
new file mode 100644
index 0000000000000..62cc67360b162
--- /dev/null
+++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/schemas/v1.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+export const bulkUntrackByQueryBodySchema = schema.object({
+ query: schema.arrayOf(schema.any()),
+ feature_ids: schema.arrayOf(schema.string()),
+});
diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/latest.ts
new file mode 100644
index 0000000000000..341ff0c24955e
--- /dev/null
+++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/latest.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export type { BulkUntrackByQueryRequestBody } from './v1';
diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/v1.ts
new file mode 100644
index 0000000000000..8e6e7b329c112
--- /dev/null
+++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_untrack_by_query/types/v1.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import type { TypeOf } from '@kbn/config-schema';
+import { bulkUntrackByQueryBodySchemaV1 } from '..';
+
+export type BulkUntrackByQueryRequestBody = TypeOf;
diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts
index 45d48345d5ce9..10161b4e09635 100644
--- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts
+++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts
@@ -45,7 +45,7 @@ import {
import type { LegacyAlertsClientParams, AlertRuleData } from '../alerts_client';
import { AlertsClient } from '../alerts_client';
import { IAlertsClient } from '../alerts_client/types';
-import { setAlertsToUntracked, SetAlertsToUntrackedOpts } from './lib/set_alerts_to_untracked';
+import { setAlertsToUntracked, SetAlertsToUntrackedParams } from './lib/set_alerts_to_untracked';
export const TOTAL_FIELDS_LIMIT = 2500;
const LEGACY_ALERT_CONTEXT = 'legacy-alert';
@@ -466,7 +466,7 @@ export class AlertsService implements IAlertsService {
}
}
- public async setAlertsToUntracked(opts: SetAlertsToUntrackedOpts) {
+ public async setAlertsToUntracked(opts: SetAlertsToUntrackedParams) {
return setAlertsToUntracked({
logger: this.options.logger,
esClient: await this.options.elasticsearchClientPromise,
diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts
index f69ee02464d4d..691fc8548c098 100644
--- a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts
+++ b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts
@@ -9,11 +9,18 @@ import {
elasticsearchServiceMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
+import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils';
import { setAlertsToUntracked } from './set_alerts_to_untracked';
let clusterClient: ElasticsearchClientMock;
let logger: ReturnType;
+const getAuthorizedRuleTypesMock = jest.fn();
+
+const getAlertIndicesAliasMock = jest.fn();
+
+const ensureAuthorizedMock = jest.fn();
+
describe('setAlertsToUntracked()', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -353,4 +360,167 @@ describe('setAlertsToUntracked()', () => {
})
).resolves;
});
+
+ test('should untrack by query', async () => {
+ getAuthorizedRuleTypesMock.mockResolvedValue([
+ {
+ id: 'test-rule-type',
+ },
+ ]);
+ getAlertIndicesAliasMock.mockResolvedValue(['test-alert-index']);
+
+ clusterClient.search.mockResponseOnce({
+ took: 1,
+ timed_out: false,
+ _shards: {
+ total: 1,
+ successful: 1,
+ skipped: 0,
+ failed: 0,
+ },
+ hits: {
+ hits: [],
+ },
+ aggregations: {
+ ruleTypeIds: {
+ buckets: [
+ {
+ key: 'some rule type',
+ consumers: {
+ buckets: [{ key: 'o11y' }],
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ clusterClient.search.mockResponseOnce({
+ took: 1,
+ timed_out: false,
+ _shards: {
+ total: 1,
+ successful: 1,
+ skipped: 0,
+ failed: 0,
+ },
+ hits: {
+ hits: [
+ {
+ _index: 'test-alert-index',
+ _id: 'test-alert-id-1',
+ _source: {
+ [ALERT_RULE_UUID]: 'test-alert-rule-id-1',
+ [ALERT_UUID]: 'test-alert-id-1',
+ },
+ },
+ {
+ _index: 'test-alert-index',
+ _id: 'test-alert-id-2',
+ _source: {
+ [ALERT_RULE_UUID]: 'test-alert-rule-id-2',
+ [ALERT_UUID]: 'test-alert-id-2',
+ },
+ },
+ ],
+ },
+ });
+
+ const result = await setAlertsToUntracked({
+ isUsingQuery: true,
+ query: [
+ {
+ bool: {
+ must: {
+ term: {
+ 'kibana.alert.rule.name': 'test',
+ },
+ },
+ },
+ },
+ ],
+ featureIds: ['o11y'],
+ spaceId: 'default',
+ getAuthorizedRuleTypes: getAuthorizedRuleTypesMock,
+ getAlertIndicesAlias: getAlertIndicesAliasMock,
+ ensureAuthorized: ensureAuthorizedMock,
+ logger,
+ esClient: clusterClient,
+ });
+
+ expect(getAlertIndicesAliasMock).lastCalledWith(['test-rule-type'], 'default');
+
+ expect(clusterClient.updateByQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'kibana.alert.status': {
+ value: 'active', // This has to be active
+ },
+ },
+ },
+ ],
+ filter: [
+ {
+ bool: {
+ must: {
+ term: {
+ 'kibana.alert.rule.name': 'test',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ }),
+ })
+ );
+
+ expect(clusterClient.search).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'kibana.alert.status': {
+ value: 'untracked', // This has to be untracked
+ },
+ },
+ },
+ ],
+ filter: [
+ {
+ bool: {
+ must: {
+ term: {
+ 'kibana.alert.rule.name': 'test',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ }),
+ })
+ );
+
+ expect(result).toEqual([
+ {
+ 'kibana.alert.rule.uuid': 'test-alert-rule-id-1',
+ 'kibana.alert.uuid': 'test-alert-id-1',
+ },
+ {
+ 'kibana.alert.rule.uuid': 'test-alert-rule-id-2',
+ 'kibana.alert.uuid': 'test-alert-id-2',
+ },
+ ]);
+ });
});
diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts
index d0e02fb8b1783..c28a760853ba6 100644
--- a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts
+++ b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts
@@ -20,15 +20,32 @@ import {
ALERT_UUID,
AlertStatus,
} from '@kbn/rule-data-utils';
+import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import { AlertingAuthorizationEntity } from '../../authorization/alerting_authorization';
+import type { RulesClientContext } from '../../rules_client';
-export interface SetAlertsToUntrackedOpts {
- indices: string[];
+type EnsureAuthorized = (opts: { ruleTypeId: string; consumer: string }) => Promise;
+
+export interface SetAlertsToUntrackedParams {
+ indices?: string[];
ruleIds?: string[];
alertUuids?: string[];
- ensureAuthorized?: (opts: { ruleTypeId: string; consumer: string }) => Promise;
+ query?: QueryDslQueryContainer[];
+ spaceId?: RulesClientContext['spaceId'];
+ featureIds?: string[];
+ isUsingQuery?: boolean;
+ getAuthorizedRuleTypes?: RulesClientContext['authorization']['getAuthorizedRuleTypes'];
+ getAlertIndicesAlias?: RulesClientContext['getAlertIndicesAlias'];
+ ensureAuthorized?: EnsureAuthorized;
+}
+
+interface SetAlertsToUntrackedParamsWithDep extends SetAlertsToUntrackedParams {
+ logger: Logger;
+ esClient: ElasticsearchClient;
}
type UntrackedAlertsResult = Array<{ [ALERT_RULE_UUID]: string; [ALERT_UUID]: string }>;
+
interface ConsumersAndRuleTypesAggregation {
ruleTypeIds: {
buckets: Array<{
@@ -40,30 +57,10 @@ interface ConsumersAndRuleTypesAggregation {
};
}
-const getQuery = ({
- ruleIds = [],
- alertUuids = [],
- alertStatus,
-}: {
- ruleIds?: string[];
- alertUuids?: string[];
- alertStatus: AlertStatus;
-}) => {
- const shouldMatchRules: Array<{ term: Record }> = ruleIds.map(
- (ruleId) => ({
- term: {
- [ALERT_RULE_UUID]: { value: ruleId },
- },
- })
- );
- const shouldMatchAlerts: Array<{ term: Record }> = alertUuids.map(
- (alertId) => ({
- term: {
- [ALERT_UUID]: { value: alertId },
- },
- })
- );
-
+const getUntrackQuery = (
+ params: SetAlertsToUntrackedParamsWithDep,
+ alertStatus: AlertStatus
+): QueryDslQueryContainer => {
const statusTerms: Array<{ term: Record }> = [
{
term: {
@@ -72,72 +69,139 @@ const getQuery = ({
},
];
- return [
- ...statusTerms,
- {
+ if (params.isUsingQuery) {
+ const { query } = params;
+ return {
bool: {
- should: shouldMatchRules,
+ must: statusTerms,
+ ...(query ? { filter: query } : {}),
},
- },
- {
+ };
+ } else {
+ const { ruleIds = [], alertUuids = [] } = params;
+ const shouldMatchRules: Array<{ term: Record }> = ruleIds.map(
+ (ruleId) => ({
+ term: {
+ [ALERT_RULE_UUID]: { value: ruleId },
+ },
+ })
+ );
+ const shouldMatchAlerts: Array<{ term: Record }> = alertUuids.map(
+ (alertId) => ({
+ term: {
+ [ALERT_UUID]: { value: alertId },
+ },
+ })
+ );
+
+ return {
bool: {
- should: shouldMatchAlerts,
- // If this is empty, ES will default to minimum_should_match: 0
+ must: [
+ ...statusTerms,
+ {
+ bool: {
+ should: shouldMatchRules,
+ },
+ },
+ {
+ bool: {
+ should: shouldMatchAlerts,
+ // If this is empty, ES will default to minimum_should_match: 0
+ },
+ },
+ ],
},
- },
- ];
+ };
+ }
};
-export async function setAlertsToUntracked({
- logger,
- esClient,
- indices,
- ruleIds = [],
- alertUuids = [], // OPTIONAL - If no alertUuids are passed, untrack ALL ids by default,
- ensureAuthorized,
-}: {
- logger: Logger;
- esClient: ElasticsearchClient;
-} & SetAlertsToUntrackedOpts): Promise {
- if (isEmpty(ruleIds) && isEmpty(alertUuids))
- throw new Error('Must provide either ruleIds or alertUuids');
+const ensureAuthorizedToUntrack = async (params: SetAlertsToUntrackedParamsWithDep) => {
+ const { esClient, indices, ensureAuthorized } = params;
- if (ensureAuthorized) {
- // Fetch all rule type IDs and rule consumers, then run the provided ensureAuthorized check for each of them
- const response = await esClient.search({
- index: indices,
- allow_no_indices: true,
- body: {
- size: 0,
- query: {
- bool: {
- must: getQuery({
- ruleIds,
- alertUuids,
- alertStatus: ALERT_STATUS_ACTIVE,
- }),
- },
- },
- aggs: {
- ruleTypeIds: {
- terms: { field: ALERT_RULE_TYPE_ID },
- aggs: { consumers: { terms: { field: ALERT_RULE_CONSUMER } } },
- },
+ if (!ensureAuthorized) {
+ return;
+ }
+ // Fetch all rule type IDs and rule consumers, then run the provided ensureAuthorized check for each of them
+ const response = await esClient.search({
+ index: indices,
+ allow_no_indices: true,
+ body: {
+ size: 0,
+ query: getUntrackQuery(params, ALERT_STATUS_ACTIVE),
+ aggs: {
+ ruleTypeIds: {
+ terms: { field: ALERT_RULE_TYPE_ID },
+ aggs: { consumers: { terms: { field: ALERT_RULE_CONSUMER } } },
},
},
- });
- const ruleTypeIdBuckets = response.aggregations?.ruleTypeIds.buckets;
- if (!ruleTypeIdBuckets) throw new Error('Unable to fetch ruleTypeIds for authorization');
- for (const {
- key: ruleTypeId,
- consumers: { buckets: consumerBuckets },
- } of ruleTypeIdBuckets) {
- const consumers = consumerBuckets.map((b) => b.key);
- for (const consumer of consumers) {
- if (consumer === 'siem') throw new Error('Untracking Security alerts is not permitted');
- await ensureAuthorized({ ruleTypeId, consumer });
+ },
+ });
+ const ruleTypeIdBuckets = response.aggregations?.ruleTypeIds.buckets;
+ if (!ruleTypeIdBuckets) {
+ throw new Error('Unable to fetch ruleTypeIds for authorization');
+ }
+ for (const {
+ key: ruleTypeId,
+ consumers: { buckets: consumerBuckets },
+ } of ruleTypeIdBuckets) {
+ const consumers = consumerBuckets.map((b) => b.key);
+ for (const consumer of consumers) {
+ if (consumer === 'siem') {
+ throw new Error('Untracking Security alerts is not permitted');
}
+ await ensureAuthorized({ ruleTypeId, consumer });
+ }
+ }
+};
+
+const getAuthorizedAlertsIndices = async ({
+ featureIds,
+ getAuthorizedRuleTypes,
+ getAlertIndicesAlias,
+ spaceId,
+ logger,
+}: SetAlertsToUntrackedParamsWithDep) => {
+ try {
+ const authorizedRuleTypes =
+ (await getAuthorizedRuleTypes?.(AlertingAuthorizationEntity.Alert, new Set(featureIds))) ||
+ [];
+ const indices = getAlertIndicesAlias?.(
+ authorizedRuleTypes.map((art: { id: string }) => art.id),
+ spaceId
+ );
+ return indices;
+ } catch (error) {
+ const errMessage = `Failed to get authorized rule types to untrack alerts by query: ${error}`;
+ logger.error(errMessage);
+ throw new Error(errMessage);
+ }
+};
+
+export async function setAlertsToUntracked(
+ params: SetAlertsToUntrackedParamsWithDep
+): Promise {
+ const {
+ logger,
+ esClient,
+ ruleIds = [],
+ alertUuids = [], // OPTIONAL - If no alertUuids are passed, untrack ALL ids by default,
+ ensureAuthorized,
+ isUsingQuery,
+ } = params;
+
+ let indices: string[];
+
+ if (isUsingQuery) {
+ indices = (await getAuthorizedAlertsIndices(params)) || [];
+ } else {
+ if (isEmpty(ruleIds) && isEmpty(alertUuids)) {
+ throw new Error('Must provide either ruleIds or alertUuids');
}
+ indices = params.indices || [];
+ }
+
+ if (ensureAuthorized) {
+ await ensureAuthorizedToUntrack(params);
}
try {
@@ -154,22 +218,20 @@ export async function setAlertsToUntracked({
source: getUntrackUpdatePainlessScript(new Date()),
lang: 'painless',
},
- query: {
- bool: {
- must: getQuery({
- ruleIds,
- alertUuids,
- alertStatus: ALERT_STATUS_ACTIVE,
- }),
- },
- },
+ query: getUntrackQuery(params, ALERT_STATUS_ACTIVE),
},
refresh: true,
});
- if (total === 0 && response.total === 0)
+
+ if (total === 0 && response.total === 0) {
throw new Error('No active alerts matched the query');
- if (response.total) total = response.total;
- if (response.total === response.updated) break;
+ }
+ if (response.total) {
+ total = response.total;
+ }
+ if (response.total === response.updated) {
+ break;
+ }
logger.warn(
`Attempt ${retryCount + 1}: Failed to untrack ${
(response.total ?? 0) - (response.updated ?? 0)
@@ -186,15 +248,7 @@ export async function setAlertsToUntracked({
body: {
_source: [ALERT_RULE_UUID, ALERT_UUID],
size: total,
- query: {
- bool: {
- must: getQuery({
- ruleIds,
- alertUuids,
- alertStatus: ALERT_STATUS_UNTRACKED,
- }),
- },
- },
+ query: getUntrackQuery(params, ALERT_STATUS_UNTRACKED),
},
});
return searchResponse.hits.hits.map((hit) => hit._source) as UntrackedAlertsResult;
diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts
index f8e8b1288634a..78485744e8103 100644
--- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts
+++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts
@@ -79,6 +79,7 @@ describe('bulkUntrackAlerts()', () => {
]);
await rulesClient.bulkUntrackAlerts({
+ isUsingQuery: true,
indices: [
'she had them apple bottom jeans (jeans)',
'boots with the fur (with the fur)',
@@ -160,6 +161,7 @@ describe('bulkUntrackAlerts()', () => {
]);
await rulesClient.bulkUntrackAlerts({
+ isUsingQuery: true,
indices: ["honestly who cares we're not even testing the index right now"],
alertUuids: [mockAlertUuid],
});
diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts
index e3c75f2889198..d3a1badd0ab33 100644
--- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts
+++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts
@@ -35,15 +35,17 @@ export async function bulkUntrackAlerts(
);
}
-async function bulkUntrackAlertsWithOCC(
- context: RulesClientContext,
- { indices, alertUuids }: BulkUntrackBody
-) {
+async function bulkUntrackAlertsWithOCC(context: RulesClientContext, params: BulkUntrackBody) {
try {
if (!context.alertsService) throw new Error('unable to access alertsService');
const result = await context.alertsService.setAlertsToUntracked({
- indices,
- alertUuids,
+ ...params,
+ featureIds: params.featureIds || [],
+ spaceId: context.spaceId,
+ getAlertIndicesAlias: context.getAlertIndicesAlias,
+ getAuthorizedRuleTypes: context.authorization.getAuthorizedRuleTypes.bind(
+ context.authorization
+ ),
ensureAuthorized: async ({
ruleTypeId,
consumer,
diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/schemas/bulk_untrack_body_schema.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/schemas/bulk_untrack_body_schema.ts
index 9c77a6e6b2b3a..f597d9f5b6fa4 100644
--- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/schemas/bulk_untrack_body_schema.ts
+++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/schemas/bulk_untrack_body_schema.ts
@@ -7,6 +7,9 @@
import { schema } from '@kbn/config-schema';
export const bulkUntrackBodySchema = schema.object({
- indices: schema.arrayOf(schema.string()),
- alertUuids: schema.arrayOf(schema.string()),
+ isUsingQuery: schema.boolean(),
+ indices: schema.maybe(schema.arrayOf(schema.string())),
+ alertUuids: schema.maybe(schema.arrayOf(schema.string())),
+ query: schema.maybe(schema.arrayOf(schema.any())),
+ featureIds: schema.maybe(schema.arrayOf(schema.string())),
});
diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts
index d28854fb3ac6a..74d8179111d3d 100644
--- a/x-pack/plugins/alerting/server/routes/index.ts
+++ b/x-pack/plugins/alerting/server/routes/index.ts
@@ -48,7 +48,8 @@ import { getFlappingSettingsRoute } from './get_flapping_settings';
import { updateFlappingSettingsRoute } from './update_flapping_settings';
import { getRuleTagsRoute } from './rule/apis/tags/get_rule_tags';
import { getScheduleFrequencyRoute } from './rule/apis/get_schedule_frequency';
-import { bulkUntrackAlertRoute } from './rule/apis/bulk_untrack';
+import { bulkUntrackAlertsRoute } from './rule/apis/bulk_untrack';
+import { bulkUntrackAlertsByQueryRoute } from './rule/apis/bulk_untrack_by_query';
import { createMaintenanceWindowRoute } from './maintenance_window/apis/create/create_maintenance_window_route';
import { getMaintenanceWindowRoute } from './maintenance_window/apis/get/get_maintenance_window_route';
@@ -134,7 +135,8 @@ export function defineRoutes(opts: RouteOptions) {
registerFieldsRoute(router, licenseState);
bulkGetMaintenanceWindowRoute(router, licenseState);
getScheduleFrequencyRoute(router, licenseState);
- bulkUntrackAlertRoute(router, licenseState);
+ bulkUntrackAlertsRoute(router, licenseState);
+ bulkUntrackAlertsByQueryRoute(router, licenseState);
getQueryDelaySettingsRoute(router, licenseState);
updateQueryDelaySettingsRoute(router, licenseState);
}
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.test.ts
new file mode 100644
index 0000000000000..ab328900dab9a
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.test.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { httpServiceMock } from '@kbn/core/server/mocks';
+import { bulkUntrackAlertsRoute } from './bulk_untrack_alerts_route';
+import { licenseStateMock } from '../../../../lib/license_state.mock';
+import { mockHandlerArguments } from '../../../_mock_handler_arguments';
+import { rulesClientMock } from '../../../../rules_client.mock';
+
+const rulesClient = rulesClientMock.create();
+
+jest.mock('../../../../lib/license_api_access', () => ({
+ verifyApiAccess: jest.fn(),
+}));
+beforeEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('bulkUntrackAlertsRoute', () => {
+ it('should call bulkUntrack with the proper values', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ bulkUntrackAlertsRoute(router, licenseState);
+
+ const [config, handler] = router.post.mock.calls[0];
+
+ expect(config.path).toBe('/internal/alerting/alerts/_bulk_untrack');
+
+ rulesClient.bulkUntrackAlerts.mockResolvedValueOnce();
+
+ const requestBody = {
+ indices: ['test-index'],
+ alert_uuids: ['id1', 'id2'],
+ };
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ body: requestBody,
+ },
+ ['ok']
+ );
+
+ expect(await handler(context, req, res)).toEqual(undefined);
+
+ expect(rulesClient.bulkUntrackAlerts).toHaveBeenCalledTimes(1);
+ expect(rulesClient.bulkUntrackAlerts.mock.calls[0]).toEqual([
+ {
+ indices: requestBody.indices,
+ alertUuids: requestBody.alert_uuids,
+ isUsingQuery: false,
+ },
+ ]);
+
+ expect(res.noContent).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alert_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.ts
similarity index 84%
rename from x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alert_route.ts
rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.ts
index 791fdcca533cc..3539db62d0649 100644
--- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alert_route.ts
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/bulk_untrack_alerts_route.ts
@@ -9,12 +9,12 @@ import {
BulkUntrackRequestBodyV1,
bulkUntrackBodySchemaV1,
} from '../../../../../common/routes/rule/apis/bulk_untrack';
-import { transformRequestBodyToApplicationV1 } from './transforms';
+import { transformBulkUntrackAlertsBodyV1 } from './transforms';
import { ILicenseState, RuleTypeDisabledError } from '../../../../lib';
import { verifyAccessAndContext } from '../../../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
-export const bulkUntrackAlertRoute = (
+export const bulkUntrackAlertsRoute = (
router: IRouter,
licenseState: ILicenseState
) => {
@@ -30,7 +30,10 @@ export const bulkUntrackAlertRoute = (
const rulesClient = (await context.alerting).getRulesClient();
const body: BulkUntrackRequestBodyV1 = req.body;
try {
- await rulesClient.bulkUntrackAlerts(transformRequestBodyToApplicationV1(body));
+ await rulesClient.bulkUntrackAlerts({
+ ...transformBulkUntrackAlertsBodyV1(body),
+ isUsingQuery: false,
+ });
return res.noContent();
} catch (e) {
if (e instanceof RuleTypeDisabledError) {
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/index.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/index.ts
index b453f47ecbb6e..3f50182814bb9 100644
--- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/index.ts
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/index.ts
@@ -5,4 +5,4 @@
* 2.0.
*/
-export { bulkUntrackAlertRoute } from './bulk_untrack_alert_route';
+export { bulkUntrackAlertsRoute } from './bulk_untrack_alerts_route';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/index.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/index.ts
index aa4eae3d633cf..0f11ca4a535b5 100644
--- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/index.ts
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/index.ts
@@ -4,5 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-export { transformRequestBodyToApplication } from './transform_request_body_to_application/latest';
-export { transformRequestBodyToApplication as transformRequestBodyToApplicationV1 } from './transform_request_body_to_application/v1';
+
+export { transformBulkUntrackAlertsBody } from './transform_bulk_untrack_alerts_body/latest';
+export { transformBulkUntrackAlertsBody as transformBulkUntrackAlertsBodyV1 } from './transform_bulk_untrack_alerts_body/v1';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/latest.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/latest.ts
similarity index 81%
rename from x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/latest.ts
rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/latest.ts
index 3dab7ef9587fb..6436c98ae0f1f 100644
--- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/latest.ts
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/latest.ts
@@ -5,4 +5,4 @@
* 2.0.
*/
-export { transformRequestBodyToApplication } from './v1';
+export { transformBulkUntrackAlertsBody } from './v1';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/v1.ts
similarity index 56%
rename from x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/v1.ts
rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/v1.ts
index 0a0750bf45b1b..2e745967ea665 100644
--- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_request_body_to_application/v1.ts
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack/transforms/transform_bulk_untrack_alerts_body/v1.ts
@@ -5,13 +5,12 @@
* 2.0.
*/
-import { RewriteRequestCase } from '../../../../../lib';
-import { BulkUntrackBody } from '../../../../../../application/rule/methods/bulk_untrack/types';
+import type { BulkUntrackRequestBodyV1 } from '../../../../../../../common/routes/rule/apis/bulk_untrack';
-export const transformRequestBodyToApplication: RewriteRequestCase = ({
+export const transformBulkUntrackAlertsBody = ({
indices,
alert_uuids: alertUuids,
-}) => ({
+}: BulkUntrackRequestBodyV1) => ({
indices,
alertUuids,
});
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.test.ts
new file mode 100644
index 0000000000000..3486005d3b588
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.test.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { httpServiceMock } from '@kbn/core/server/mocks';
+import { bulkUntrackAlertsByQueryRoute } from './bulk_untrack_alerts_by_query_route';
+import { licenseStateMock } from '../../../../lib/license_state.mock';
+import { mockHandlerArguments } from '../../../_mock_handler_arguments';
+import { rulesClientMock } from '../../../../rules_client.mock';
+
+const rulesClient = rulesClientMock.create();
+
+jest.mock('../../../../lib/license_api_access', () => ({
+ verifyApiAccess: jest.fn(),
+}));
+beforeEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('bulkUntrackAlertsByQueryRoute', () => {
+ it('should call bulkUntrack with the proper values', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ bulkUntrackAlertsByQueryRoute(router, licenseState);
+
+ const [config, handler] = router.post.mock.calls[0];
+
+ expect(config.path).toBe('/internal/alerting/alerts/_bulk_untrack_by_query');
+
+ rulesClient.bulkUntrackAlerts.mockResolvedValueOnce();
+
+ const requestBody = {
+ query: [
+ {
+ bool: {
+ must: {
+ term: {
+ 'kibana.alert.rule.name': 'test',
+ },
+ },
+ },
+ },
+ ],
+ feature_ids: ['o11y'],
+ };
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ body: requestBody,
+ },
+ ['ok']
+ );
+
+ expect(await handler(context, req, res)).toEqual(undefined);
+
+ expect(rulesClient.bulkUntrackAlerts).toHaveBeenCalledTimes(1);
+ expect(rulesClient.bulkUntrackAlerts.mock.calls[0]).toEqual([
+ {
+ query: requestBody.query,
+ featureIds: requestBody.feature_ids,
+ isUsingQuery: true,
+ },
+ ]);
+
+ expect(res.noContent).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.ts
new file mode 100644
index 0000000000000..92b43570772c7
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/bulk_untrack_alerts_by_query_route.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { IRouter } from '@kbn/core/server';
+import {
+ BulkUntrackByQueryRequestBodyV1,
+ bulkUntrackByQueryBodySchemaV1,
+} from '../../../../../common/routes/rule/apis/bulk_untrack_by_query';
+import { ILicenseState, RuleTypeDisabledError } from '../../../../lib';
+import { verifyAccessAndContext } from '../../../lib';
+import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
+import { transformBulkUntrackAlertsByQueryBodyV1 } from './transforms';
+
+export const bulkUntrackAlertsByQueryRoute = (
+ router: IRouter,
+ licenseState: ILicenseState
+) => {
+ router.post(
+ {
+ path: `${INTERNAL_BASE_ALERTING_API_PATH}/alerts/_bulk_untrack_by_query`,
+ validate: {
+ body: bulkUntrackByQueryBodySchemaV1,
+ },
+ },
+ router.handleLegacyErrors(
+ verifyAccessAndContext(licenseState, async function (context, req, res) {
+ const rulesClient = (await context.alerting).getRulesClient();
+ const body: BulkUntrackByQueryRequestBodyV1 = req.body;
+ try {
+ await rulesClient.bulkUntrackAlerts({
+ ...transformBulkUntrackAlertsByQueryBodyV1(body),
+ isUsingQuery: true,
+ });
+ return res.noContent();
+ } catch (e) {
+ if (e instanceof RuleTypeDisabledError) {
+ return e.sendResponse(res);
+ }
+ throw e;
+ }
+ })
+ )
+ );
+};
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/index.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/index.ts
new file mode 100644
index 0000000000000..b22cfb075457d
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { bulkUntrackAlertsByQueryRoute } from './bulk_untrack_alerts_by_query_route';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/index.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/index.ts
new file mode 100644
index 0000000000000..71d3212f4a3bc
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/index.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { transformBulkUntrackAlertsByQueryBody } from './transform_bulk_untrack_alerts_by_query_body/latest';
+
+export { transformBulkUntrackAlertsByQueryBody as transformBulkUntrackAlertsByQueryBodyV1 } from './transform_bulk_untrack_alerts_by_query_body/v1';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/latest.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/latest.ts
new file mode 100644
index 0000000000000..73fdbd68378d6
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/latest.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { transformBulkUntrackAlertsByQueryBody } from './v1';
diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/v1.ts
new file mode 100644
index 0000000000000..87c58c48c4c6d
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_untrack_by_query/transforms/transform_bulk_untrack_alerts_by_query_body/v1.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { BulkUntrackByQueryRequestBodyV1 } from '../../../../../../../common/routes/rule/apis/bulk_untrack_by_query';
+
+export const transformBulkUntrackAlertsByQueryBody = ({
+ query,
+ feature_ids: featureIds,
+}: BulkUntrackByQueryRequestBodyV1) => ({
+ query,
+ featureIds,
+});
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.test.tsx
index 9fa320531a959..84b9180733e83 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.test.tsx
@@ -308,7 +308,22 @@ describe('bulk action hooks', () => {
},
}));
const { result } = renderHook(
- () => useBulkUntrackActions({ setIsBulkActionsLoading, refresh, clearSelection }),
+ () =>
+ useBulkUntrackActions({
+ setIsBulkActionsLoading,
+ refresh,
+ clearSelection,
+ isAllSelected: true,
+ query: {
+ bool: {
+ must: {
+ term: {
+ test: 'test',
+ },
+ },
+ },
+ },
+ }),
{
wrapper: appMockRender.AppWrapper,
}
@@ -361,7 +376,7 @@ describe('bulk action hooks', () => {
},
Object {
"data-test-subj": "mark-as-untracked",
- "disableOnQuery": true,
+ "disableOnQuery": false,
"disabledLabel": "Mark as untracked",
"key": "mark-as-untracked",
"label": "Mark as untracked",
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts
index 9b692b3c1b16a..aec60b89e5e1e 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts
@@ -32,6 +32,7 @@ import {
} from './translations';
import { TimelineItem } from '../bulk_actions/components/toolbar';
import { useBulkUntrackAlerts } from './use_bulk_untrack_alerts';
+import { useBulkUntrackAlertsByQuery } from './use_bulk_untrack_alerts_by_query';
interface BulkActionsProps {
query: Pick;
@@ -54,8 +55,10 @@ export interface UseBulkActions {
type UseBulkAddToCaseActionsProps = Pick &
Pick;
-type UseBulkUntrackActionsProps = Pick &
- Pick;
+type UseBulkUntrackActionsProps = Pick &
+ Pick & {
+ isAllSelected: boolean;
+ };
const filterAlertsAlreadyAttachedToCase = (alerts: TimelineItem[], caseId: string) =>
alerts.filter(
@@ -181,6 +184,9 @@ export const useBulkUntrackActions = ({
setIsBulkActionsLoading,
refresh,
clearSelection,
+ query,
+ featureIds = [],
+ isAllSelected,
}: UseBulkUntrackActionsProps) => {
const onSuccess = useCallback(() => {
refresh();
@@ -189,6 +195,8 @@ export const useBulkUntrackActions = ({
const { application } = useKibana().services;
const { mutateAsync: untrackAlerts } = useBulkUntrackAlerts();
+ const { mutateAsync: untrackAlertsByQuery } = useBulkUntrackAlertsByQuery();
+
// Check if at least one Observability feature is enabled
if (!application?.capabilities) return [];
const hasApmPermission = application.capabilities.apm?.['alerting:show'];
@@ -212,7 +220,7 @@ export const useBulkUntrackActions = ({
{
label: MARK_AS_UNTRACKED,
key: 'mark-as-untracked',
- disableOnQuery: true,
+ disableOnQuery: false,
disabledLabel: MARK_AS_UNTRACKED,
'data-test-subj': 'mark-as-untracked',
onClick: async (alerts?: TimelineItem[]) => {
@@ -221,7 +229,11 @@ export const useBulkUntrackActions = ({
const indices = alerts.map((alert) => alert._index ?? '');
try {
setIsBulkActionsLoading(true);
- await untrackAlerts({ indices, alertUuids });
+ if (isAllSelected) {
+ await untrackAlertsByQuery({ query, featureIds });
+ } else {
+ await untrackAlerts({ indices, alertUuids });
+ }
onSuccess();
} finally {
setIsBulkActionsLoading(false);
@@ -255,6 +267,9 @@ export function useBulkActions({
setIsBulkActionsLoading,
refresh,
clearSelection,
+ query,
+ featureIds,
+ isAllSelected: bulkActionsState.isAllSelected,
});
const initialItems = [
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_untrack_alerts_by_query.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_untrack_alerts_by_query.tsx
new file mode 100644
index 0000000000000..3ef0efd7faad8
--- /dev/null
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_untrack_alerts_by_query.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { useMutation } from '@tanstack/react-query';
+import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import { INTERNAL_BASE_ALERTING_API_PATH } from '@kbn/alerting-plugin/common';
+import { ValidFeatureId } from '@kbn/rule-data-utils';
+import { AlertsTableQueryContext } from '../contexts/alerts_table_context';
+import { useKibana } from '../../../../common';
+
+export const useBulkUntrackAlertsByQuery = () => {
+ const {
+ http,
+ notifications: { toasts },
+ } = useKibana().services;
+
+ const untrackAlertsByQuery = useMutation<
+ string,
+ string,
+ { query: Pick; featureIds: ValidFeatureId[] }
+ >(
+ ['untrackAlerts'],
+ ({ query, featureIds }) => {
+ try {
+ const body = JSON.stringify({
+ query: Array.isArray(query) ? query : [query],
+ feature_ids: featureIds,
+ });
+ return http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/alerts/_bulk_untrack_by_query`, {
+ body,
+ });
+ } catch (e) {
+ throw new Error(`Unable to parse bulk untrack by query params: ${e}`);
+ }
+ },
+ {
+ context: AlertsTableQueryContext,
+ onError: () => {
+ toasts.addDanger(
+ i18n.translate('xpack.triggersActionsUI.alertsTable.untrackByQuery.failedMessage', {
+ defaultMessage: 'Failed to untrack alerts by query',
+ })
+ );
+ },
+
+ onSuccess: () => {
+ toasts.addSuccess(
+ i18n.translate('xpack.triggersActionsUI.alertsTable.untrackByQuery.successMessage', {
+ defaultMessage: 'Untracked alerts',
+ })
+ );
+ },
+ }
+ );
+
+ return untrackAlertsByQuery;
+};
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_untrack_by_query.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_untrack_by_query.ts
new file mode 100644
index 0000000000000..a1c799cce9529
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_untrack_by_query.ts
@@ -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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
+import { ALERT_STATUS, ALERT_RULE_NAME } from '@kbn/rule-data-utils';
+import { getUrlPrefix, ObjectRemover, getTestRuleData, getEventLog } from '../../../../common/lib';
+import { FtrProviderContext } from '../../../../common/ftr_provider_context';
+import { UserAtSpaceScenarios } from '../../../scenarios';
+
+const alertAsDataIndex = '.internal.alerts-observability.test.alerts.alerts-default-000001';
+
+// eslint-disable-next-line import/no-default-export
+export default function bulkUntrackByQueryTests({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const supertestWithoutAuth = getService('supertestWithoutAuth');
+ const retry = getService('retry');
+ const es = getService('es');
+
+ describe('bulk untrack by query', () => {
+ const objectRemover = new ObjectRemover(supertest);
+
+ afterEach(async () => {
+ await es.deleteByQuery({
+ index: alertAsDataIndex,
+ query: {
+ match_all: {},
+ },
+ conflicts: 'proceed',
+ });
+ objectRemover.removeAll();
+ });
+
+ for (const scenario of UserAtSpaceScenarios) {
+ describe(scenario.id, async () => {
+ it('should bulk mark alerts as untracked by query', async () => {
+ const { body: createdRule1 } = await supertest
+ .post(`${getUrlPrefix(scenario.space.id)}/api/alerting/rule`)
+ .set('kbn-xsrf', 'foo')
+ .send(
+ getTestRuleData({
+ name: 'rule1',
+ rule_type_id: 'test.always-firing-alert-as-data',
+ schedule: { interval: '24h' },
+ throttle: undefined,
+ notify_when: undefined,
+ params: {
+ index: ES_TEST_INDEX_NAME,
+ reference: 'test',
+ },
+ })
+ )
+ .expect(200);
+ objectRemover.add(scenario.space.id, createdRule1.id, 'rule', 'alerting');
+
+ const { body: createdRule2 } = await supertest
+ .post(`${getUrlPrefix(scenario.space.id)}/api/alerting/rule`)
+ .set('kbn-xsrf', 'foo')
+ .send(
+ getTestRuleData({
+ name: 'rule2',
+ rule_type_id: 'test.always-firing-alert-as-data',
+ schedule: { interval: '24h' },
+ throttle: undefined,
+ notify_when: undefined,
+ params: {
+ index: ES_TEST_INDEX_NAME,
+ reference: 'test',
+ },
+ })
+ )
+ .expect(200);
+ objectRemover.add(scenario.space.id, createdRule2.id, 'rule', 'alerting');
+
+ await retry.try(async () => {
+ return await getEventLog({
+ getService,
+ spaceId: scenario.space.id,
+ type: 'alert',
+ id: createdRule1.id,
+ provider: 'alerting',
+ actions: new Map([['active-instance', { equal: 2 }]]),
+ });
+ });
+
+ await retry.try(async () => {
+ return await getEventLog({
+ getService,
+ spaceId: scenario.space.id,
+ type: 'alert',
+ id: createdRule2.id,
+ provider: 'alerting',
+ actions: new Map([['active-instance', { equal: 2 }]]),
+ });
+ });
+
+ await retry.try(async () => {
+ const {
+ hits: { hits: activeAlerts },
+ } = await es.search({
+ index: alertAsDataIndex,
+ body: { query: { match_all: {} } },
+ });
+
+ activeAlerts.forEach((activeAlert: any) => {
+ expect(activeAlert._source[ALERT_STATUS]).eql('active');
+ });
+ });
+
+ const response = await supertestWithoutAuth
+ .post(
+ `${getUrlPrefix(scenario.space.id)}/internal/alerting/alerts/_bulk_untrack_by_query`
+ )
+ .set('kbn-xsrf', 'foo')
+ .auth(scenario.user.username, scenario.user.password)
+ .send({
+ query: [
+ {
+ bool: {
+ must: {
+ term: {
+ 'kibana.alert.rule.name': 'rule1',
+ },
+ },
+ },
+ },
+ ],
+ feature_ids: ['alertsFixture'],
+ });
+
+ switch (scenario.id) {
+ case 'no_kibana_privileges at space1':
+ case 'space_1_all at space1':
+ case 'space_1_all at space2':
+ case 'global_read at space1':
+ case 'space_1_all_alerts_none_actions at space1':
+ case 'space_1_all_with_restricted_fixture at space1':
+ expect(response.statusCode).to.eql(403);
+ break;
+ case 'superuser at space1':
+ expect(response.statusCode).to.eql(204);
+ await retry.try(async () => {
+ const untrackedAlert = [];
+
+ const {
+ hits: { hits },
+ } = await es.search({
+ index: alertAsDataIndex,
+ body: { query: { match_all: {} } },
+ });
+
+ hits.forEach((alert: any) => {
+ if (
+ alert._source[ALERT_RULE_NAME] === 'rule1' &&
+ alert._source[ALERT_STATUS] === 'untracked'
+ ) {
+ untrackedAlert.push(alert);
+ }
+ });
+
+ expect(untrackedAlert.length).eql(2);
+ });
+ break;
+ default:
+ throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
+ }
+ });
+ });
+ }
+ });
+}
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts
index 8c0fb30872ac3..149990d5dcf89 100644
--- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts
@@ -33,6 +33,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./rule_types'));
loadTestFile(require.resolve('./retain_api_key'));
loadTestFile(require.resolve('./bulk_untrack'));
+ loadTestFile(require.resolve('./bulk_untrack_by_query'));
});
});
}
From ef759f6d367b6accf7c3b8a26416fdc7afa43f8b Mon Sep 17 00:00:00 2001
From: jennypavlova
Date: Mon, 12 Feb 2024 20:05:02 +0100
Subject: [PATCH 19/83] [Infra] Make k8s section collapsible (#176734)
Closes #176733
## Summary
This PR makes the Kubernetes Overview section on the Asset details page
collapsible
## Testing
- Open the asset details page
- Check the metrics section (the Kubernetes Overview section should be
collapsible)
https://github.com/elastic/kibana/assets/14139027/61fe91e4-1cd0-4dd8-b81f-402a04e47fcc
---
.../asset_details/tabs/overview/metrics/metrics_section.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_section.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_section.tsx
index dd6ac4b485a80..ec50deb11f2cf 100644
--- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_section.tsx
+++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/metrics/metrics_section.tsx
@@ -58,7 +58,11 @@ export const MetricsSection = ({ assetName, metricsDataView, logsDataView, dateR
filterFieldName={model.fields.name}
/>
-
+
Date: Mon, 12 Feb 2024 20:09:40 +0100
Subject: [PATCH 20/83] Fix PIT issue (#176699)
---
.../security_solution/server/lib/telemetry/receiver.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts
index b3dc89d613d67..6571822e57461 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts
@@ -450,7 +450,6 @@ export class TelemetryReceiver implements ITelemetryReceiver {
pit: { id: pitId },
search_after: searchAfter,
size: telemetryConfiguration.telemetry_max_buffer_size,
- expand_wildcards: ['open' as const, 'hidden' as const],
};
let response = null;
@@ -461,7 +460,7 @@ export class TelemetryReceiver implements ITelemetryReceiver {
if (numOfHits > 0) {
const lastHit = response?.hits.hits[numOfHits - 1];
- searchAfter = lastHit?.sort;
+ query.search_after = lastHit?.sort;
} else {
fetchMore = false;
}
@@ -807,6 +806,7 @@ export class TelemetryReceiver implements ITelemetryReceiver {
await this.esClient.openPointInTime({
index: `${indexPattern}*`,
keep_alive: keepAlive,
+ expand_wildcards: ['open' as const, 'hidden' as const],
})
).id;
From d0dfc33679447a8353eb070245bf7cedb2482cd7 Mon Sep 17 00:00:00 2001
From: Alexi Doak <109488926+doakalexi@users.noreply.github.com>
Date: Mon, 12 Feb 2024 11:16:09 -0800
Subject: [PATCH 21/83] [ResponseOps] Include alert creation delay in the event
log (#176348)
Resolves https://github.com/elastic/kibana/issues/175941
## Summary
Adds a new field, `number_of_delayed_alerts`, to the event log for a
rule run.
It's a count for all the delayed alerts, I opted to go this route
instead of counting the number of times each alert was delayed. Pls let
me know if you would like to go another way or would like to add any
other metrics :)
### To verify
-Go to Dev Tools
- Create a rule with the alert delay
```
POST kbn:/api/alerting/rule
{
"params": {
"searchType": "esQuery",
"timeWindowSize": 5,
"timeWindowUnit": "m",
"threshold": [
-1
],
"thresholdComparator": ">",
"size": 100,
"esQuery": """{
"query":{
"match_all" : {}
}
}""",
"aggType": "count",
"groupBy": "all",
"termSize": 5,
"excludeHitsFromPreviousRun": false,
"sourceFields": [],
"index": [
".kibana-event-log*"
],
"timeField": "@timestamp"
},
"consumer": "stackAlerts",
"schedule": {
"interval": "1m"
},
"tags": [],
"name": "test",
"rule_type_id": ".es-query",
"actions": [
{
"group": "query matched",
"id": "${ACTION_ID}",
"params": {
"level": "info",
"message": """Elasticsearch query rule '{{rule.name}}' is active:
- Value: {{context.value}}
- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}
- Timestamp: {{context.date}}
- Link: {{context.link}}"""
},
"frequency": {
"notify_when": "onActionGroupChange",
"throttle": null,
"summary": false
}
}
],
"alert_delay": {
"active": 3
}
}
```
- Let the rule run and then run the following to look at the event log
- Verify that there is a new field `number_of_delayed_alerts` and that
the counts what you would would expect for a rule running with the
alert_delay
---
.../alerts_client/legacy_alerts_client.ts | 3 +
.../alerting/server/alerts_client/types.ts | 1 +
.../alerting_event_logger.test.ts | 5 +
.../alerting_event_logger.ts | 1 +
.../lib/get_alerts_for_notification.test.ts | 140 ++++++++++--------
.../server/lib/get_alerts_for_notification.ts | 3 +
.../server/lib/last_run_status.test.ts | 1 +
.../server/lib/rule_execution_status.test.ts | 1 +
.../server/lib/rule_run_metrics_store.mock.ts | 2 +
.../server/lib/rule_run_metrics_store.test.ts | 7 +
.../server/lib/rule_run_metrics_store.ts | 8 +
.../server/task_runner/task_runner.test.ts | 16 +-
.../server/task_runner/task_runner.ts | 1 +
.../task_runner_alerts_client.test.ts | 3 +-
.../task_runner/task_runner_cancel.test.ts | 3 +-
.../plugins/event_log/generated/mappings.json | 3 +
x-pack/plugins/event_log/generated/schemas.ts | 1 +
x-pack/plugins/event_log/scripts/mappings.js | 3 +
.../tests/alerting/group1/event_log.ts | 6 +
.../alerts_as_data_alert_delay.ts | 11 ++
20 files changed, 152 insertions(+), 67 deletions(-)
diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts
index a5cfc642a19ee..8c9bf91d17ed8 100644
--- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts
+++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts
@@ -141,6 +141,7 @@ export class LegacyAlertsClient<
flappingSettings,
maintenanceWindowIds,
alertDelay,
+ ruleRunMetricsStore,
}: ProcessAlertsOpts) {
const {
newAlerts: processedAlertsNew,
@@ -176,6 +177,7 @@ export class LegacyAlertsClient<
processedAlertsRecoveredCurrent,
this.startedAtString
);
+ ruleRunMetricsStore.setNumberOfDelayedAlerts(alerts.delayedAlertsCount);
alerts.currentRecoveredAlerts = merge(alerts.currentRecoveredAlerts, earlyRecoveredAlerts);
this.processedAlerts.new = alerts.newAlerts;
@@ -213,6 +215,7 @@ export class LegacyAlertsClient<
flappingSettings,
maintenanceWindowIds,
alertDelay,
+ ruleRunMetricsStore,
});
this.logAlerts({
diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts
index 1de1c06f5e826..043fcbe5c3d8f 100644
--- a/x-pack/plugins/alerting/server/alerts_client/types.ts
+++ b/x-pack/plugins/alerting/server/alerts_client/types.ts
@@ -120,6 +120,7 @@ export interface ProcessAlertsOpts {
notifyOnActionGroupChange: boolean;
maintenanceWindowIds: string[];
alertDelay: number;
+ ruleRunMetricsStore: RuleRunMetricsStore;
}
export interface LogAlertsOpts {
diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts
index de71e4e67f590..62d2a2f14162d 100644
--- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts
+++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts
@@ -726,6 +726,7 @@ describe('AlertingEventLogger', () => {
numberOfNewAlerts: 4,
numberOfRecoveredAlerts: 5,
numSearches: 6,
+ numberOfDelayedAlerts: 7,
esSearchDurationMs: 3300,
totalSearchDurationMs: 10333,
hasReachedAlertLimit: false,
@@ -753,6 +754,7 @@ describe('AlertingEventLogger', () => {
new: 4,
recovered: 5,
},
+ number_of_delayed_alerts: 7,
number_of_searches: 6,
es_search_duration_ms: 3300,
total_search_duration_ms: 10333,
@@ -825,6 +827,7 @@ describe('AlertingEventLogger', () => {
numberOfNewAlerts: 4,
numberOfRecoveredAlerts: 5,
numSearches: 6,
+ numberOfDelayedAlerts: 7,
esSearchDurationMs: 3300,
totalSearchDurationMs: 10333,
hasReachedAlertLimit: false,
@@ -862,6 +865,7 @@ describe('AlertingEventLogger', () => {
new: 4,
recovered: 5,
},
+ number_of_delayed_alerts: 7,
number_of_searches: 6,
es_search_duration_ms: 3300,
total_search_duration_ms: 10333,
@@ -910,6 +914,7 @@ describe('AlertingEventLogger', () => {
new: 0,
recovered: 0,
},
+ number_of_delayed_alerts: 0,
number_of_searches: 0,
es_search_duration_ms: 0,
total_search_duration_ms: 0,
diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts
index 680279f5dbac7..60ddb03c7c68a 100644
--- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts
+++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts
@@ -440,6 +440,7 @@ export function updateEvent(event: IEvent, opts: UpdateEventOpts) {
new: metrics.numberOfNewAlerts ? metrics.numberOfNewAlerts : 0,
recovered: metrics.numberOfRecoveredAlerts ? metrics.numberOfRecoveredAlerts : 0,
},
+ number_of_delayed_alerts: metrics.numberOfDelayedAlerts ? metrics.numberOfDelayedAlerts : 0,
number_of_searches: metrics.numSearches ? metrics.numSearches : 0,
es_search_duration_ms: metrics.esSearchDurationMs ? metrics.esSearchDurationMs : 0,
total_search_duration_ms: metrics.totalSearchDurationMs ? metrics.totalSearchDurationMs : 0,
diff --git a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts
index 4656f4377f130..2528a27f19f9e 100644
--- a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts
+++ b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts
@@ -23,9 +23,11 @@ describe('getAlertsForNotification', () => {
'default',
0,
{
+ // new alerts
'1': alert1,
},
{
+ // active alerts
'1': alert1,
'2': alert2,
},
@@ -94,11 +96,13 @@ describe('getAlertsForNotification', () => {
{},
{},
{
+ // recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
},
{
+ // current recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
@@ -228,11 +232,13 @@ describe('getAlertsForNotification', () => {
{},
{},
{
+ // recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
},
{
+ // current recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
@@ -360,11 +366,13 @@ describe('getAlertsForNotification', () => {
{},
{},
{
+ // recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
},
{
+ // current recovered alerts
'1': alert1,
'2': alert2,
'3': alert3,
@@ -459,21 +467,24 @@ describe('getAlertsForNotification', () => {
});
const alert2 = new Alert('2', { meta: { uuid: 'uuid-2' } });
- const { newAlerts, activeAlerts, currentActiveAlerts } = getAlertsForNotification(
- DEFAULT_FLAPPING_SETTINGS,
- true,
- 'default',
- 0,
- {
- '1': alert1,
- },
- {
- '1': alert1,
- '2': alert2,
- },
- {},
- {}
- );
+ const { newAlerts, activeAlerts, currentActiveAlerts, delayedAlertsCount } =
+ getAlertsForNotification(
+ DEFAULT_FLAPPING_SETTINGS,
+ true,
+ 'default',
+ 0,
+ {
+ // new alerts
+ '1': alert1,
+ },
+ {
+ // active alerts
+ '1': alert1,
+ '2': alert2,
+ },
+ {},
+ {}
+ );
expect(newAlerts).toMatchInlineSnapshot(`
Object {
"1": Object {
@@ -536,28 +547,32 @@ describe('getAlertsForNotification', () => {
},
}
`);
+ expect(delayedAlertsCount).toBe(0);
});
test('should reset activeCount for all recovered alerts', () => {
const alert1 = new Alert('1', { meta: { activeCount: 3 } });
const alert3 = new Alert('3');
- const { recoveredAlerts, currentRecoveredAlerts } = getAlertsForNotification(
- DEFAULT_FLAPPING_SETTINGS,
- true,
- 'default',
- 0,
- {},
- {},
- {
- '1': alert1,
- '3': alert3,
- },
- {
- '1': alert1,
- '3': alert3,
- }
- );
+ const { recoveredAlerts, currentRecoveredAlerts, delayedAlertsCount } =
+ getAlertsForNotification(
+ DEFAULT_FLAPPING_SETTINGS,
+ true,
+ 'default',
+ 0,
+ {},
+ {},
+ {
+ // recovered alerts
+ '1': alert1,
+ '3': alert3,
+ },
+ {
+ // current recovered alerts
+ '1': alert1,
+ '3': alert3,
+ }
+ );
expect(alertsWithAnyUUID(recoveredAlerts)).toMatchInlineSnapshot(`
Object {
@@ -603,6 +618,7 @@ describe('getAlertsForNotification', () => {
},
}
`);
+ expect(delayedAlertsCount).toBe(0);
});
test('should remove the alert from newAlerts and should not return the alert in currentActiveAlerts if the activeCount is less than the rule alertDelay', () => {
@@ -611,21 +627,24 @@ describe('getAlertsForNotification', () => {
});
const alert2 = new Alert('2', { meta: { uuid: 'uuid-2' } });
- const { newAlerts, activeAlerts, currentActiveAlerts } = getAlertsForNotification(
- DEFAULT_FLAPPING_SETTINGS,
- true,
- 'default',
- 5,
- {
- '1': alert1,
- },
- {
- '1': alert1,
- '2': alert2,
- },
- {},
- {}
- );
+ const { newAlerts, activeAlerts, currentActiveAlerts, delayedAlertsCount } =
+ getAlertsForNotification(
+ DEFAULT_FLAPPING_SETTINGS,
+ true,
+ 'default',
+ 5,
+ {
+ // new alerts
+ '1': alert1,
+ },
+ {
+ // active alerts
+ '1': alert1,
+ '2': alert2,
+ },
+ {},
+ {}
+ );
expect(newAlerts).toMatchInlineSnapshot(`Object {}`);
expect(activeAlerts).toMatchInlineSnapshot(`
Object {
@@ -652,23 +671,26 @@ describe('getAlertsForNotification', () => {
}
`);
expect(currentActiveAlerts).toMatchInlineSnapshot(`Object {}`);
+ expect(delayedAlertsCount).toBe(2);
});
test('should update active alert to look like a new alert if the activeCount is equal to the rule alertDelay', () => {
const alert2 = new Alert('2', { meta: { uuid: 'uuid-2' } });
- const { newAlerts, activeAlerts, currentActiveAlerts } = getAlertsForNotification(
- DEFAULT_FLAPPING_SETTINGS,
- true,
- 'default',
- 1,
- {},
- {
- '2': alert2,
- },
- {},
- {}
- );
+ const { newAlerts, activeAlerts, currentActiveAlerts, delayedAlertsCount } =
+ getAlertsForNotification(
+ DEFAULT_FLAPPING_SETTINGS,
+ true,
+ 'default',
+ 1,
+ {},
+ {
+ // active alerts
+ '2': alert2,
+ },
+ {},
+ {}
+ );
expect(newAlerts['2'].getState().duration).toBe('0');
expect(newAlerts['2'].getState().start).toBeTruthy();
@@ -677,5 +699,7 @@ describe('getAlertsForNotification', () => {
expect(currentActiveAlerts['2'].getState().duration).toBe('0');
expect(currentActiveAlerts['2'].getState().start).toBeTruthy();
+
+ expect(delayedAlertsCount).toBe(0);
});
});
diff --git a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts
index 593d92b35383b..c5c7ac017b2c1 100644
--- a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts
+++ b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts
@@ -27,6 +27,7 @@ export function getAlertsForNotification<
startedAt?: string | null
) {
const currentActiveAlerts: Record> = {};
+ let delayedAlertsCount = 0;
for (const id of keys(activeAlerts)) {
const alert = activeAlerts[id];
@@ -37,6 +38,7 @@ export function getAlertsForNotification<
if (alert.getActiveCount() < alertDelay) {
// remove from new alerts
delete newAlerts[id];
+ delayedAlertsCount += 1;
} else {
currentActiveAlerts[id] = alert;
// if the active count is equal to the alertDelay it is considered a new alert
@@ -100,5 +102,6 @@ export function getAlertsForNotification<
currentActiveAlerts,
recoveredAlerts,
currentRecoveredAlerts,
+ delayedAlertsCount,
};
}
diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts
index c4b1ac0acc3a7..97c47d5294a30 100644
--- a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts
+++ b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts
@@ -24,6 +24,7 @@ const getMetrics = ({
numberOfNewAlerts: 12,
numberOfRecoveredAlerts: 11,
numberOfTriggeredActions: 5,
+ numberOfDelayedAlerts: 3,
totalSearchDurationMs: 2,
hasReachedAlertLimit,
hasReachedQueuedActionsLimit,
diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts
index 34c831db01a75..9eb66bbf7dbb3 100644
--- a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts
+++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts
@@ -28,6 +28,7 @@ const executionMetrics = {
numberOfActiveAlerts: 2,
numberOfNewAlerts: 3,
numberOfRecoveredAlerts: 13,
+ numberOfDelayedAlerts: 7,
hasReachedAlertLimit: false,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
hasReachedQueuedActionsLimit: false,
diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.mock.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.mock.ts
index 5f04ec74127b7..3a78242116b16 100644
--- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.mock.ts
+++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.mock.ts
@@ -16,6 +16,7 @@ const createRuleRunMetricsStoreMock = () => {
getNumberOfActiveAlerts: jest.fn(),
getNumberOfRecoveredAlerts: jest.fn(),
getNumberOfNewAlerts: jest.fn(),
+ getNumberOfDelayedAlerts: jest.fn(),
getStatusByConnectorType: jest.fn(),
getMetrics: jest.fn(),
getHasReachedAlertLimit: jest.fn(),
@@ -28,6 +29,7 @@ const createRuleRunMetricsStoreMock = () => {
setNumberOfActiveAlerts: jest.fn(),
setNumberOfRecoveredAlerts: jest.fn(),
setNumberOfNewAlerts: jest.fn(),
+ setNumberOfDelayedAlerts: jest.fn(),
setTriggeredActionsStatusByConnectorType: jest.fn(),
setHasReachedAlertLimit: jest.fn(),
hasReachedTheExecutableActionsLimit: jest.fn(),
diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts
index e2b7cc61550bd..86b30041dba83 100644
--- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts
+++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts
@@ -23,6 +23,7 @@ describe('RuleRunMetricsStore', () => {
expect(ruleRunMetricsStore.getNumberOfActiveAlerts()).toBe(0);
expect(ruleRunMetricsStore.getNumberOfRecoveredAlerts()).toBe(0);
expect(ruleRunMetricsStore.getNumberOfNewAlerts()).toBe(0);
+ expect(ruleRunMetricsStore.getNumberOfDelayedAlerts()).toBe(0);
expect(ruleRunMetricsStore.getStatusByConnectorType('any')).toBe(undefined);
expect(ruleRunMetricsStore.getHasReachedAlertLimit()).toBe(false);
expect(ruleRunMetricsStore.getHasReachedQueuedActionsLimit()).toBe(false);
@@ -68,6 +69,11 @@ describe('RuleRunMetricsStore', () => {
expect(ruleRunMetricsStore.getNumberOfNewAlerts()).toBe(12);
});
+ test('sets and returns getNumberOfDelayedAlerts', () => {
+ ruleRunMetricsStore.setNumberOfDelayedAlerts(7);
+ expect(ruleRunMetricsStore.getNumberOfDelayedAlerts()).toBe(7);
+ });
+
test('sets and returns triggeredActionsStatusByConnectorType', () => {
ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({
actionTypeId: testConnectorId,
@@ -111,6 +117,7 @@ describe('RuleRunMetricsStore', () => {
numberOfNewAlerts: 12,
numberOfRecoveredAlerts: 11,
numberOfTriggeredActions: 5,
+ numberOfDelayedAlerts: 7,
totalSearchDurationMs: 2,
hasReachedAlertLimit: true,
hasReachedQueuedActionsLimit: true,
diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts
index 80b72e0069bb6..e088586136eab 100644
--- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts
+++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts
@@ -19,6 +19,7 @@ interface State {
numberOfActiveAlerts: number;
numberOfRecoveredAlerts: number;
numberOfNewAlerts: number;
+ numberOfDelayedAlerts: number;
hasReachedAlertLimit: boolean;
connectorTypes: {
[key: string]: {
@@ -43,6 +44,7 @@ export class RuleRunMetricsStore {
numberOfActiveAlerts: 0,
numberOfRecoveredAlerts: 0,
numberOfNewAlerts: 0,
+ numberOfDelayedAlerts: 0,
hasReachedAlertLimit: false,
connectorTypes: {},
hasReachedQueuedActionsLimit: false,
@@ -79,6 +81,9 @@ export class RuleRunMetricsStore {
public getNumberOfNewAlerts = () => {
return this.state.numberOfNewAlerts;
};
+ public getNumberOfDelayedAlerts = () => {
+ return this.state.numberOfDelayedAlerts;
+ };
public getStatusByConnectorType = (actionTypeId: string) => {
return this.state.connectorTypes[actionTypeId];
};
@@ -128,6 +133,9 @@ export class RuleRunMetricsStore {
public setNumberOfNewAlerts = (numberOfNewAlerts: number) => {
this.state.numberOfNewAlerts = numberOfNewAlerts;
};
+ public setNumberOfDelayedAlerts = (numberOfDelayedAlerts: number) => {
+ this.state.numberOfDelayedAlerts = numberOfDelayedAlerts;
+ };
public setTriggeredActionsStatusByConnectorType = ({
actionTypeId,
status,
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
index e4afa351d4f14..3994883ec9277 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
@@ -307,7 +307,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
4,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
testAlertingEventLogCalls({ status: 'ok' });
@@ -389,7 +389,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
5,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
testAlertingEventLogCalls({
@@ -477,7 +477,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
6,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
testAlertingEventLogCalls({
@@ -931,7 +931,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
6,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
}
@@ -1376,7 +1376,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
6,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
testAlertingEventLogCalls({
@@ -1503,7 +1503,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
6,
- `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}`
+ `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}`
);
testAlertingEventLogCalls({
@@ -2642,7 +2642,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
4,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
testAlertingEventLogCalls({
@@ -3422,6 +3422,7 @@ describe('Task Runner', () => {
numberOfNewAlerts: newAlerts,
numberOfRecoveredAlerts: recoveredAlerts,
numberOfTriggeredActions: triggeredActions,
+ numberOfDelayedAlerts: 0,
totalSearchDurationMs: 23423,
hasReachedAlertLimit,
triggeredActionsStatus: 'partial',
@@ -3458,6 +3459,7 @@ describe('Task Runner', () => {
numberOfNewAlerts: newAlerts,
numberOfRecoveredAlerts: recoveredAlerts,
numberOfTriggeredActions: triggeredActions,
+ numberOfDelayedAlerts: 0,
totalSearchDurationMs: 23423,
hasReachedAlertLimit,
triggeredActionsStatus: 'complete',
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
index 2733521eab88f..aea121e7037ce 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts
@@ -589,6 +589,7 @@ export class TaskRunner<
some(actions, (action) => action.frequency?.notifyWhen === RuleNotifyWhen.CHANGE),
maintenanceWindowIds: maintenanceWindowsWithoutScopedQueryIds,
alertDelay: alertDelay?.active ?? 0,
+ ruleRunMetricsStore,
});
});
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts
index bd47acbbdb8c1..4274c320126de 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts
@@ -462,7 +462,7 @@ describe('Task Runner', () => {
);
expect(logger.debug).nthCalledWith(
debugCall++,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
expect(
taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update
@@ -808,6 +808,7 @@ describe('Task Runner', () => {
statusChangeThreshold: 4,
},
maintenanceWindowIds: [],
+ ruleRunMetricsStore,
});
expect(alertsClientToUse.logAlerts).toHaveBeenCalledWith({
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts
index 0ce2f758ade39..8d7a8857a0e9a 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts
@@ -473,7 +473,7 @@ describe('Task Runner Cancel', () => {
);
expect(logger.debug).nthCalledWith(
8,
- 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
+ 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}'
);
}
@@ -517,6 +517,7 @@ describe('Task Runner Cancel', () => {
numberOfNewAlerts: newAlerts,
numberOfRecoveredAlerts: recoveredAlerts,
numberOfTriggeredActions: triggeredActions,
+ numberOfDelayedAlerts: 0,
totalSearchDurationMs: 23423,
hasReachedAlertLimit,
triggeredActionsStatus: 'complete',
diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json
index 836228fad02f7..561217eeae803 100644
--- a/x-pack/plugins/event_log/generated/mappings.json
+++ b/x-pack/plugins/event_log/generated/mappings.json
@@ -359,6 +359,9 @@
}
}
},
+ "number_of_delayed_alerts": {
+ "type": "long"
+ },
"number_of_searches": {
"type": "long"
},
diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts
index ea407540f7dbc..b8be2221ec1ed 100644
--- a/x-pack/plugins/event_log/generated/schemas.ts
+++ b/x-pack/plugins/event_log/generated/schemas.ts
@@ -162,6 +162,7 @@ export const EventSchema = schema.maybe(
recovered: ecsStringOrNumber(),
})
),
+ number_of_delayed_alerts: ecsStringOrNumber(),
number_of_searches: ecsStringOrNumber(),
total_indexing_duration_ms: ecsStringOrNumber(),
es_search_duration_ms: ecsStringOrNumber(),
diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js
index c0c3aa9f2b152..6d0ec3635ab41 100644
--- a/x-pack/plugins/event_log/scripts/mappings.js
+++ b/x-pack/plugins/event_log/scripts/mappings.js
@@ -134,6 +134,9 @@ exports.EcsCustomPropertyMappings = {
},
},
},
+ number_of_delayed_alerts: {
+ type: 'long',
+ },
number_of_searches: {
type: 'long',
},
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts
index da3752e098de2..79e5b659e341d 100644
--- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts
@@ -1855,6 +1855,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) {
const NEW_PATH = 'kibana.alert.rule.execution.metrics.alert_counts.new';
const RECOVERED_PATH = 'kibana.alert.rule.execution.metrics.alert_counts.recovered';
const ACTION_PATH = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions';
+ const DELAYED_PATH = 'kibana.alert.rule.execution.metrics.number_of_delayed_alerts';
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
@@ -1933,6 +1934,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) {
expect(get(event, NEW_PATH)).to.be(0);
expect(get(event, RECOVERED_PATH)).to.be(0);
expect(get(event, ACTION_PATH)).to.be(0);
+ expect(get(event, DELAYED_PATH)).to.be(1);
});
// third executions creates the delayed active alert and triggers actions
@@ -1940,24 +1942,28 @@ export default function eventLogTests({ getService }: FtrProviderContext) {
expect(get(executeEvents[2], NEW_PATH)).to.be(1);
expect(get(executeEvents[2], RECOVERED_PATH)).to.be(0);
expect(get(executeEvents[2], ACTION_PATH)).to.be(1);
+ expect(get(executeEvents[2], DELAYED_PATH)).to.be(0);
// fourth execution
expect(get(executeEvents[3], ACTIVE_PATH)).to.be(1);
expect(get(executeEvents[3], NEW_PATH)).to.be(0);
expect(get(executeEvents[3], RECOVERED_PATH)).to.be(0);
expect(get(executeEvents[3], ACTION_PATH)).to.be(0);
+ expect(get(executeEvents[3], DELAYED_PATH)).to.be(0);
// fifth recovered execution
expect(get(executeEvents[4], ACTIVE_PATH)).to.be(0);
expect(get(executeEvents[4], NEW_PATH)).to.be(0);
expect(get(executeEvents[4], RECOVERED_PATH)).to.be(1);
expect(get(executeEvents[4], ACTION_PATH)).to.be(0);
+ expect(get(executeEvents[4], DELAYED_PATH)).to.be(0);
// sixth execution does not create the active alert
expect(get(executeEvents[5], ACTIVE_PATH)).to.be(0);
expect(get(executeEvents[5], NEW_PATH)).to.be(0);
expect(get(executeEvents[5], RECOVERED_PATH)).to.be(0);
expect(get(executeEvents[5], ACTION_PATH)).to.be(0);
+ expect(get(executeEvents[5], DELAYED_PATH)).to.be(1);
});
});
}
diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_alert_delay.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_alert_delay.ts
index f7e2876e9775b..c900a08311add 100644
--- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_alert_delay.ts
+++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_alert_delay.ts
@@ -53,6 +53,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
const RECOVERED_PATH = 'kibana.alert.rule.execution.metrics.alert_counts.recovered';
const ACTION_PATH = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions';
const UUID_PATH = 'kibana.alert.rule.execution.uuid';
+ const DELAYED_PATH = 'kibana.alert.rule.execution.metrics.number_of_delayed_alerts';
const es = getService('es');
const retry = getService('retry');
@@ -152,6 +153,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(1);
// Query for alerts
const alertDocsRun1 = await queryForAlertDocs();
@@ -178,6 +180,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(1);
// Query for alerts
const alertDocsRun2 = await queryForAlertDocs();
@@ -205,6 +208,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(1);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(1);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(0);
// Query for alerts
const alertDocsRun3 = await queryForAlertDocs();
@@ -260,6 +264,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(0);
// Query for alerts
const alertDocsRun4 = await queryForAlertDocs();
@@ -311,6 +316,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(1);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(0);
// Query for alerts
const alertDocsRun5 = await queryForAlertDocs();
@@ -366,6 +372,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(1);
// Query for alerts
const alertDocsRun6 = await queryForAlertDocs();
@@ -439,6 +446,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(2);
// Query for alerts
const alertDocsRun1 = await queryForAlertDocs(alwaysFiringAlertsAsDataIndex);
@@ -465,6 +473,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(2);
// Query for alerts
const alertDocsRun2 = await queryForAlertDocs(alwaysFiringAlertsAsDataIndex);
@@ -493,6 +502,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(2);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(2);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(0);
// Query for alerts
const alertDocsRun3 = await queryForAlertDocs(alwaysFiringAlertsAsDataIndex);
@@ -555,6 +565,7 @@ export default function createAlertsAsDataAlertDelayInstallResourcesTest({
expect(get(executeEvent, NEW_PATH)).to.be(0);
expect(get(executeEvent, RECOVERED_PATH)).to.be(0);
expect(get(executeEvent, ACTION_PATH)).to.be(0);
+ expect(get(executeEvent, DELAYED_PATH)).to.be(0);
// Query for alerts
const alertDocsRun4 = await queryForAlertDocs(alwaysFiringAlertsAsDataIndex);
From 06ecb5aee9771dacd4f2f8121dbe313435e364c4 Mon Sep 17 00:00:00 2001
From: Paulo Henrique
Date: Mon, 12 Feb 2024 11:39:29 -0800
Subject: [PATCH 22/83] [Cloud Security] [Findings] Set misconfiguration tab as
default and Fix non matching groups data (#176735)
## Summary
This PR addresses the following issues in the Findings ->
Misconfigurations page:
- Updated Misconfigurations to be the Default selected tab in the
Findings page on the initial load (when LocalStorage is not set yet)
https://github.com/elastic/kibana/assets/19270322/dc3f071e-f64f-4cf4-8d63-fb481ea98d6b
- Fixed an issue with the Non-Matching group that wasn't combining the
must_not filter from the grouping with the Benchmark Rules.
https://github.com/elastic/kibana/assets/19270322/b428070d-f237-40f9-8249-28fdb0fcddf4
---
.../configurations/latest_findings/use_latest_findings.ts | 2 +-
.../public/pages/findings/findings.tsx | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts
index 5a516920df1bd..69638f92e5b64 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts
+++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts
@@ -74,7 +74,7 @@ export const getFindingsQuery = (
},
},
],
- must_not: mutedRulesFilterQuery,
+ must_not: [...(query?.bool?.must_not ?? []), ...mutedRulesFilterQuery],
},
},
...(pageParam ? { from: pageParam } : {}),
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx
index 67e46d82020cc..f8d91d5250234 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx
@@ -47,9 +47,11 @@ const FindingsTabRedirecter = ({ lastTabSelected }: { lastTabSelected?: Findings
);
}
- // otherwise stay on the vulnerabilities tab, since it's the first one.
+ // otherwise stay on the misconfigurations tab, since it's the first one.
return (
-
+
);
};
From 6827db46d5f922be50cf35a09de045e8b813275b Mon Sep 17 00:00:00 2001
From: Devon Thomson
Date: Mon, 12 Feb 2024 14:55:16 -0500
Subject: [PATCH 23/83] [Embeddable rebuild] Allow Dashboard to provide
references (#176455)
Adds the ability for the Dashboard to provide references for its React Embeddable children to inject, and adds the ability for the React Embeddable children to provide extracted references back to the Dashboard.
---
examples/embeddable_examples/kibana.jsonc | 9 +-
examples/embeddable_examples/public/plugin.ts | 20 +-
.../react_embeddables/field_list/constants.ts | 11 +
.../field_list/create_field_list_action.tsx | 35 +++
.../field_list_react_embeddable.tsx | 221 ++++++++++++++++++
.../react_embeddables/field_list/types.ts | 19 ++
examples/embeddable_examples/tsconfig.json | 10 +-
.../interfaces/serialized_state.ts | 4 +-
.../presentation_containers/tsconfig.json | 2 +-
.../dashboard_container_references.ts | 68 +++---
.../component/grid/dashboard_grid_item.tsx | 21 +-
.../embeddable/api/run_save_functions.tsx | 29 ++-
.../embeddable/create/create_dashboard.ts | 7 +
.../embeddable/dashboard_container.tsx | 34 +--
.../dashboard_content_management_service.ts | 3 +-
.../lib/load_dashboard_state.ts | 9 +-
.../lib/save_dashboard_state.ts | 13 +-
.../dashboard_content_management/types.ts | 9 +
18 files changed, 443 insertions(+), 81 deletions(-)
create mode 100644 examples/embeddable_examples/public/react_embeddables/field_list/constants.ts
create mode 100644 examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx
create mode 100644 examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx
create mode 100644 examples/embeddable_examples/public/react_embeddables/field_list/types.ts
diff --git a/examples/embeddable_examples/kibana.jsonc b/examples/embeddable_examples/kibana.jsonc
index 339533a18ca4f..0788268aedf3f 100644
--- a/examples/embeddable_examples/kibana.jsonc
+++ b/examples/embeddable_examples/kibana.jsonc
@@ -8,12 +8,15 @@
"server": true,
"browser": true,
"requiredPlugins": [
+ "dataViews",
"embeddable",
"uiActions",
"dashboard",
+ "data",
+ "charts",
+ "fieldFormats"
],
- "extraPublicDirs": [
- "public/hello_world"
- ]
+ "requiredBundles": ["presentationUtil"],
+ "extraPublicDirs": ["public/hello_world"]
}
}
diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts
index c94eef3107972..83c5b911dc8e4 100644
--- a/examples/embeddable_examples/public/plugin.ts
+++ b/examples/embeddable_examples/public/plugin.ts
@@ -6,9 +6,13 @@
* Side Public License, v 1.
*/
+import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { Plugin, CoreSetup, CoreStart } from '@kbn/core/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
+import { DataPublicPluginStart } from '@kbn/data-plugin/public';
+import { ChartsPluginStart } from '@kbn/charts-plugin/public';
+import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import {
HelloWorldEmbeddableFactory,
HELLO_WORLD_EMBEDDABLE,
@@ -33,6 +37,8 @@ import {
} from './filter_debugger';
import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown/eui_markdown_react_embeddable';
import { registerCreateEuiMarkdownAction } from './react_embeddables/eui_markdown/create_eui_markdown_action';
+import { registerFieldListFactory } from './react_embeddables/field_list/field_list_react_embeddable';
+import { registerCreateFieldListAction } from './react_embeddables/field_list/create_field_list_action';
export interface EmbeddableExamplesSetupDependencies {
embeddable: EmbeddableSetup;
@@ -40,7 +46,12 @@ export interface EmbeddableExamplesSetupDependencies {
}
export interface EmbeddableExamplesStartDependencies {
+ dataViews: DataViewsPublicPluginStart;
embeddable: EmbeddableStart;
+ uiActions: UiActionsStart;
+ data: DataPublicPluginStart;
+ charts: ChartsPluginStart;
+ fieldFormats: FieldFormatsStart;
}
interface ExampleEmbeddableFactories {
@@ -70,9 +81,6 @@ export class EmbeddableExamplesPlugin
core: CoreSetup,
deps: EmbeddableExamplesSetupDependencies
) {
- registerMarkdownEditorEmbeddable();
- registerCreateEuiMarkdownAction(deps.uiActions);
-
this.exampleEmbeddableFactories.getHelloWorldEmbeddableFactory =
deps.embeddable.registerEmbeddableFactory(
HELLO_WORLD_EMBEDDABLE,
@@ -104,6 +112,12 @@ export class EmbeddableExamplesPlugin
core: CoreStart,
deps: EmbeddableExamplesStartDependencies
): EmbeddableExamplesStart {
+ registerFieldListFactory(core, deps);
+ registerCreateFieldListAction(deps.uiActions);
+
+ registerMarkdownEditorEmbeddable();
+ registerCreateEuiMarkdownAction(deps.uiActions);
+
return {
createSampleData: async () => {},
factories: this.exampleEmbeddableFactories as ExampleEmbeddableFactories,
diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/constants.ts b/examples/embeddable_examples/public/react_embeddables/field_list/constants.ts
new file mode 100644
index 0000000000000..213d76e0fe874
--- /dev/null
+++ b/examples/embeddable_examples/public/react_embeddables/field_list/constants.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export const FIELD_LIST_ID = 'field_list';
+export const ADD_FIELD_LIST_ACTION_ID = 'create_field_list';
+export const FIELD_LIST_DATA_VIEW_REF_NAME = 'field_list_data_view_id';
diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx
new file mode 100644
index 0000000000000..aef3d121cd9d2
--- /dev/null
+++ b/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { apiIsPresentationContainer } from '@kbn/presentation-containers';
+import { EmbeddableApiContext } from '@kbn/presentation-publishing';
+import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
+import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
+import { ADD_FIELD_LIST_ACTION_ID, FIELD_LIST_ID } from './constants';
+
+export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) => {
+ uiActions.registerAction({
+ id: ADD_FIELD_LIST_ACTION_ID,
+ getIconType: () => 'indexOpen',
+ isCompatible: async ({ embeddable }) => {
+ return apiIsPresentationContainer(embeddable);
+ },
+ execute: async ({ embeddable }) => {
+ if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
+ embeddable.addNewPanel({
+ panelType: FIELD_LIST_ID,
+ });
+ },
+ getDisplayName: () =>
+ i18n.translate('embeddableExamples.unifiedFieldList.displayName', {
+ defaultMessage: 'Field list',
+ }),
+ });
+ uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_FIELD_LIST_ACTION_ID);
+};
diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx
new file mode 100644
index 0000000000000..38122c7393c84
--- /dev/null
+++ b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx
@@ -0,0 +1,221 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { css } from '@emotion/react';
+import { ChartsPluginStart } from '@kbn/charts-plugin/public';
+import { Reference } from '@kbn/content-management-utils';
+import { CoreStart } from '@kbn/core-lifecycle-browser';
+import { DataPublicPluginStart } from '@kbn/data-plugin/public';
+import {
+ DataViewsPublicPluginStart,
+ DATA_VIEW_SAVED_OBJECT_TYPE,
+ type DataView,
+} from '@kbn/data-views-plugin/public';
+import {
+ initializeReactEmbeddableTitles,
+ initializeReactEmbeddableUuid,
+ ReactEmbeddableFactory,
+ RegisterReactEmbeddable,
+ registerReactEmbeddableFactory,
+ useReactEmbeddableApiHandle,
+ useReactEmbeddableUnsavedChanges,
+} from '@kbn/embeddable-plugin/public';
+import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
+import { i18n } from '@kbn/i18n';
+import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
+import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public';
+import { euiThemeVars } from '@kbn/ui-theme';
+import {
+ UnifiedFieldListSidebarContainer,
+ type UnifiedFieldListSidebarContainerProps,
+} from '@kbn/unified-field-list';
+import { cloneDeep } from 'lodash';
+import React, { useEffect, useState } from 'react';
+import { BehaviorSubject } from 'rxjs';
+import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants';
+import { FieldListApi, FieldListSerializedStateState } from './types';
+
+const DataViewPicker = withSuspense(LazyDataViewPicker, null);
+
+const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
+ return {
+ originatingApp: '',
+ localStorageKeyPrefix: 'examples',
+ timeRangeUpdatesType: 'timefilter',
+ compressed: true,
+ showSidebarToggleButton: false,
+ disablePopularFields: true,
+ };
+};
+
+export const registerFieldListFactory = (
+ core: CoreStart,
+ {
+ dataViews,
+ data,
+ charts,
+ fieldFormats,
+ }: {
+ dataViews: DataViewsPublicPluginStart;
+ data: DataPublicPluginStart;
+ charts: ChartsPluginStart;
+ fieldFormats: FieldFormatsStart;
+ }
+) => {
+ const fieldListEmbeddableFactory: ReactEmbeddableFactory<
+ FieldListSerializedStateState,
+ FieldListApi
+ > = {
+ deserializeState: (state) => {
+ const serializedState = cloneDeep(state.rawState) as FieldListSerializedStateState;
+ // inject the reference
+ const dataViewIdRef = state.references?.find(
+ (ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME
+ );
+ if (dataViewIdRef && serializedState) {
+ serializedState.dataViewId = dataViewIdRef?.id;
+ }
+ return serializedState;
+ },
+ getComponent: async (initialState, maybeId) => {
+ const uuid = initializeReactEmbeddableUuid(maybeId);
+ const { titlesApi, titleComparators, serializeTitles } =
+ initializeReactEmbeddableTitles(initialState);
+
+ const allDataViews = await dataViews.getIdsWithTitle();
+
+ const selectedDataViewId$ = new BehaviorSubject(
+ initialState.dataViewId ?? (await dataViews.getDefaultDataView())?.id
+ );
+ const selectedFieldNames$ = new BehaviorSubject(
+ initialState.selectedFieldNames
+ );
+
+ return RegisterReactEmbeddable((apiRef) => {
+ const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
+ uuid,
+ fieldListEmbeddableFactory,
+ {
+ dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)],
+ selectedFieldNames: [
+ selectedFieldNames$,
+ (value) => selectedFieldNames$.next(value),
+ (a, b) => {
+ return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? '');
+ },
+ ],
+ ...titleComparators,
+ }
+ );
+
+ useReactEmbeddableApiHandle(
+ {
+ ...titlesApi,
+ unsavedChanges,
+ resetUnsavedChanges,
+ serializeState: async () => {
+ const dataViewId = selectedDataViewId$.getValue();
+ const references: Reference[] = dataViewId
+ ? [
+ {
+ type: DATA_VIEW_SAVED_OBJECT_TYPE,
+ name: FIELD_LIST_DATA_VIEW_REF_NAME,
+ id: dataViewId,
+ },
+ ]
+ : [];
+ return {
+ rawState: {
+ ...serializeTitles(),
+ // here we skip serializing the dataViewId, because the reference contains that information.
+ selectedFieldNames: selectedFieldNames$.getValue(),
+ },
+ references,
+ };
+ },
+ },
+ apiRef,
+ uuid
+ );
+
+ const [selectedDataViewId, selectedFieldNames] = useBatchedPublishingSubjects(
+ selectedDataViewId$,
+ selectedFieldNames$
+ );
+
+ const [selectedDataView, setSelectedDataView] = useState(undefined);
+
+ useEffect(() => {
+ if (!selectedDataViewId) return;
+ let mounted = true;
+ (async () => {
+ const dataView = await dataViews.get(selectedDataViewId);
+ if (!mounted) return;
+ setSelectedDataView(dataView);
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, [selectedDataViewId]);
+
+ return (
+
+
+ {
+ selectedDataViewId$.next(nextSelection);
+ }}
+ trigger={{
+ label:
+ selectedDataView?.getName() ??
+ i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', {
+ defaultMessage: 'Please select a data view',
+ }),
+ }}
+ />
+
+
+ {selectedDataView ? (
+
+ selectedFieldNames$.next([
+ ...(selectedFieldNames$.getValue() ?? []),
+ field.name,
+ ])
+ }
+ onRemoveFieldFromWorkspace={(field) => {
+ selectedFieldNames$.next(
+ (selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name)
+ );
+ }}
+ />
+ ) : null}
+
+
+ );
+ });
+ },
+ };
+
+ registerReactEmbeddableFactory(FIELD_LIST_ID, fieldListEmbeddableFactory);
+};
diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/types.ts b/examples/embeddable_examples/public/react_embeddables/field_list/types.ts
new file mode 100644
index 0000000000000..9e51d89be58a5
--- /dev/null
+++ b/examples/embeddable_examples/public/react_embeddables/field_list/types.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import {
+ DefaultEmbeddableApi,
+ SerializedReactEmbeddableTitles,
+} from '@kbn/embeddable-plugin/public';
+
+export type FieldListSerializedStateState = SerializedReactEmbeddableTitles & {
+ dataViewId?: string;
+ selectedFieldNames?: string[];
+};
+
+export type FieldListApi = DefaultEmbeddableApi;
diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json
index 35f799c8c4e3a..65704c797e1e9 100644
--- a/examples/embeddable_examples/tsconfig.json
+++ b/examples/embeddable_examples/tsconfig.json
@@ -21,6 +21,14 @@
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/es-query",
- "@kbn/presentation-containers"
+ "@kbn/presentation-containers",
+ "@kbn/data-views-plugin",
+ "@kbn/data-plugin",
+ "@kbn/charts-plugin",
+ "@kbn/field-formats-plugin",
+ "@kbn/content-management-utils",
+ "@kbn/core-lifecycle-browser",
+ "@kbn/presentation-util-plugin",
+ "@kbn/unified-field-list"
]
}
diff --git a/packages/presentation/presentation_containers/interfaces/serialized_state.ts b/packages/presentation/presentation_containers/interfaces/serialized_state.ts
index 6b24471d34c75..87d51580ca6dd 100644
--- a/packages/presentation/presentation_containers/interfaces/serialized_state.ts
+++ b/packages/presentation/presentation_containers/interfaces/serialized_state.ts
@@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
-import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
+import { Reference } from '@kbn/content-management-utils';
/**
* A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any
* strategy, this is the format that should be used.
*/
export interface SerializedPanelState {
- references?: SavedObjectReference[];
+ references?: Reference[];
rawState: RawStateType;
version?: string;
}
diff --git a/packages/presentation/presentation_containers/tsconfig.json b/packages/presentation/presentation_containers/tsconfig.json
index 7892712228da2..8e25a7b80c6e2 100644
--- a/packages/presentation/presentation_containers/tsconfig.json
+++ b/packages/presentation/presentation_containers/tsconfig.json
@@ -9,6 +9,6 @@
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/core-mount-utils-browser",
- "@kbn/core-saved-objects-api-server",
+ "@kbn/content-management-utils",
]
}
diff --git a/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts
index 7b4d682085352..987fce0f0455b 100644
--- a/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts
+++ b/src/plugins/dashboard/common/dashboard_container/persistable_state/dashboard_container_references.ts
@@ -6,18 +6,32 @@
* Side Public License, v 1.
*/
+import { Reference } from '@kbn/content-management-utils';
+import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableInput,
- EmbeddableStateWithType,
EmbeddablePersistableStateService,
+ EmbeddableStateWithType,
} from '@kbn/embeddable-plugin/common';
-import { Reference } from '@kbn/content-management-utils';
-import { CONTROL_GROUP_TYPE, PersistableControlGroupInput } from '@kbn/controls-plugin/common';
-
-import { DashboardPanelState } from '../types';
import { ParsedDashboardAttributesWithType } from '../../types';
-const getPanelStatePrefix = (state: DashboardPanelState) => `${state.explicitInput.id}:`;
+export const getReferencesForPanelId = (id: string, references: Reference[]): Reference[] => {
+ const prefix = `${id}:`;
+ const filteredReferences = references
+ .filter((reference) => reference.name.indexOf(prefix) === 0)
+ .map((reference) => ({ ...reference, name: reference.name.replace(prefix, '') }));
+ return filteredReferences;
+};
+
+export const prefixReferencesFromPanel = (id: string, references: Reference[]): Reference[] => {
+ const prefix = `${id}:`;
+ return references
+ .filter((reference) => reference.type !== 'tag') // panel references should never contain tags. If they do, they must be removed
+ .map((reference) => ({
+ ...reference,
+ name: `${prefix}${reference.name}`,
+ }));
+};
const controlGroupReferencePrefix = 'controlGroup_';
const controlGroupId = 'dashboard_control_group';
@@ -35,16 +49,15 @@ export const createInject = (
for (const [key, panel] of Object.entries(workingState.panels)) {
workingState.panels[key] = { ...panel };
- // Find the references for this panel
- const prefix = getPanelStatePrefix(panel);
-
- const filteredReferences = references
- .filter((reference) => reference.name.indexOf(prefix) === 0)
- .map((reference) => ({ ...reference, name: reference.name.replace(prefix, '') }));
-
+ const filteredReferences = getReferencesForPanelId(key, references);
const panelReferences = filteredReferences.length === 0 ? references : filteredReferences;
- // Inject dashboard references back in
+ /**
+ * Inject saved object ID back into the explicit input.
+ *
+ * TODO move this logic into the persistable state service inject method for each panel type
+ * that could be by value or by reference
+ */
if (panel.panelRefName !== undefined) {
const matchingReference = panelReferences.find(
(reference) => reference.name === panel.panelRefName
@@ -116,15 +129,18 @@ export const createExtract = (
workingState.panels = { ...workingState.panels };
// Run every panel through the state service to get the nested references
- for (const [key, panel] of Object.entries(workingState.panels)) {
- const prefix = getPanelStatePrefix(panel);
-
- // If the panel is a saved object, then we will make the reference for that saved object and change the explicit input
+ for (const [id, panel] of Object.entries(workingState.panels)) {
+ /**
+ * Extract saved object ID reference from the explicit input.
+ *
+ * TODO move this logic into the persistable state service extract method for each panel type
+ * that could be by value or by reference.
+ */
if (panel.explicitInput.savedObjectId) {
- panel.panelRefName = `panel_${key}`;
+ panel.panelRefName = `panel_${id}`;
references.push({
- name: `${prefix}panel_${key}`,
+ name: `${id}:panel_${id}`,
type: panel.type,
id: panel.explicitInput.savedObjectId as string,
});
@@ -137,18 +153,10 @@ export const createExtract = (
type: panel.type,
});
- // We're going to prefix the names of the references so that we don't end up with dupes (from visualizations for instance)
- const prefixedReferences = panelReferences
- .filter((reference) => reference.type !== 'tag') // panel references should never contain tags. If they do, they must be removed
- .map((reference) => ({
- ...reference,
- name: `${prefix}${reference.name}`,
- }));
-
- references.push(...prefixedReferences);
+ references.push(...prefixReferencesFromPanel(id, panelReferences));
const { type, ...restOfState } = panelState;
- workingState.panels[key].explicitInput = restOfState as EmbeddableInput;
+ workingState.panels[id].explicitInput = restOfState as EmbeddableInput;
}
}
diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx
index 164555b92a176..948b9f7bc6332 100644
--- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx
+++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx
@@ -6,20 +6,19 @@
* Side Public License, v 1.
*/
-import React, { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
import { EuiLoadingChart } from '@elastic/eui';
-import classNames from 'classnames';
-
-import { PhaseEvent } from '@kbn/presentation-publishing';
+import { css } from '@emotion/react';
import {
- ReactEmbeddableRenderer,
EmbeddablePanel,
reactEmbeddableRegistryHasKey,
+ ReactEmbeddableRenderer,
ViewMode,
} from '@kbn/embeddable-plugin/public';
-
-import { css } from '@emotion/react';
+import { PhaseEvent } from '@kbn/presentation-publishing';
+import classNames from 'classnames';
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { DashboardPanelState } from '../../../../common';
+import { getReferencesForPanelId } from '../../../../common/dashboard_container/persistable_state/dashboard_container_references';
import { pluginServices } from '../../../services/plugin_services';
import { useDashboardContainer } from '../../embeddable/dashboard_container';
@@ -101,17 +100,19 @@ export const Item = React.forwardRef(
: css``;
const renderedEmbeddable = useMemo(() => {
+ const references = getReferencesForPanelId(id, container.savedObjectReferences);
+ // render React embeddable
if (reactEmbeddableRegistryHasKey(type)) {
return (
);
}
+ // render legacy embeddable
return (
(
);
}, [container, id, index, onPanelStatusChange, type, panel]);
- // render legacy embeddable
-
return (
=> {
+): Promise<{ panels: DashboardContainerInput['panels']; references: Reference[] }> => {
+ const references: Reference[] = [];
const reactEmbeddableSavePromises: Array<
Promise<{ serializedState: SerializedPanelState; uuid: string }>
> = [];
@@ -51,8 +56,9 @@ const serializeAllPanelState = async (
const saveResults = await Promise.all(reactEmbeddableSavePromises);
for (const { serializedState, uuid } of saveResults) {
panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid };
+ references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? []));
}
- return panels;
+ return { panels, references };
};
export function runSaveAs(this: DashboardContainer) {
@@ -112,7 +118,7 @@ export function runSaveAs(this: DashboardContainer) {
// do not save if title is duplicate and is unconfirmed
return {};
}
- const nextPanels = await serializeAllPanelState(this);
+ const { panels: nextPanels, references } = await serializeAllPanelState(this);
const dashboardStateToSave: DashboardContainerInput = {
...currentState,
panels: nextPanels,
@@ -127,6 +133,7 @@ export function runSaveAs(this: DashboardContainer) {
const beforeAddTime = window.performance.now();
const saveResult = await saveDashboardState({
+ panelReferences: references,
currentState: stateToSave,
saveOptions,
lastSavedId,
@@ -145,12 +152,13 @@ export function runSaveAs(this: DashboardContainer) {
batch(() => {
this.dispatch.setStateFromSaveModal(stateFromSaveModal);
this.dispatch.setLastSavedInput(dashboardStateToSave);
- this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);
}
});
}
+ this.savedObjectReferences = saveResult.references ?? [];
+ this.lastSavedState.next();
resolve(saveResult);
return saveResult;
};
@@ -186,7 +194,7 @@ export async function runQuickSave(this: DashboardContainer) {
if (managed) return;
- const nextPanels = await serializeAllPanelState(this);
+ const { panels: nextPanels, references } = await serializeAllPanelState(this);
const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels };
let stateToSave: SavedDashboardInput = dashboardStateToSave;
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
@@ -196,11 +204,13 @@ export async function runQuickSave(this: DashboardContainer) {
}
const saveResult = await saveDashboardState({
- lastSavedId,
+ panelReferences: references,
currentState: stateToSave,
saveOptions: {},
+ lastSavedId,
});
+ this.savedObjectReferences = saveResult.references ?? [];
this.dispatch.setLastSavedInput(dashboardStateToSave);
this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
@@ -279,6 +289,7 @@ export async function runClone(this: DashboardContainer) {
title: newTitle,
},
});
+ this.savedObjectReferences = saveResult.references ?? [];
resolve(saveResult);
return saveResult.id
? {
diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
index d7d34fa42f078..e23c7cdb09846 100644
--- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
+++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts
@@ -239,6 +239,13 @@ export const initializeDashboard = async ({
description: initialDashboardInput.title,
};
+ // --------------------------------------------------------------------------------------
+ // Track references
+ // --------------------------------------------------------------------------------------
+ untilDashboardReady().then((dashboard) => {
+ dashboard.savedObjectReferences = loadDashboardReturn?.references;
+ });
+
// --------------------------------------------------------------------------------------
// Set up unified search integration.
// --------------------------------------------------------------------------------------
diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx
index e58e8597500dd..46885ecc7c0a5 100644
--- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx
+++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx
@@ -6,14 +6,8 @@
* Side Public License, v 1.
*/
-import { v4 } from 'uuid';
-import { omit } from 'lodash';
-import React, { createContext, useContext } from 'react';
-import ReactDOM from 'react-dom';
-import { batch } from 'react-redux';
-import { BehaviorSubject, Subject, Subscription } from 'rxjs';
-import { map, distinctUntilChanged } from 'rxjs/operators';
-import deepEqual from 'fast-deep-equal';
+import { METRIC_TYPE } from '@kbn/analytics';
+import { Reference } from '@kbn/content-management-utils';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import { RefreshInterval } from '@kbn/data-plugin/public';
@@ -21,9 +15,9 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import {
Container,
+ DefaultEmbeddableApi,
EmbeddableFactoryNotFoundError,
isExplicitInputWithAttributes,
- DefaultEmbeddableApi,
PanelNotFoundError,
ReactEmbeddableParentContext,
reactEmbeddableRegistryHasKey,
@@ -33,17 +27,25 @@ import {
type EmbeddableOutput,
type IEmbeddable,
} from '@kbn/embeddable-plugin/public';
-import { METRIC_TYPE } from '@kbn/analytics';
-import { I18nProvider } from '@kbn/i18n-react';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
+import { I18nProvider } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { PanelPackage } from '@kbn/presentation-containers';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { LocatorPublic } from '@kbn/share-plugin/common';
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
-
+import deepEqual from 'fast-deep-equal';
+import { omit } from 'lodash';
+import React, { createContext, useContext } from 'react';
+import ReactDOM from 'react-dom';
+import { batch } from 'react-redux';
+import { BehaviorSubject, Subject, Subscription } from 'rxjs';
+import { distinctUntilChanged, map } from 'rxjs/operators';
+import { v4 } from 'uuid';
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
+import { getReferencesForPanelId } from '../../../common/dashboard_container/persistable_state/dashboard_container_references';
+import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
import {
DASHBOARD_APP_ID,
DASHBOARD_LOADED_EVENT,
@@ -55,6 +57,7 @@ import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardCapabilitiesService } from '../../services/dashboard_capabilities/types';
import { pluginServices } from '../../services/plugin_services';
import { placePanel } from '../component/panel_placement';
+import { panelPlacementStrategies } from '../component/panel_placement/place_new_panel_strategies';
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
import { DashboardExternallyAccessibleApi } from '../external_api/dashboard_api';
import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
@@ -80,8 +83,6 @@ import {
dashboardTypeDisplayLowercase,
dashboardTypeDisplayName,
} from './dashboard_container_factory';
-import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
-import { panelPlacementStrategies } from '../component/panel_placement/place_new_panel_strategies';
export interface InheritedChildInput {
filters: Filter[];
@@ -160,6 +161,7 @@ export class DashboardContainer
// new embeddable framework
public reactEmbeddableChildren: BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }> =
new BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }>({});
+ public savedObjectReferences: Reference[] = [];
constructor(
initialInput: DashboardContainerInput,
@@ -736,8 +738,8 @@ export class DashboardContainer
} = this.getState();
const panel: DashboardPanelState | undefined = panels[childId];
- // TODO Embeddable refactor. References here
- return { rawState: panel?.explicitInput, version: panel?.version, references: [] };
+ const references = getReferencesForPanelId(childId, this.savedObjectReferences);
+ return { rawState: panel?.explicitInput, version: panel?.version, references };
};
public removePanel(id: string) {
diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts
index 0cac2dff75e32..2b2835e2a2420 100644
--- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts
+++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts
@@ -56,7 +56,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
contentManagement,
savedObjectsTagging,
}),
- saveDashboardState: ({ currentState, saveOptions, lastSavedId }) =>
+ saveDashboardState: ({ currentState, saveOptions, lastSavedId, panelReferences }) =>
saveDashboardState({
data,
embeddable,
@@ -64,6 +64,7 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen
lastSavedId,
currentState,
notifications,
+ panelReferences,
dashboardBackup,
contentManagement,
initializerContext,
diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts
index 5b748c740cea4..cabd1542efbb2 100644
--- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts
+++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/load_dashboard_state.ts
@@ -57,7 +57,12 @@ export const loadDashboardState = async ({
* This is a newly created dashboard, so there is no saved object state to load.
*/
if (!savedObjectId) {
- return { dashboardInput: newDashboardState, dashboardFound: true, newDashboardCreated: true };
+ return {
+ dashboardInput: newDashboardState,
+ dashboardFound: true,
+ newDashboardCreated: true,
+ references: [],
+ };
}
/**
@@ -97,6 +102,7 @@ export const loadDashboardState = async ({
dashboardInput: newDashboardState,
dashboardFound: false,
dashboardId: savedObjectId,
+ references: [],
};
}
@@ -192,6 +198,7 @@ export const loadDashboardState = async ({
return {
managed,
+ references,
resolveMeta,
dashboardInput,
anyMigrationRun,
diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts
index ec7e1fd2c097b..a2b0c3f8609b2 100644
--- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts
+++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/save_dashboard_state.ts
@@ -73,6 +73,7 @@ export const saveDashboardState = async ({
lastSavedId,
saveOptions,
currentState,
+ panelReferences,
dashboardBackup,
contentManagement,
savedObjectsTagging,
@@ -180,6 +181,8 @@ export const saveDashboardState = async ({
? savedObjectsTagging.updateTagsReferences(dashboardReferences, tags)
: dashboardReferences;
+ const allReferences = [...references, ...(panelReferences ?? [])];
+
/**
* Save the saved object using the content management
*/
@@ -191,7 +194,11 @@ export const saveDashboardState = async ({
>({
contentTypeId: DASHBOARD_CONTENT_ID,
data: attributes,
- options: { id: idToSaveTo, references, overwrite: true },
+ options: {
+ id: idToSaveTo,
+ references: allReferences,
+ overwrite: true,
+ },
});
const newId = result.item.id;
@@ -207,12 +214,12 @@ export const saveDashboardState = async ({
*/
if (newId !== lastSavedId) {
dashboardBackup.clearState(lastSavedId);
- return { redirectRequired: true, id: newId };
+ return { redirectRequired: true, id: newId, references: allReferences };
} else {
dashboardContentManagementCache.deleteDashboard(newId); // something changed in an existing dashboard, so delete it from the cache so that it can be re-fetched
}
}
- return { id: newId };
+ return { id: newId, references: allReferences };
} catch (error) {
toasts.addDanger({
title: dashboardSaveToastStrings.getFailureString(currentState.title, error.message),
diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts
index 7e79e01df56ad..900d8e6d09972 100644
--- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts
+++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts
@@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
+import { Reference } from '@kbn/content-management-utils';
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public';
@@ -74,6 +75,12 @@ export interface LoadDashboardReturn {
resolveMeta?: DashboardResolveMeta;
dashboardInput: SavedDashboardInput;
anyMigrationRun?: boolean;
+
+ /**
+ * Raw references returned directly from the Dashboard saved object. These
+ * should be provided to the React Embeddable children on deserialize.
+ */
+ references: Reference[];
}
/**
@@ -84,12 +91,14 @@ export type SavedDashboardSaveOpts = SavedObjectSaveOpts & { saveAsCopy?: boolea
export interface SaveDashboardProps {
currentState: SavedDashboardInput;
saveOptions: SavedDashboardSaveOpts;
+ panelReferences?: Reference[];
lastSavedId?: string;
}
export interface SaveDashboardReturn {
id?: string;
error?: string;
+ references?: Reference[];
redirectRequired?: boolean;
}
From e81568ca919767a938334d65ff2eea4c1f7a2561 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 12 Feb 2024 13:28:55 -0700
Subject: [PATCH 24/83] Update dependency @elastic/charts to v63.1.0 (main)
(#176527)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@elastic/charts](https://togithub.com/elastic/elastic-charts) |
[`63.0.0` ->
`63.1.0`](https://renovatebot.com/diffs/npm/@elastic%2fcharts/63.0.0/63.1.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@elastic%2fcharts/63.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@elastic%2fcharts/63.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@elastic%2fcharts/63.0.0/63.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@elastic%2fcharts/63.0.0/63.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
elastic/elastic-charts (@elastic/charts)
###
[`v63.1.0`](https://togithub.com/elastic/elastic-charts/blob/HEAD/CHANGELOG.md#6310-2024-01-29)
[Compare
Source](https://togithub.com/elastic/elastic-charts/compare/v63.0.0...v63.1.0)
##### Bug Fixes
- **deps:** update dependency
[@elastic/eui](https://togithub.com/elastic/eui) to ^92.1.1
([#2315](https://togithub.com/elastic/elastic-charts/issues/2315))
([f4e4fae](https://togithub.com/elastic/elastic-charts/commit/f4e4fae42e5dcc1b882f0c5126c66d3c96cd2fcc))
- **deps:** update dependency
[@playwright/test](https://togithub.com/playwright/test) to
^1.41.1
([#2316](https://togithub.com/elastic/elastic-charts/issues/2316))
([e2ab527](https://togithub.com/elastic/elastic-charts/commit/e2ab52791baef9da628930d3e8d738ce3772c34a))
- **styles:** isolated point style overrides
([#2278](https://togithub.com/elastic/elastic-charts/issues/2278))
([3fb1df2](https://togithub.com/elastic/elastic-charts/commit/3fb1df21d08c441c84705bd3d5984fc07caa11be))
##### Features
- **metric:** custom slot to render contents in gap
([#2303](https://togithub.com/elastic/elastic-charts/issues/2303))
([3256c8c](https://togithub.com/elastic/elastic-charts/commit/3256c8ca14d180e4d7a483811a37612aca2691ce))
##### Performance Improvements
- **tooltip:** improve placement logic
([#2310](https://togithub.com/elastic/elastic-charts/issues/2310))
([cac5f49](https://togithub.com/elastic/elastic-charts/commit/cac5f4908a54374d114d7a11e66d648979013039))
---
### Configuration
📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.
♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR has been generated by [Mend
Renovate](https://www.mend.io/free-developer-tools/renovate/). View
repository job log
[here](https://developer.mend.io/github/elastic/kibana).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package.json | 2 +-
yarn.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index a0bc855415b9d..191de4ea372fd 100644
--- a/package.json
+++ b/package.json
@@ -101,7 +101,7 @@
"@dnd-kit/utilities": "^2.0.0",
"@elastic/apm-rum": "^5.16.0",
"@elastic/apm-rum-react": "^2.0.2",
- "@elastic/charts": "63.0.0",
+ "@elastic/charts": "63.1.0",
"@elastic/datemath": "5.0.3",
"@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.9.1-canary.1",
"@elastic/ems-client": "8.5.1",
diff --git a/yarn.lock b/yarn.lock
index fbd3e8ca5979a..973c748f3b781 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1658,10 +1658,10 @@
dependencies:
object-hash "^1.3.0"
-"@elastic/charts@63.0.0":
- version "63.0.0"
- resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-63.0.0.tgz#c7f54cd60a1a59a28b5654886392e05a10fd67b8"
- integrity sha512-nvLg/qFJXYuKOdTDYj3iuwJ/X4zhkHdIB91yezd7fo+YvpBRiAUzJfc6Dpy6M5JkmGwx7Dq8zjGt6mO8ngOhog==
+"@elastic/charts@63.1.0":
+ version "63.1.0"
+ resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-63.1.0.tgz#6348ffe01d6e77ddd150382b57515134f76293b3"
+ integrity sha512-UdzsErplc5z2cQxRY7N4kXZXRfb0pdDdsC7V4ag2WIlDiYDpygB3iThb83sG99E9KtOqIkHPE5nyDmWI6GwfOg==
dependencies:
"@popperjs/core" "^2.11.8"
bezier-easing "^2.1.0"
From f76b0bbc77583d5190eb0f00c4bb59c313c2b010 Mon Sep 17 00:00:00 2001
From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
Date: Mon, 12 Feb 2024 15:29:25 -0500
Subject: [PATCH 25/83] [8.13][SecuritySolution][Endpoint] Retrieve (API) agent
status for SentinelOne agents and display it in the UI (#176440)
## Summary
- New internal API that returns the agent status for one of the
supported agent types (currently `endpoint` or `sentinel_one`) - route:
`/internal/api/endpoint/agent_status`
- Supported query params: `agentType` and `agentIds`
- The response will include a record for each agent ID that was
requested. Each record contains a `found: boolean` that indicates
whether the agent id information was found or not (could be the case
when agents are un-enrolled)
- NOTE: currently, this API will return only statuses for SentinelOne in
8.13. In the next release, it will be expanded with a framework that
enables it to return status for any supported `agentType`
- UI changes to show Host/Agent info. for SentinelOne using new api
__________
API Request: `GET
/internal/api/endpoint/agent_status?agentType=sentinel_one&agentIds=1111&agentIds=2222agentIds=invalid-id`
API Response:
```json5
{
"data": {
"1111": {
"agentType": "sentinel_one",
"id": "1111",
"found": true,
"status": "healthy",
"isolated": true, // <<<< Note host is isolated
"lastSeen": "2024-02-07T19:03:31.287300Z",
"pendingActions": {
"execute": 0,
"upload": 0,
"unisolate": 0,
"isolate": 0,
"get-file": 0,
"kill-process": 0,
"suspend-process": 0,
"running-processes": 0
}
},
"2222": {
"agentType": "sentinel_one",
"id": "2222",
"found": true,
"status": "healthy",
"isolated": false,
"lastSeen": "2024-02-07T14:45:02.830739Z",
"pendingActions": {
"execute": 0,
"upload": 0,
"unisolate": 0,
"isolate": 1, // <<< Note: host is Isolating
"get-file": 0,
"kill-process": 0,
"suspend-process": 0,
"running-processes": 0
}
}
},
{
"invalid-id": {
"agentType": "sentinel_one",
"id": "invalid",
"found": false,
"status": "unenrolled",
"isolated": false,
"lastSeen": "",
"pendingActions": {
"execute": 0,
"upload": 0,
"unisolate": 0,
"isolate": 0,
"get-file": 0,
"kill-process": 0,
"suspend-process": 0,
"running-processes": 0
}
}
}
```
Co-authored-by: Ashokaditya
Co-authored-by: Ash <1849116+ashokaditya@users.noreply.github.com>
---
.../api/endpoint/actions/common/base.ts | 17 +-
.../common/api/endpoint/actions/list_route.ts | 13 +-
.../agent/get_agent_status_route.test.ts | 54 +++++
.../endpoint/agent/get_agent_status_route.ts | 41 ++++
.../common/endpoint/constants.ts | 3 +
.../common/endpoint/types/agents.ts | 29 +++
.../common/endpoint/types/index.ts | 1 +
.../use_responder_action_data.ts | 1 -
.../sentinel_one_agent_status.tsx | 55 +++--
.../use_host_isolation_action.tsx | 43 ++--
.../use_sentinelone_host_isolation.tsx | 72 ++++---
.../highlighted_fields_cell.test.tsx | 7 +-
.../sentinel_one/header_sentinel_one_info.tsx | 8 +-
.../hooks/use_with_show_responder.tsx | 2 -
.../routes/actions/response_actions.test.ts | 2 +-
.../routes/agent/agent_status_handler.test.ts | 131 ++++++++++++
.../routes/agent/agent_status_handler.ts | 107 ++++++++++
.../server/endpoint/routes/agent/index.ts | 17 ++
.../get_response_actions_client.test.ts | 2 +-
.../actions/clients/sentinelone/mock.ts | 177 -----------------
.../actions/clients/sentinelone/mocks.ts | 188 ++++++++++++++++++
.../sentinel_one_actions_client.test.ts | 4 +-
.../services/agent/agent_status.test.ts | 184 +++++++++++++++++
.../endpoint/services/agent/agent_status.ts | 138 +++++++++++++
.../security_solution/server/plugin.ts | 2 +
.../common/sentinelone/types.ts | 2 -
26 files changed, 1011 insertions(+), 289 deletions(-)
create mode 100644 x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.test.ts
create mode 100644 x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.ts
create mode 100644 x-pack/plugins/security_solution/common/endpoint/types/agents.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/agent/index.ts
delete mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mock.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mocks.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.test.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.ts
diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/common/base.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/common/base.ts
index 755361902eaa1..a5e5c060e7303 100644
--- a/x-pack/plugins/security_solution/common/api/endpoint/actions/common/base.ts
+++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/common/base.ts
@@ -9,6 +9,21 @@ import type { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../endpoint/service/response_actions/constants';
+export const AgentTypeSchemaLiteral = RESPONSE_ACTION_AGENT_TYPE.map((agentType) =>
+ schema.literal(agentType)
+);
+
+export const agentTypesSchema = {
+ schema: schema.oneOf(
+ // @ts-expect-error TS2769: No overload matches this call
+ AgentTypeSchemaLiteral
+ ),
+ options: {
+ minSize: 1,
+ maxSize: RESPONSE_ACTION_AGENT_TYPE.length,
+ },
+};
+
export const BaseActionRequestSchema = {
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
endpoint_ids: schema.arrayOf(schema.string({ minLength: 1 }), {
@@ -46,7 +61,7 @@ export const BaseActionRequestSchema = {
agent_type: schema.maybe(
schema.oneOf(
// @ts-expect-error TS2769: No overload matches this call
- RESPONSE_ACTION_AGENT_TYPE.map((agentType) => schema.literal(agentType)),
+ AgentTypeSchemaLiteral,
{ defaultValue: 'endpoint' }
)
),
diff --git a/x-pack/plugins/security_solution/common/api/endpoint/actions/list_route.ts b/x-pack/plugins/security_solution/common/api/endpoint/actions/list_route.ts
index 3fe188198bc4b..720764d9cd03c 100644
--- a/x-pack/plugins/security_solution/common/api/endpoint/actions/list_route.ts
+++ b/x-pack/plugins/security_solution/common/api/endpoint/actions/list_route.ts
@@ -9,12 +9,12 @@
import type { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import {
- RESPONSE_ACTION_AGENT_TYPE,
RESPONSE_ACTION_API_COMMANDS_NAMES,
RESPONSE_ACTION_STATUS,
RESPONSE_ACTION_TYPE,
} from '../../../endpoint/service/response_actions/constants';
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../endpoint/constants';
+import { agentTypesSchema } from './common/base';
const commandsSchema = schema.oneOf(
// @ts-expect-error TS2769: No overload matches this call
@@ -33,17 +33,6 @@ const actionTypesSchema = {
options: { minSize: 1, maxSize: RESPONSE_ACTION_TYPE.length },
};
-const agentTypesSchema = {
- schema: schema.oneOf(
- // @ts-expect-error TS2769: No overload matches this call
- RESPONSE_ACTION_AGENT_TYPE.map((agentType) => schema.literal(agentType))
- ),
- options: {
- minSize: 1,
- maxSize: RESPONSE_ACTION_AGENT_TYPE.length,
- },
-};
-
export const EndpointActionListRequestSchema = {
query: schema.object({
agentIds: schema.maybe(
diff --git a/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.test.ts b/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.test.ts
new file mode 100644
index 0000000000000..f9362aa6847a9
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.test.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EndpointAgentStatusRequestSchema } from './get_agent_status_route';
+
+describe('Agent status api route schema', () => {
+ it('should optionally accept `agentType`', () => {
+ expect(() =>
+ EndpointAgentStatusRequestSchema.query.validate({
+ agentIds: '1',
+ })
+ ).not.toThrow();
+ });
+
+ it('should error if unknown `agentType` is used', () => {
+ expect(() =>
+ EndpointAgentStatusRequestSchema.query.validate({
+ agentIds: '1',
+ agentType: 'foo',
+ })
+ ).toThrow(/\[agentType\]: types that failed validation/);
+ });
+
+ it.each([
+ ['string with spaces only', { agentIds: ' ' }],
+ ['empty string', { agentIds: '' }],
+ ['array with empty strings', { agentIds: [' ', ''] }],
+ ['agentIds not defined', {}],
+ ['agentIds is empty array', { agentIds: [] }],
+ [
+ 'more than 50 agentIds',
+ { agentIds: Array.from({ length: 51 }, () => Math.random().toString(32)) },
+ ],
+ ])('should error if %s are used for `agentIds`', (_, validateOptions) => {
+ expect(() => EndpointAgentStatusRequestSchema.query.validate(validateOptions)).toThrow(
+ /\[agentIds\]:/
+ );
+ });
+
+ it.each([
+ ['single string value', 'one'],
+ ['array of strings', ['one', 'two']],
+ ])('should accept %s of `agentIds`', (_, agentIdsValue) => {
+ expect(() =>
+ EndpointAgentStatusRequestSchema.query.validate({
+ agentIds: agentIdsValue,
+ })
+ ).not.toThrow();
+ });
+});
diff --git a/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.ts b/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.ts
new file mode 100644
index 0000000000000..20eea48571c49
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/api/endpoint/agent/get_agent_status_route.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { schema, type TypeOf } from '@kbn/config-schema';
+import { AgentTypeSchemaLiteral } from '..';
+
+const AgentStatusAgentIdSchema = schema.string({
+ minLength: 1,
+ validate: (id) => {
+ if (id.trim() === '') {
+ return 'actionIds can not be empty strings';
+ }
+ },
+});
+
+export const EndpointAgentStatusRequestSchema = {
+ query: schema.object({
+ agentIds: schema.oneOf([
+ schema.arrayOf(AgentStatusAgentIdSchema, { minSize: 1, maxSize: 50 }),
+ AgentStatusAgentIdSchema,
+ ]),
+
+ agentType: schema.maybe(
+ schema.oneOf(
+ // @ts-expect-error TS2769: No overload matches this call
+ AgentTypeSchemaLiteral,
+ {
+ defaultValue: 'endpoint',
+ }
+ )
+ ),
+ }),
+};
+
+export type EndpointAgentStatusRequestQueryParams = TypeOf<
+ typeof EndpointAgentStatusRequestSchema.query
+>;
diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts
index 892cb717e1a2c..7f41e36382137 100644
--- a/x-pack/plugins/security_solution/common/endpoint/constants.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts
@@ -103,6 +103,9 @@ export const ACTION_AGENT_FILE_INFO_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{acti
export const ACTION_AGENT_FILE_DOWNLOAD_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}/file/{file_id}/download`;
export const ACTION_STATE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/state`;
+/** Endpoint Agent Routes */
+export const ENDPOINT_AGENT_STATUS_ROUTE = `/internal${BASE_ENDPOINT_ROUTE}/agent_status`;
+
export const failedFleetActionErrorCode = '424';
export const ENDPOINT_DEFAULT_PAGE = 0;
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/agents.ts b/x-pack/plugins/security_solution/common/endpoint/types/agents.ts
new file mode 100644
index 0000000000000..5394df26a3bae
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/endpoint/types/agents.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { HostStatus } from '.';
+import type {
+ ResponseActionsApiCommandNames,
+ ResponseActionAgentType,
+} from '../service/response_actions/constants';
+import {} from '../service/response_actions/constants';
+
+export interface AgentStatusInfo {
+ id: string;
+ agentType: ResponseActionAgentType;
+ found: boolean;
+ isolated: boolean;
+ isPendingUninstall: boolean;
+ isUninstalled: boolean;
+ lastSeen: string; // ISO date
+ pendingActions: Record;
+ status: HostStatus;
+}
+
+export interface AgentStatusApiResponse {
+ data: Record;
+}
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts
index 4887d83493f5c..f525c03ce17f7 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts
@@ -13,6 +13,7 @@ export * from './actions';
export * from './os';
export * from './trusted_apps';
export * from './utility_types';
+export * from './agents';
export type { ConditionEntriesMap, ConditionEntry } from './exception_list_items';
/**
diff --git a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts
index cfa004b7ce6b2..fb20548271191 100644
--- a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts
@@ -138,7 +138,6 @@ export const useResponderActionData = ({
capabilities: ['isolation'],
hostName: agentInfoFromAlert.host.name,
platform: agentInfoFromAlert.host.os.family,
- lastCheckin: agentInfoFromAlert.lastCheckin,
});
}
if (hostInfo) {
diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx
index 3d643dffc51cc..171515aea5122 100644
--- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx
@@ -8,29 +8,15 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
-import type { SentinelOneAgent } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
-import { HostStatus } from '../../../../common/endpoint/types';
import { getAgentStatusText } from '../../../common/components/endpoint/agent_status_text';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../management/pages/endpoint_hosts/view/host_constants';
-import { useSentinelOneAgentData } from './use_sentinelone_host_isolation';
+import { useGetSentinelOneAgentStatus } from './use_sentinelone_host_isolation';
import {
- ISOLATING_LABEL,
ISOLATED_LABEL,
+ ISOLATING_LABEL,
RELEASING_LABEL,
} from '../../../common/components/endpoint/endpoint_agent_status';
-const getSentinelOneAgentStatus = (data?: SentinelOneAgent) => {
- if (!data) {
- return HostStatus.UNENROLLED;
- }
-
- if (!data?.isActive) {
- return HostStatus.OFFLINE;
- }
-
- return HostStatus.HEALTHY;
-};
-
export enum SENTINEL_ONE_NETWORK_STATUS {
CONNECTING = 'connecting',
CONNECTED = 'connected',
@@ -46,25 +32,27 @@ const EuiFlexGroupStyled = styled(EuiFlexGroup)`
export const SentinelOneAgentStatus = React.memo(
({ agentId, 'data-test-subj': dataTestSubj }: { agentId: string; 'data-test-subj'?: string }) => {
- const { data, isFetched } = useSentinelOneAgentData({ agentId });
+ const { data, isLoading, isFetched } = useGetSentinelOneAgentStatus([agentId]);
+ const agentStatus = data?.[`${agentId}`];
const label = useMemo(() => {
- const networkStatus = data?.data?.data?.[0]?.networkStatus;
+ const currentNetworkStatus = agentStatus?.isolated;
+ const pendingActions = agentStatus?.pendingActions;
- if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING) {
- return ISOLATING_LABEL;
- }
+ if (pendingActions) {
+ if (pendingActions.isolate > 0) {
+ return ISOLATING_LABEL;
+ }
- if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED) {
- return ISOLATED_LABEL;
+ if (pendingActions.unisolate > 0) {
+ return RELEASING_LABEL;
+ }
}
- if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.CONNECTING) {
- return RELEASING_LABEL;
+ if (currentNetworkStatus) {
+ return ISOLATED_LABEL;
}
- }, [data?.data?.data]);
-
- const agentStatus = useMemo(() => getSentinelOneAgentStatus(data?.data?.data?.[0]), [data]);
+ }, [agentStatus?.isolated, agentStatus?.pendingActions]);
return (
- {isFetched ? (
-
- {getAgentStatusText(agentStatus)}
+ {isFetched && !isLoading && agentStatus ? (
+
+ {getAgentStatusText(agentStatus.status)}
) : (
'-'
)}
- {label && (
+ {isFetched && !isLoading && label && (
<>{label}>
diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx
index c899653a0c836..bf96225f5ca9d 100644
--- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx
@@ -20,7 +20,7 @@ import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { getFieldValue } from './helpers';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
-import { useSentinelOneAgentData } from './use_sentinelone_host_isolation';
+import { useGetSentinelOneAgentStatus } from './use_sentinelone_host_isolation';
interface UseHostIsolationActionProps {
closePopover: () => void;
@@ -78,23 +78,19 @@ export const useHostIsolationAction = ({
agentId,
});
- const { data: sentinelOneResponse } = useSentinelOneAgentData({ agentId: sentinelOneAgentId });
-
- const sentinelOneAgentData = useMemo(
- () => sentinelOneResponse?.data?.data?.[0],
- [sentinelOneResponse]
- );
+ const { data: sentinelOneAgentData } = useGetSentinelOneAgentStatus([sentinelOneAgentId || '']);
+ const sentinelOneAgentStatus = sentinelOneAgentData?.[`${sentinelOneAgentId}`];
const isHostIsolated = useMemo(() => {
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert) {
- return sentinelOneAgentData?.networkStatus === 'disconnected';
+ return sentinelOneAgentStatus?.isolated;
}
return isIsolated;
}, [
isIsolated,
isSentinelOneAlert,
- sentinelOneAgentData?.networkStatus,
+ sentinelOneAgentStatus?.isolated,
sentinelOneManualHostActionsEnabled,
]);
@@ -107,8 +103,8 @@ export const useHostIsolationAction = ({
});
}
- if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert && sentinelOneAgentData) {
- return sentinelOneAgentData.isActive;
+ if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert && sentinelOneAgentStatus) {
+ return sentinelOneAgentStatus.status === 'healthy';
}
return false;
}, [
@@ -117,7 +113,7 @@ export const useHostIsolationAction = ({
hostOsFamily,
isEndpointAlert,
isSentinelOneAlert,
- sentinelOneAgentData,
+ sentinelOneAgentStatus,
sentinelOneManualHostActionsEnabled,
]);
@@ -130,29 +126,34 @@ export const useHostIsolationAction = ({
}
}, [closePopover, isHostIsolated, onAddIsolationStatusClick]);
- const menuItemDisabled = useMemo(() => {
+ const isIsolationActionDisabled = useMemo(() => {
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert) {
return (
- !sentinelOneAgentData ||
- sentinelOneAgentData?.isUninstalled ||
- sentinelOneAgentData?.isPendingUninstall
+ !sentinelOneAgentStatus ||
+ sentinelOneAgentStatus?.isUninstalled ||
+ sentinelOneAgentStatus?.isPendingUninstall
);
}
return agentStatus === HostStatus.UNENROLLED;
- }, [agentStatus, isSentinelOneAlert, sentinelOneAgentData, sentinelOneManualHostActionsEnabled]);
+ }, [
+ agentStatus,
+ isSentinelOneAlert,
+ sentinelOneAgentStatus,
+ sentinelOneManualHostActionsEnabled,
+ ]);
const menuItems = useMemo(
() => [
{
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
- disabled: menuItemDisabled,
+ disabled: isIsolationActionDisabled,
onClick: isolateHostHandler,
name: isHostIsolated ? UNISOLATE_HOST : ISOLATE_HOST,
},
],
- [isHostIsolated, isolateHostHandler, menuItemDisabled]
+ [isHostIsolated, isolateHostHandler, isIsolationActionDisabled]
);
return useMemo(() => {
@@ -164,7 +165,7 @@ export const useHostIsolationAction = ({
isSentinelOneAlert &&
sentinelOneManualHostActionsEnabled &&
sentinelOneAgentId &&
- sentinelOneAgentData &&
+ sentinelOneAgentStatus &&
hasActionsAllPrivileges
) {
return menuItems;
@@ -191,7 +192,7 @@ export const useHostIsolationAction = ({
isSentinelOneAlert,
loadingHostIsolationStatus,
menuItems,
- sentinelOneAgentData,
+ sentinelOneAgentStatus,
sentinelOneAgentId,
sentinelOneManualHostActionsEnabled,
]);
diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_sentinelone_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_sentinelone_host_isolation.tsx
index f0fa47edf9c06..a8c9ea91a6d9e 100644
--- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_sentinelone_host_isolation.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_sentinelone_host_isolation.tsx
@@ -6,45 +6,51 @@
*/
import { isEmpty } from 'lodash';
-import type {
- SentinelOneGetAgentsParams,
- SentinelOneGetAgentsResponse,
-} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
-import { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '@kbn/stack-connectors-plugin/public/common';
+import type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
+import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+import { useQuery } from '@tanstack/react-query';
+import type { IHttpFetchError } from '@kbn/core-http-browser';
+import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
+import { ENDPOINT_AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
+import type { AgentStatusApiResponse } from '../../../../common/endpoint/types';
+import { useHttp } from '../../../common/lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
-import { useSubAction } from '../../../timelines/components/side_panel/event_details/flyout/use_sub_action';
-import { useLoadConnectors } from '../../../common/components/response_actions/use_load_connectors';
-import { SENTINEL_ONE_NETWORK_STATUS } from './sentinel_one_agent_status';
-/**
- * Using SentinelOne connector to pull agent's data from the SentinelOne API. If the agentId is in the transition state
- * (isolating/releasing) it will keep pulling the state until it finalizes the action
- * @param agentId
- */
-export const useSentinelOneAgentData = ({ agentId }: { agentId?: string }) => {
+interface ErrorType {
+ statusCode: number;
+ message: string;
+ meta: ActionTypeExecutorResult;
+}
+
+export const useGetSentinelOneAgentStatus = (
+ agentIds: string[],
+ options: UseQueryOptions> = {}
+): UseQueryResult> => {
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
- const { data: connector } = useLoadConnectors({ actionTypeId: SENTINELONE_CONNECTOR_ID });
- return useSubAction({
- connectorId: connector?.[0]?.id,
- subAction: SUB_ACTION.GET_AGENTS,
- subActionParams: {
- uuid: agentId,
- },
- disabled: !sentinelOneManualHostActionsEnabled || isEmpty(agentId),
- // @ts-expect-error update types
- refetchInterval: (lastResponse: { data: SentinelOneGetAgentsResponse }) => {
- const networkStatus = lastResponse?.data?.data?.[0]
- .networkStatus as SENTINEL_ONE_NETWORK_STATUS;
+ const http = useHttp();
- return [
- SENTINEL_ONE_NETWORK_STATUS.CONNECTING,
- SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING,
- ].includes(networkStatus)
- ? 5000
- : false;
- },
+ return useQuery>({
+ queryKey: ['get-agent-status', agentIds],
+ ...options,
+ enabled: !(
+ sentinelOneManualHostActionsEnabled &&
+ isEmpty(agentIds.filter((agentId) => agentId.trim().length))
+ ),
+ // TODO: update this to use a function instead of a number
+ refetchInterval: 2000,
+ queryFn: () =>
+ http
+ .get<{ data: AgentStatusApiResponse['data'] }>(ENDPOINT_AGENT_STATUS_ROUTE, {
+ version: '1',
+ query: {
+ agentIds,
+ // 8.13 sentinel_one support via internal API
+ agentType: 'sentinel_one',
+ },
+ })
+ .then((response) => response.data),
});
};
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx
index c27828f637c8a..562de8574146f 100644
--- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx
+++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx
@@ -18,7 +18,7 @@ import { LeftPanelInsightsTab, DocumentDetailsLeftPanelKey } from '../../left';
import { TestProviders } from '../../../../common/mock';
import { ENTITIES_TAB_ID } from '../../left/components/entities_details';
import { useGetEndpointDetails } from '../../../../management/hooks';
-import { useSentinelOneAgentData } from '../../../../detections/components/host_isolation/use_sentinelone_host_isolation';
+import { useGetSentinelOneAgentStatus } from '../../../../detections/components/host_isolation/use_sentinelone_host_isolation';
import { useExpandableFlyoutApi, type ExpandableFlyoutApi } from '@kbn/expandable-flyout';
jest.mock('../../../../management/hooks');
@@ -96,7 +96,10 @@ describe('', () => {
});
it('should render sentinelone agent status cell if field is agent.status and origialField is observer.serial_number', () => {
- (useSentinelOneAgentData as jest.Mock).mockReturnValue({ isFetched: true });
+ (useGetSentinelOneAgentStatus as jest.Mock).mockReturnValue({
+ isFetched: true,
+ isLoading: false,
+ });
const { getByTestId } = render(
(
- ({ agentId, platform, hostName, lastCheckin }) => {
+ ({ agentId, platform, hostName }) => {
+ const { data } = useGetSentinelOneAgentStatus([agentId]);
+ const agentStatus = data?.[agentId];
+ const lastCheckin = agentStatus ? agentStatus.lastSeen : '';
+
return (
;
capabilities: ImmutableArray;
platform: string;
- lastCheckin: string;
});
export const useWithShowResponder = (): ShowResponseActionsConsole => {
@@ -115,7 +114,6 @@ export const useWithShowResponder = (): ShowResponseActionsConsole => {
);
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
index 4789ff8046e6d..066e9f950ad44 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
@@ -73,7 +73,7 @@ import { omit, set } from 'lodash';
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
import { responseActionsClientMock } from '../../services/actions/clients/mocks';
import type { ActionsApiRequestHandlerContext } from '@kbn/actions-plugin/server';
-import { sentinelOneMock } from '../../services/actions/clients/sentinelone/mock';
+import { sentinelOneMock } from '../../services/actions/clients/sentinelone/mocks';
jest.mock('../../services', () => {
const realModule = jest.requireActual('../../services');
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts
new file mode 100644
index 0000000000000..7ab7cd3b3c8d4
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { HttpApiTestSetupMock } from '../../mocks';
+import { createHttpApiTestSetupMock } from '../../mocks';
+import { sentinelOneMock } from '../../services/actions/clients/sentinelone/mocks';
+import { registerAgentStatusRoute } from './agent_status_handler';
+import { ENDPOINT_AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
+import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
+import type { EndpointAgentStatusRequestQueryParams } from '../../../../common/api/endpoint/agent/get_agent_status_route';
+
+describe('Agent Status API route handler', () => {
+ let apiTestSetup: HttpApiTestSetupMock;
+ let httpRequestMock: ReturnType<
+ HttpApiTestSetupMock['createRequestMock']
+ >;
+ let httpHandlerContextMock: HttpApiTestSetupMock<
+ never,
+ EndpointAgentStatusRequestQueryParams
+ >['httpHandlerContextMock'];
+ let httpResponseMock: HttpApiTestSetupMock<
+ never,
+ EndpointAgentStatusRequestQueryParams
+ >['httpResponseMock'];
+
+ beforeEach(async () => {
+ apiTestSetup = createHttpApiTestSetupMock();
+ ({ httpHandlerContextMock, httpResponseMock } = apiTestSetup);
+
+ httpRequestMock = apiTestSetup.createRequestMock({
+ query: { agentType: 'sentinel_one', agentIds: ['one', 'two'] },
+ });
+
+ (
+ (await apiTestSetup.httpHandlerContextMock.actions).getActionsClient as jest.Mock
+ ).mockReturnValue(sentinelOneMock.createConnectorActionsClient());
+
+ apiTestSetup.endpointAppContextMock.experimentalFeatures = {
+ ...apiTestSetup.endpointAppContextMock.experimentalFeatures,
+ responseActionsSentinelOneV1Enabled: true,
+ };
+
+ registerAgentStatusRoute(apiTestSetup.routerMock, apiTestSetup.endpointAppContextMock);
+ });
+
+ it('should error if the sentinel_one feature flag is turned off', async () => {
+ apiTestSetup.endpointAppContextMock.experimentalFeatures = {
+ ...apiTestSetup.endpointAppContextMock.experimentalFeatures,
+ responseActionsSentinelOneV1Enabled: false,
+ };
+
+ await apiTestSetup
+ .getRegisteredVersionedRoute('get', ENDPOINT_AGENT_STATUS_ROUTE, '1')
+ .routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
+
+ expect(httpResponseMock.customError).toHaveBeenCalledWith({
+ statusCode: 400,
+ body: expect.any(CustomHttpRequestError),
+ });
+ });
+
+ it('should only (v8.13) accept agent type of sentinel_one', async () => {
+ // @ts-expect-error `query.*` is not mutable
+ httpRequestMock.query.agentType = 'endpoint';
+ await apiTestSetup
+ .getRegisteredVersionedRoute('get', ENDPOINT_AGENT_STATUS_ROUTE, '1')
+ .routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
+
+ expect(httpResponseMock.customError).toHaveBeenCalledWith({
+ statusCode: 400,
+ body: expect.any(CustomHttpRequestError),
+ });
+ });
+
+ it('should return status code 200 with expected payload', async () => {
+ await apiTestSetup
+ .getRegisteredVersionedRoute('get', ENDPOINT_AGENT_STATUS_ROUTE, '1')
+ .routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
+
+ expect(httpResponseMock.ok).toHaveBeenCalledWith({
+ body: {
+ data: {
+ one: {
+ agentType: 'sentinel_one',
+ found: false,
+ id: 'one',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: false,
+ lastSeen: '',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 0,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 0,
+ upload: 0,
+ },
+ status: 'unenrolled',
+ },
+ two: {
+ agentType: 'sentinel_one',
+ found: false,
+ id: 'two',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: false,
+ lastSeen: '',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 0,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 0,
+ upload: 0,
+ },
+ status: 'unenrolled',
+ },
+ },
+ },
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts
new file mode 100644
index 0000000000000..2bf78bbb73b99
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts
@@ -0,0 +1,107 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { RequestHandler } from '@kbn/core/server';
+import { getAgentStatus } from '../../services/agent/agent_status';
+import { errorHandler } from '../error_handler';
+import type { EndpointAgentStatusRequestQueryParams } from '../../../../common/api/endpoint/agent/get_agent_status_route';
+import { EndpointAgentStatusRequestSchema } from '../../../../common/api/endpoint/agent/get_agent_status_route';
+import { ENDPOINT_AGENT_STATUS_ROUTE } from '../../../../common/endpoint/constants';
+import type {
+ SecuritySolutionPluginRouter,
+ SecuritySolutionRequestHandlerContext,
+} from '../../../types';
+import type { EndpointAppContext } from '../../types';
+import { withEndpointAuthz } from '../with_endpoint_authz';
+import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
+
+export const registerAgentStatusRoute = (
+ router: SecuritySolutionPluginRouter,
+ endpointContext: EndpointAppContext
+) => {
+ router.versioned
+ .get({
+ access: 'internal',
+ path: ENDPOINT_AGENT_STATUS_ROUTE,
+ options: { authRequired: true, tags: ['access:securitySolution'] },
+ })
+ .addVersion(
+ {
+ version: '1',
+ validate: {
+ request: EndpointAgentStatusRequestSchema,
+ },
+ },
+ withEndpointAuthz(
+ { all: ['canReadSecuritySolution'] },
+ endpointContext.logFactory.get('actionStatusRoute'),
+ getAgentStatusRouteHandler(endpointContext)
+ )
+ );
+};
+
+export const getAgentStatusRouteHandler = (
+ endpointContext: EndpointAppContext
+): RequestHandler<
+ never,
+ EndpointAgentStatusRequestQueryParams,
+ unknown,
+ SecuritySolutionRequestHandlerContext
+> => {
+ const logger = endpointContext.logFactory.get('agentStatus');
+
+ return async (context, request, response) => {
+ const { agentType = 'endpoint', agentIds: _agentIds } = request.query;
+ const agentIds = Array.isArray(_agentIds) ? _agentIds : [_agentIds];
+
+ // Note: because our API schemas are defined as module static variables (as opposed to a
+ // `getter` function), we need to include this additional validation here, since
+ // `agent_type` is included in the schema independent of the feature flag
+ if (
+ agentType === 'sentinel_one' &&
+ !endpointContext.experimentalFeatures.responseActionsSentinelOneV1Enabled
+ ) {
+ return errorHandler(
+ logger,
+ response,
+ new CustomHttpRequestError(`[request query.agent_type]: feature is disabled`, 400)
+ );
+ }
+
+ // TEMPORARY:
+ // For v8.13 we only support SentinelOne on this API due to time constraints
+ if (agentType !== 'sentinel_one') {
+ return errorHandler(
+ logger,
+ response,
+ new CustomHttpRequestError(
+ `[${agentType}] agent type is not currently supported by this API`,
+ 400
+ )
+ );
+ }
+
+ logger.debug(
+ `Retrieving status for: agentType [${agentType}], agentIds: [${agentIds.join(', ')}]`
+ );
+
+ try {
+ return response.ok({
+ body: {
+ data: await getAgentStatus({
+ agentType,
+ agentIds,
+ logger,
+ connectorActionsClient: (await context.actions).getActionsClient(),
+ }),
+ },
+ });
+ } catch (e) {
+ return errorHandler(logger, response, e);
+ }
+ };
+};
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/agent/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/agent/index.ts
new file mode 100644
index 0000000000000..be028ae114218
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/agent/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { registerAgentStatusRoute } from './agent_status_handler';
+import type { SecuritySolutionPluginRouter } from '../../../types';
+import type { EndpointAppContext } from '../../types';
+
+export const registerAgentRoutes = (
+ router: SecuritySolutionPluginRouter,
+ endpointContext: EndpointAppContext
+) => {
+ registerAgentStatusRoute(router, endpointContext);
+};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/get_response_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/get_response_actions_client.test.ts
index 224c8eac855ed..904cc9dfad3d7 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/get_response_actions_client.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/get_response_actions_client.test.ts
@@ -11,7 +11,7 @@ import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../../common/endpoint/servi
import { getResponseActionsClient } from '../..';
import { ResponseActionsClientImpl } from './lib/base_response_actions_client';
import { UnsupportedResponseActionsAgentTypeError } from './errors';
-import { sentinelOneMock } from './sentinelone/mock';
+import { sentinelOneMock } from './sentinelone/mocks';
describe('getResponseActionsClient()', () => {
let options: GetResponseActionsClientConstructorOptions;
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mock.ts
deleted file mode 100644
index 48a6ace18adc5..0000000000000
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mock.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
-import {
- SENTINELONE_CONNECTOR_ID,
- SUB_ACTION,
-} from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
-import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
-import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
-import type { ResponseActionsClientOptionsMock } from '../mocks';
-import { responseActionsClientMock } from '../mocks';
-
-export interface SentinelOneActionsClientOptionsMock extends ResponseActionsClientOptionsMock {
- connectorActions: ActionsClientMock;
-}
-
-const createSentinelOneGetAgentsApiResponseMock = (): SentinelOneGetAgentsResponse => {
- return {
- pagination: {
- nextCursor: 'next-0',
- totalItems: 1,
- },
- errors: null,
- data: [
- {
- accountId: '11111111111',
- accountName: 'Elastic',
- groupUpdatedAt: null,
- policyUpdatedAt: null,
- activeDirectory: {
- computerDistinguishedName: null,
- computerMemberOf: [],
- lastUserDistinguishedName: null,
- lastUserMemberOf: [],
- userPrincipalName: null,
- mail: null,
- },
- activeThreats: 0,
- agentVersion: '23.3.2.12',
- allowRemoteShell: true,
- appsVulnerabilityStatus: 'not_applicable',
- cloudProviders: {},
- computerName: 'sentinelone-1460',
- consoleMigrationStatus: 'N/A',
- coreCount: 1,
- cpuCount: 1,
- cpuId: 'ARM Cortex-A72',
- createdAt: '2023-12-21T20:32:52.290978Z',
- detectionState: null,
- domain: 'unknown',
- encryptedApplications: false,
- externalId: '',
- externalIp: '108.77.84.191',
- firewallEnabled: true,
- firstFullModeTime: null,
- fullDiskScanLastUpdatedAt: '2023-12-21T20:57:55.690655Z',
- groupId: '9999999999999',
- groupIp: '108.77.84.x',
- groupName: 'Default Group',
- id: '1845174760470303882',
- inRemoteShellSession: false,
- infected: false,
- installerType: '.deb',
- isActive: true,
- isDecommissioned: false,
- isPendingUninstall: false,
- isUninstalled: false,
- isUpToDate: true,
- lastActiveDate: '2023-12-26T21:34:28.032981Z',
- lastIpToMgmt: '192.168.64.2',
- lastLoggedInUserName: '',
- licenseKey: '',
- locationEnabled: false,
- locationType: 'not_supported',
- locations: null,
- machineType: 'server',
- mitigationMode: 'detect',
- mitigationModeSuspicious: 'detect',
- modelName: 'QEMU QEMU Virtual Machine',
- networkInterfaces: [
- {
- gatewayIp: '192.168.64.1',
- gatewayMacAddress: 'be:d0:74:50:d8:64',
- id: '1845174760470303883',
- inet: ['192.168.64.2'],
- inet6: ['fdf4:f033:b1d4:8c51:5054:ff:febc:6253'],
- name: 'enp0s1',
- physical: '52:54:00:BC:62:53',
- },
- ],
- networkQuarantineEnabled: false,
- networkStatus: 'connecting',
- operationalState: 'na',
- operationalStateExpiration: null,
- osArch: '64 bit',
- osName: 'Linux',
- osRevision: 'Ubuntu 22.04.3 LTS 5.15.0-91-generic',
- osStartTime: '2023-12-21T20:31:51Z',
- osType: 'linux',
- osUsername: 'root',
- rangerStatus: 'Enabled',
- rangerVersion: '23.4.0.9',
- registeredAt: '2023-12-21T20:32:52.286752Z',
- remoteProfilingState: 'disabled',
- remoteProfilingStateExpiration: null,
- scanAbortedAt: null,
- scanFinishedAt: '2023-12-21T20:57:55.690655Z',
- scanStartedAt: '2023-12-21T20:33:31.170460Z',
- scanStatus: 'finished',
- serialNumber: null,
- showAlertIcon: false,
- siteId: '88888888888',
- siteName: 'Default site',
- storageName: null,
- storageType: null,
- tags: { sentinelone: [] },
- threatRebootRequired: false,
- totalMemory: 1966,
- updatedAt: '2023-12-26T21:35:35.986596Z',
- userActionsNeeded: [],
- uuid: 'a2f4603d-c9e2-d7a2-bec2-0d646f3bbc9f',
- },
- ],
- };
-};
-
-const createConnectorActionsClientMock = (): ActionsClientMock => {
- const client = responseActionsClientMock.createConnectorActionsClient();
-
- (client.getAll as jest.Mock).mockImplementation(async () => {
- const result: ConnectorWithExtraFindData[] = [
- // SentinelOne connector
- responseActionsClientMock.createConnector({
- actionTypeId: SENTINELONE_CONNECTOR_ID,
- id: 's1-connector-instance-id',
- }),
- ];
-
- return result;
- });
-
- (client.execute as jest.Mock).mockImplementation(
- async (options: Parameters[0]) => {
- const subAction = options.params.subAction;
-
- switch (subAction) {
- case SUB_ACTION.GET_AGENTS:
- return responseActionsClientMock.createConnectorActionExecuteResponse({
- data: createSentinelOneGetAgentsApiResponseMock(),
- });
-
- default:
- return responseActionsClientMock.createConnectorActionExecuteResponse();
- }
- }
- );
-
- return client;
-};
-
-const createConstructorOptionsMock = (): SentinelOneActionsClientOptionsMock => {
- return {
- ...responseActionsClientMock.createConstructorOptions(),
- connectorActions: createConnectorActionsClientMock(),
- };
-};
-
-export const sentinelOneMock = {
- createGetAgentsResponse: createSentinelOneGetAgentsApiResponseMock,
- createConnectorActionsClient: createConnectorActionsClientMock,
- createConstructorOptions: createConstructorOptionsMock,
-};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mocks.ts
new file mode 100644
index 0000000000000..2701120d1e4f0
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/mocks.ts
@@ -0,0 +1,188 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
+import {
+ SENTINELONE_CONNECTOR_ID,
+ SUB_ACTION,
+} from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
+import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
+import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
+import { merge } from 'lodash';
+import type { ResponseActionsClientOptionsMock } from '../mocks';
+import { responseActionsClientMock } from '../mocks';
+
+export interface SentinelOneActionsClientOptionsMock extends ResponseActionsClientOptionsMock {
+ connectorActions: ActionsClientMock;
+}
+
+const createSentinelOneAgentDetailsMock = (
+ overrides: Partial = {}
+): SentinelOneGetAgentsResponse['data'][number] => {
+ return merge(
+ {
+ accountId: '11111111111',
+ accountName: 'Elastic',
+ groupUpdatedAt: null,
+ policyUpdatedAt: null,
+ activeDirectory: {
+ computerDistinguishedName: null,
+ computerMemberOf: [],
+ lastUserDistinguishedName: null,
+ lastUserMemberOf: [],
+ userPrincipalName: null,
+ mail: null,
+ },
+ activeThreats: 0,
+ agentVersion: '23.3.2.12',
+ allowRemoteShell: true,
+ appsVulnerabilityStatus: 'not_applicable',
+ cloudProviders: {},
+ computerName: 'sentinelone-1460',
+ consoleMigrationStatus: 'N/A',
+ coreCount: 1,
+ cpuCount: 1,
+ cpuId: 'ARM Cortex-A72',
+ createdAt: '2023-12-21T20:32:52.290978Z',
+ detectionState: null,
+ domain: 'unknown',
+ encryptedApplications: false,
+ externalId: '',
+ externalIp: '108.77.84.191',
+ firewallEnabled: true,
+ firstFullModeTime: null,
+ fullDiskScanLastUpdatedAt: '2023-12-21T20:57:55.690655Z',
+ groupId: '9999999999999',
+ groupIp: '108.77.84.x',
+ groupName: 'Default Group',
+ id: '1845174760470303882',
+ inRemoteShellSession: false,
+ infected: false,
+ installerType: '.deb',
+ isActive: true,
+ isDecommissioned: false,
+ isPendingUninstall: false,
+ isUninstalled: false,
+ isUpToDate: true,
+ lastActiveDate: '2023-12-26T21:34:28.032981Z',
+ lastIpToMgmt: '192.168.64.2',
+ lastLoggedInUserName: '',
+ licenseKey: '',
+ locationEnabled: false,
+ locationType: 'not_supported',
+ locations: null,
+ machineType: 'server',
+ mitigationMode: 'detect',
+ mitigationModeSuspicious: 'detect',
+ modelName: 'QEMU QEMU Virtual Machine',
+ networkInterfaces: [
+ {
+ gatewayIp: '192.168.64.1',
+ gatewayMacAddress: 'be:d0:74:50:d8:64',
+ id: '1845174760470303883',
+ inet: ['192.168.64.2'],
+ inet6: ['fdf4:f033:b1d4:8c51:5054:ff:febc:6253'],
+ name: 'enp0s1',
+ physical: '52:54:00:BC:62:53',
+ },
+ ],
+ networkQuarantineEnabled: false,
+ networkStatus: 'connecting',
+ operationalState: 'na',
+ operationalStateExpiration: null,
+ osArch: '64 bit',
+ osName: 'Linux',
+ osRevision: 'Ubuntu 22.04.3 LTS 5.15.0-91-generic',
+ osStartTime: '2023-12-21T20:31:51Z',
+ osType: 'linux',
+ osUsername: 'root',
+ rangerStatus: 'Enabled',
+ rangerVersion: '23.4.0.9',
+ registeredAt: '2023-12-21T20:32:52.286752Z',
+ remoteProfilingState: 'disabled',
+ remoteProfilingStateExpiration: null,
+ scanAbortedAt: null,
+ scanFinishedAt: '2023-12-21T20:57:55.690655Z',
+ scanStartedAt: '2023-12-21T20:33:31.170460Z',
+ scanStatus: 'finished',
+ serialNumber: null,
+ showAlertIcon: false,
+ siteId: '88888888888',
+ siteName: 'Default site',
+ storageName: null,
+ storageType: null,
+ tags: { sentinelone: [] },
+ threatRebootRequired: false,
+ totalMemory: 1966,
+ updatedAt: '2023-12-26T21:35:35.986596Z',
+ userActionsNeeded: [],
+ uuid: 'a2f4603d-c9e2-d7a2-bec2-0d646f3bbc9f',
+ },
+ overrides
+ );
+};
+
+const createSentinelOneGetAgentsApiResponseMock = (
+ data: SentinelOneGetAgentsResponse['data'] = [createSentinelOneAgentDetailsMock()]
+): SentinelOneGetAgentsResponse => {
+ return {
+ pagination: {
+ nextCursor: 'next-0',
+ totalItems: 1,
+ },
+ errors: null,
+ data,
+ };
+};
+
+const createConnectorActionsClientMock = (): ActionsClientMock => {
+ const client = responseActionsClientMock.createConnectorActionsClient();
+
+ (client.getAll as jest.Mock).mockImplementation(async () => {
+ const result: ConnectorWithExtraFindData[] = [
+ // SentinelOne connector
+ responseActionsClientMock.createConnector({
+ actionTypeId: SENTINELONE_CONNECTOR_ID,
+ id: 's1-connector-instance-id',
+ }),
+ ];
+
+ return result;
+ });
+
+ (client.execute as jest.Mock).mockImplementation(
+ async (options: Parameters[0]) => {
+ const subAction = options.params.subAction;
+
+ switch (subAction) {
+ case SUB_ACTION.GET_AGENTS:
+ return responseActionsClientMock.createConnectorActionExecuteResponse({
+ data: createSentinelOneGetAgentsApiResponseMock(),
+ });
+
+ default:
+ return responseActionsClientMock.createConnectorActionExecuteResponse();
+ }
+ }
+ );
+
+ return client;
+};
+
+const createConstructorOptionsMock = (): SentinelOneActionsClientOptionsMock => {
+ return {
+ ...responseActionsClientMock.createConstructorOptions(),
+ connectorActions: createConnectorActionsClientMock(),
+ };
+};
+
+export const sentinelOneMock = {
+ createGetAgentsResponse: createSentinelOneGetAgentsApiResponseMock,
+ createSentinelOneAgentDetails: createSentinelOneAgentDetailsMock,
+ createConnectorActionsClient: createConnectorActionsClientMock,
+ createConstructorOptions: createConstructorOptionsMock,
+};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
index c506e8615ed04..aec2ffdebe1ee 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
@@ -11,8 +11,8 @@ import { SentinelOneActionsClient } from './sentinel_one_actions_client';
import { getActionDetailsById as _getActionDetailsById } from '../../action_details_by_id';
import { ResponseActionsClientError, ResponseActionsNotSupportedError } from '../errors';
import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
-import type { SentinelOneActionsClientOptionsMock } from './mock';
-import { sentinelOneMock } from './mock';
+import type { SentinelOneActionsClientOptionsMock } from './mocks';
+import { sentinelOneMock } from './mocks';
import {
ENDPOINT_ACTION_RESPONSES_INDEX,
ENDPOINT_ACTIONS_INDEX,
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.test.ts
new file mode 100644
index 0000000000000..8037590d323c2
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.test.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { GetAgentStatusOptions } from './agent_status';
+import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
+import { sentinelOneMock } from '../actions/clients/sentinelone/mocks';
+import { getAgentStatus, SENTINEL_ONE_NETWORK_STATUS } from './agent_status';
+import { responseActionsClientMock } from '../actions/clients/mocks';
+
+describe('Endpoint Get Agent Status service', () => {
+ let agentStatusOptions: GetAgentStatusOptions;
+
+ beforeEach(() => {
+ agentStatusOptions = {
+ agentType: 'sentinel_one',
+ agentIds: ['1', '2'],
+ logger: loggingSystemMock.create().get('getAgentStatus'),
+ connectorActionsClient: sentinelOneMock.createConnectorActionsClient(),
+ };
+ });
+
+ it('should throw error if unable to access stack connectors', async () => {
+ (agentStatusOptions.connectorActionsClient.getAll as jest.Mock).mockImplementation(async () => {
+ throw new Error('boom');
+ });
+ const getStatusResponsePromise = getAgentStatus(agentStatusOptions);
+
+ await expect(getStatusResponsePromise).rejects.toHaveProperty(
+ 'message',
+ 'Unable to retrieve list of stack connectors: boom'
+ );
+ await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 400);
+ });
+
+ it('should throw error if no SentinelOne connector is registered', async () => {
+ (agentStatusOptions.connectorActionsClient.getAll as jest.Mock).mockResolvedValue([]);
+ const getStatusResponsePromise = getAgentStatus(agentStatusOptions);
+
+ await expect(getStatusResponsePromise).rejects.toHaveProperty(
+ 'message',
+ 'No SentinelOne stack connector found'
+ );
+ await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 400);
+ });
+
+ it('should send api request to SentinelOne', async () => {
+ await getAgentStatus(agentStatusOptions);
+
+ expect(agentStatusOptions.connectorActionsClient.execute).toHaveBeenCalledWith({
+ actionId: 's1-connector-instance-id',
+ params: {
+ subAction: 'getAgents',
+ subActionParams: {
+ uuids: '1,2',
+ },
+ },
+ });
+ });
+
+ it('should throw if api call to SentinelOne failed', async () => {
+ (agentStatusOptions.connectorActionsClient.execute as jest.Mock).mockResolvedValue(
+ responseActionsClientMock.createConnectorActionExecuteResponse({
+ status: 'error',
+ serviceMessage: 'boom',
+ })
+ );
+ const getStatusResponsePromise = getAgentStatus(agentStatusOptions);
+
+ await expect(getStatusResponsePromise).rejects.toHaveProperty(
+ 'message',
+ 'Attempt retrieve agent information from to SentinelOne failed: boom'
+ );
+ await expect(getStatusResponsePromise).rejects.toHaveProperty('statusCode', 500);
+ });
+
+ it('should return expected output', async () => {
+ agentStatusOptions.agentIds = ['aaa', 'bbb', 'ccc', 'invalid'];
+ (agentStatusOptions.connectorActionsClient.execute as jest.Mock).mockResolvedValue(
+ responseActionsClientMock.createConnectorActionExecuteResponse({
+ data: sentinelOneMock.createGetAgentsResponse([
+ sentinelOneMock.createSentinelOneAgentDetails({
+ networkStatus: SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED, // Isolated
+ uuid: 'aaa',
+ }),
+ sentinelOneMock.createSentinelOneAgentDetails({
+ networkStatus: SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING, // Releasing
+ uuid: 'bbb',
+ }),
+ sentinelOneMock.createSentinelOneAgentDetails({
+ networkStatus: SENTINEL_ONE_NETWORK_STATUS.CONNECTING, // isolating
+ uuid: 'ccc',
+ }),
+ ]),
+ })
+ );
+
+ await expect(getAgentStatus(agentStatusOptions)).resolves.toEqual({
+ aaa: {
+ agentType: 'sentinel_one',
+ found: true,
+ id: 'aaa',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: true,
+ lastSeen: '2023-12-26T21:35:35.986596Z',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 0,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 0,
+ upload: 0,
+ },
+ status: 'healthy',
+ },
+ bbb: {
+ agentType: 'sentinel_one',
+ found: true,
+ id: 'bbb',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: false,
+ lastSeen: '2023-12-26T21:35:35.986596Z',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 1,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 0,
+ upload: 0,
+ },
+ status: 'healthy',
+ },
+ ccc: {
+ agentType: 'sentinel_one',
+ found: true,
+ id: 'ccc',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: false,
+ lastSeen: '2023-12-26T21:35:35.986596Z',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 0,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 1,
+ upload: 0,
+ },
+ status: 'healthy',
+ },
+ invalid: {
+ agentType: 'sentinel_one',
+ found: false,
+ id: 'invalid',
+ isUninstalled: false,
+ isPendingUninstall: false,
+ isolated: false,
+ lastSeen: '',
+ pendingActions: {
+ execute: 0,
+ 'get-file': 0,
+ isolate: 0,
+ 'kill-process': 0,
+ 'running-processes': 0,
+ 'suspend-process': 0,
+ unisolate: 0,
+ upload: 0,
+ },
+ status: 'unenrolled',
+ },
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.ts b/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.ts
new file mode 100644
index 0000000000000..f91b4238979dc
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/agent/agent_status.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ActionsClient } from '@kbn/actions-plugin/server';
+import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
+import {
+ SENTINELONE_CONNECTOR_ID,
+ SUB_ACTION,
+} from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
+import type { Logger } from '@kbn/core/server';
+import { keyBy, merge } from 'lodash';
+import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
+import type { SentinelOneGetAgentsResponse } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
+import { stringify } from '../../utils/stringify';
+import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
+import type { AgentStatusApiResponse } from '../../../../common/endpoint/types';
+import { HostStatus } from '../../../../common/endpoint/types';
+import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
+
+export interface GetAgentStatusOptions {
+ // NOTE: only sentinel_one currently supported
+ agentType: ResponseActionAgentType & 'sentinel_one';
+ agentIds: string[];
+ connectorActionsClient: ActionsClient;
+ logger: Logger;
+}
+
+export const getAgentStatus = async ({
+ agentType,
+ agentIds,
+ connectorActionsClient,
+ logger,
+}: GetAgentStatusOptions): Promise => {
+ let connectorList: ConnectorWithExtraFindData[] = [];
+
+ try {
+ connectorList = await connectorActionsClient.getAll();
+ } catch (err) {
+ throw new CustomHttpRequestError(
+ `Unable to retrieve list of stack connectors: ${err.message}`,
+ // failure here is likely due to Authz, but because we don't have a good way to determine that,
+ // the `statusCode` below is set to `400` instead of `401`.
+ 400,
+ err
+ );
+ }
+ const connector = connectorList.find(({ actionTypeId, isDeprecated, isMissingSecrets }) => {
+ return actionTypeId === SENTINELONE_CONNECTOR_ID && !isDeprecated && !isMissingSecrets;
+ });
+
+ if (!connector) {
+ throw new CustomHttpRequestError(`No SentinelOne stack connector found`, 400, connectorList);
+ }
+
+ logger.debug(`Using SentinelOne stack connector: ${connector.name} (${connector.id})`);
+
+ const agentDetailsResponse = (await connectorActionsClient.execute({
+ actionId: connector.id,
+ params: {
+ subAction: SUB_ACTION.GET_AGENTS,
+ subActionParams: {
+ uuids: agentIds.filter((agentId) => agentId.trim().length).join(','),
+ },
+ },
+ })) as ActionTypeExecutorResult;
+
+ if (agentDetailsResponse.status === 'error') {
+ logger.error(stringify(agentDetailsResponse));
+
+ throw new CustomHttpRequestError(
+ `Attempt retrieve agent information from to SentinelOne failed: ${
+ agentDetailsResponse.serviceMessage || agentDetailsResponse.message
+ }`,
+ 500,
+ agentDetailsResponse
+ );
+ }
+
+ const agentDetailsById = keyBy(agentDetailsResponse.data?.data, 'uuid');
+
+ logger.debug(`Response from SentinelOne API:\n${stringify(agentDetailsById)}`);
+
+ return agentIds.reduce((acc, agentId) => {
+ const thisAgentDetails = agentDetailsById[agentId];
+ const thisAgentStatus = {
+ agentType,
+ id: agentId,
+ found: false,
+ isolated: false,
+ isPendingUninstall: false,
+ isUninstalled: false,
+ lastSeen: '',
+ pendingActions: {
+ execute: 0,
+ upload: 0,
+ unisolate: 0,
+ isolate: 0,
+ 'get-file': 0,
+ 'kill-process': 0,
+ 'suspend-process': 0,
+ 'running-processes': 0,
+ },
+ status: HostStatus.UNENROLLED,
+ };
+
+ if (thisAgentDetails) {
+ merge(thisAgentStatus, {
+ found: true,
+ lastSeen: thisAgentDetails.updatedAt,
+ isPendingUninstall: thisAgentDetails.isPendingUninstall,
+ isUninstalled: thisAgentDetails.isUninstalled,
+ isolated: thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED,
+ status: !thisAgentDetails.isActive ? HostStatus.OFFLINE : HostStatus.HEALTHY,
+ pendingActions: {
+ isolate:
+ thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING ? 1 : 0,
+ unisolate:
+ thisAgentDetails.networkStatus === SENTINEL_ONE_NETWORK_STATUS.CONNECTING ? 1 : 0,
+ },
+ });
+ }
+
+ acc[agentId] = thisAgentStatus;
+
+ return acc;
+ }, {});
+};
+
+export enum SENTINEL_ONE_NETWORK_STATUS {
+ CONNECTING = 'connecting',
+ CONNECTED = 'connected',
+ DISCONNECTING = 'disconnecting',
+ DISCONNECTED = 'disconnected',
+}
diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts
index 93f49275491ee..1af0d9171153b 100644
--- a/x-pack/plugins/security_solution/server/plugin.ts
+++ b/x-pack/plugins/security_solution/server/plugin.ts
@@ -19,6 +19,7 @@ import type { ILicense } from '@kbn/licensing-plugin/server';
import { FLEET_ENDPOINT_PACKAGE } from '@kbn/fleet-plugin/common';
import { i18n } from '@kbn/i18n';
+import { registerAgentRoutes } from './endpoint/routes/agent';
import { endpointPackagePoliciesStatsSearchStrategyProvider } from './search_strategy/endpoint_package_policies_stats';
import { turnOffPolicyProtectionsIfNotSupported } from './endpoint/migrations/turn_off_policy_protections';
import { endpointSearchStrategyProvider } from './search_strategy/endpoint';
@@ -363,6 +364,7 @@ export class Plugin implements ISecuritySolutionPlugin {
this.endpointContext,
plugins.encryptedSavedObjects?.canEncrypt === true
);
+ registerAgentRoutes(router, this.endpointContext);
if (plugins.alerting != null) {
const ruleNotificationType = legacyRulesNotificationRuleType({ logger });
diff --git a/x-pack/plugins/stack_connectors/common/sentinelone/types.ts b/x-pack/plugins/stack_connectors/common/sentinelone/types.ts
index 65dda9e6028a3..95f0391c7e5f4 100644
--- a/x-pack/plugins/stack_connectors/common/sentinelone/types.ts
+++ b/x-pack/plugins/stack_connectors/common/sentinelone/types.ts
@@ -29,8 +29,6 @@ export type SentinelOneBaseApiResponse = TypeOf>;
export type SentinelOneGetAgentsResponse = TypeOf;
-export type SentinelOneAgent = SentinelOneGetAgentsResponse['data'][0];
-
export type SentinelOneKillProcessParams = TypeOf;
export type SentinelOneExecuteScriptParams = TypeOf;
From 8af71e4889682380623376843af7c096522812b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebasti=C3=A1n=20Zaffarano?=
Date: Mon, 12 Feb 2024 21:54:56 +0100
Subject: [PATCH 26/83] [Security Solution] Add async telemetry events sender
(#174577)
---
.../__mocks__/endpoint-alert.json | 196 +++
.../integration_tests/__mocks__/rule.json | 127 ++
.../server/integration_tests/lib/helpers.ts | 109 ++
.../lib/telemetry_helpers.ts | 269 ++++
.../integration_tests/telemetry.test.ts | 351 +++++
.../utils/get_detecton_rules_preview.ts | 6 +-
.../utils/get_diagnostics_preview.ts | 6 +-
.../telemetry/utils/get_endpoint_preview.ts | 6 +-
.../utils/get_security_lists_preview.ts | 6 +-
.../query/create_query_alert_type.test.ts | 2 +-
.../rule_types/utils/send_telemetry_events.ts | 3 +-
.../server/lib/telemetry/__mocks__/index.ts | 35 +
.../telemetry/__mocks__/kibana-artifacts.zip | Bin 1788 -> 2109 bytes
.../server/lib/telemetry/artifact.test.ts | 46 +-
.../server/lib/telemetry/artifact.ts | 101 +-
.../server/lib/telemetry/async_sender.test.ts | 1148 +++++++++++++++++
.../server/lib/telemetry/async_sender.ts | 414 ++++++
.../lib/telemetry/async_sender.types.ts | 55 +
.../lib/telemetry/collections_helpers.test.ts | 81 ++
.../lib/telemetry/collections_helpers.ts | 65 +
.../server/lib/telemetry/configuration.ts | 28 +-
.../server/lib/telemetry/helpers.test.ts | 42 -
.../server/lib/telemetry/helpers.ts | 27 +-
.../server/lib/telemetry/preview_sender.ts | 40 +-
.../lib/telemetry/preview_task_metrics.ts | 52 +
.../server/lib/telemetry/rxjs_helpers.test.ts | 166 +++
.../server/lib/telemetry/rxjs_helpers.ts | 83 ++
.../server/lib/telemetry/sender.ts | 74 +-
.../server/lib/telemetry/sender_helpers.ts | 125 ++
.../server/lib/telemetry/task.test.ts | 14 +-
.../server/lib/telemetry/task.ts | 16 +-
.../server/lib/telemetry/task_metrics.test.ts | 80 ++
.../server/lib/telemetry/task_metrics.ts | 51 +
.../lib/telemetry/task_metrics.types.ts | 25 +
.../lib/telemetry/tasks/configuration.test.ts | 8 +-
.../lib/telemetry/tasks/configuration.ts | 89 +-
.../telemetry/tasks/detection_rule.test.ts | 8 +-
.../lib/telemetry/tasks/detection_rule.ts | 53 +-
.../lib/telemetry/tasks/diagnostic.test.ts | 11 +-
.../server/lib/telemetry/tasks/diagnostic.ts | 38 +-
.../lib/telemetry/tasks/endpoint.test.ts | 13 +-
.../server/lib/telemetry/tasks/endpoint.ts | 58 +-
.../server/lib/telemetry/tasks/filterlists.ts | 47 +-
.../tasks/prebuilt_rule_alerts.test.ts | 11 +-
.../telemetry/tasks/prebuilt_rule_alerts.ts | 33 +-
.../telemetry/tasks/security_lists.test.ts | 8 +-
.../lib/telemetry/tasks/security_lists.ts | 37 +-
.../lib/telemetry/tasks/timelines.test.ts | 10 +-
.../server/lib/telemetry/tasks/timelines.ts | 33 +-
.../tasks/timelines_diagnostic.test.ts | 10 +-
.../telemetry/tasks/timelines_diagnostic.ts | 37 +-
.../server/lib/telemetry/types.ts | 44 +-
.../security_solution/server/plugin.ts | 22 +-
.../plugins/security_solution/tsconfig.json | 1 +
.../task_based/all_types.ts | 8 +-
.../task_based/detection_rules.ts | 22 +-
56 files changed, 4130 insertions(+), 320 deletions(-)
create mode 100644 x-pack/plugins/security_solution/server/integration_tests/__mocks__/endpoint-alert.json
create mode 100644 x-pack/plugins/security_solution/server/integration_tests/__mocks__/rule.json
create mode 100644 x-pack/plugins/security_solution/server/integration_tests/lib/helpers.ts
create mode 100644 x-pack/plugins/security_solution/server/integration_tests/lib/telemetry_helpers.ts
create mode 100644 x-pack/plugins/security_solution/server/integration_tests/telemetry.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/async_sender.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/async_sender.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/async_sender.types.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/preview_task_metrics.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/sender_helpers.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.types.ts
diff --git a/x-pack/plugins/security_solution/server/integration_tests/__mocks__/endpoint-alert.json b/x-pack/plugins/security_solution/server/integration_tests/__mocks__/endpoint-alert.json
new file mode 100644
index 0000000000000..e5ae0ceec9785
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/integration_tests/__mocks__/endpoint-alert.json
@@ -0,0 +1,196 @@
+{
+ "@timestamp": "2024-01-13T01:49:46.2633272Z",
+ "Endpoint": {
+ "policy": {
+ "applied": {
+ "artifacts": {
+ "global": {
+ "identifiers": [
+ {
+ "name": "diagnostic-configuration-v1",
+ "sha256": "9a0c8808ce6d9b8043cc3033ec55de4fee958dc871680789f699c16e3079cc05"
+ },
+ {
+ "name": "diagnostic-endpointpe-v4-blocklist",
+ "sha256": "9b98d31453367a4a4af2f32763ef9d08faadd0520a4c34104ebd89060bcbc87c"
+ }
+ ],
+ "version": "staging.NOT_FOUND-1705109685512"
+ },
+ "user": {
+ "identifiers": [
+ {
+ "name": "endpoint-eventfilterlist-windows-v1",
+ "sha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658"
+ },
+ {
+ "name": "endpoint-exceptionlist-windows-v1",
+ "sha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658"
+ }
+ ],
+ "version": "1.0.0"
+ }
+ }
+ }
+ }
+ },
+ "agent": {
+ "build": {
+ "original": "version: 7.17.16, compiled: Mon Nov 13 15:00:00 2023, branch: HEAD, commit: db581d419ce896071e0fcfbec5ae0666297d37dc"
+ },
+ "id": "69bb7a83-c735-4c27-9646-01f697aa3f68",
+ "type": "endpoint",
+ "version": "7.17.16"
+ },
+ "cluster_name": "639afbf700044279b8221d6a5f935f33",
+ "cluster_uuid": "F6paC1IFSXCcT3N9MiWttw",
+ "data_stream": {
+ "dataset": "endpoint.alerts",
+ "namespace": "default",
+ "type": "logs"
+ },
+ "ecs": {
+ "version": "1.11.0"
+ },
+ "elastic": {
+ "agent": {
+ "id": "69bb7a83-c735-4c27-9646-01f697aa3f68"
+ }
+ },
+ "event": {
+ "action": "creation",
+ "agent_id_status": "verified",
+ "category": ["malware", "intrusion_detection", "file"],
+ "code": "malicious_file",
+ "created": "2024-01-13T01:49:46.2633272Z",
+ "dataset": "endpoint.alerts",
+ "id": "NO5fsT3K5DRjdEjO++++++zo",
+ "ingested": "2024-01-13T01:50:17Z",
+ "kind": "alert",
+ "module": "endpoint",
+ "outcome": "success",
+ "risk_score": 99,
+ "sequence": 2382,
+ "severity": 99,
+ "type": ["info", "creation", "allowed"]
+ },
+ "file": {
+ "Ext": {
+ "code_signature": [
+ {
+ "exists": false
+ }
+ ],
+ "malware_classification": {
+ "identifier": "endpointpe-v4-model",
+ "score": 0.9998635053634644,
+ "threshold": 0.58,
+ "version": "4.0.38000"
+ }
+ },
+ "accessed": "2024-01-13T01:49:46.2633272Z",
+ "created": "2024-01-13T01:49:46.2633272Z",
+ "directory": "C:\\Windows\\TEMP\\d9a4d415-0a1f-451d-a878-c0753eb653dd",
+ "extension": "exe",
+ "hash": {
+ "md5": "f410cff9e5d18862b08a448c278817f0",
+ "sha1": "c4c355c5a23454418009401e3a16d8625f081e23",
+ "sha256": "5a79448a3c3062794851a986f55d118a9a71ca057bfcb0fda1997142125736c2"
+ },
+ "mtime": "2024-01-13T01:49:46.2633272Z",
+ "name": "sample.exe",
+ "path": "C:\\Windows\\TEMP\\d9a4d415-0a1f-451d-a878-c0753eb653dd\\sample.exe",
+ "size": 288256
+ },
+ "host": {
+ "os": {
+ "Ext": {
+ "variant": "Windows Server 2019 Datacenter"
+ },
+ "family": "windows",
+ "full": "Windows Server 2019 Datacenter 1809 (10.0.17763.3406)",
+ "kernel": "1809 (10.0.17763.3406)",
+ "name": "Windows",
+ "platform": "windows",
+ "type": "windows",
+ "version": "1809 (10.0.17763.3406)"
+ }
+ },
+ "license": {
+ "issued_to": "639afbf700044279b8221d6a5f935f33",
+ "issuer": "elasticsearch",
+ "status": "active",
+ "type": "trial",
+ "uid": "b7f9aac3-751e-4cbb-84a8-1b853c53d197"
+ },
+ "process": {
+ "Ext": {
+ "architecture": "x86_64",
+ "code_signature": [
+ {
+ "exists": true,
+ "status": "trusted",
+ "subject_name": "Microsoft Windows",
+ "trusted": true
+ }
+ ],
+ "token": {
+ "integrity_level_name": "system"
+ }
+ },
+ "args": [
+ "powershell.exe",
+ "-NoProfile",
+ "-NoLogo",
+ "-ExecutionPolicy",
+ "Unrestricted",
+ "-File",
+ "C:\\Windows\\TEMP\\metadata-scripts2073839487\\windows-startup-script-ps1.ps1"
+ ],
+ "command_line": "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Unrestricted -File C:\\Windows\\TEMP\\metadata-scripts2073839487\\windows-startup-script-ps1.ps1",
+ "entity_id": "NjliYjdhODMtYzczNS00YzI3LTk2NDYtMDFmNjk3YWEzZjY4LTQwODgtMTMzNDk1ODQwNTUuNDIzODIxNDAw",
+ "executable": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+ "hash": {
+ "md5": "7353f60b1739074eb17c5f4dddefe239",
+ "sha1": "6cbce4a295c163791b60fc23d285e6d84f28ee4c",
+ "sha256": "de96a6e69944335375dc1ac238336066889d9ffc7d73628ef4fe1b1b160ab32c"
+ },
+ "name": "powershell.exe",
+ "parent": {
+ "Ext": {
+ "architecture": "x86_64",
+ "code_signature": [
+ {
+ "exists": true,
+ "status": "trusted",
+ "subject_name": "Google LLC",
+ "trusted": true
+ }
+ ]
+ },
+ "args": [
+ "C:\\Program Files\\Google\\Compute Engine\\metadata_scripts\\GCEMetadataScripts.exe",
+ "startup"
+ ],
+ "command_line": "\"C:\\Program Files\\Google\\Compute Engine\\metadata_scripts\\GCEMetadataScripts.exe\" \"startup\"",
+ "entity_id": "NjliYjdhODMtYzczNS00YzI3LTk2NDYtMDFmNjk3YWEzZjY4LTM4OTItMTMzNDk1ODQwNTQuOTkwOTU1NjAw",
+ "executable": "C:\\Program Files\\Google\\Compute Engine\\metadata_scripts\\GCEMetadataScripts.exe",
+ "hash": {
+ "md5": "98aea13d8067b8f89ee0dce69a154966",
+ "sha1": "88ceb122a9a4b33674ecdba95784e82ea5be08f6",
+ "sha256": "590870dc691dd708c875bfc5385a9482fa6ecce0b34d607fe23a71c55b2693ee"
+ },
+ "name": "GCEMetadataScripts.exe",
+ "pid": 3892,
+ "uptime": 132
+ },
+ "pe": {
+ "original_file_name": "PowerShell.EXE"
+ },
+ "pid": 4088,
+ "uptime": 131
+ },
+ "rule": {
+ "ruleset": "production"
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/integration_tests/__mocks__/rule.json b/x-pack/plugins/security_solution/server/integration_tests/__mocks__/rule.json
new file mode 100644
index 0000000000000..2b33be991177b
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/integration_tests/__mocks__/rule.json
@@ -0,0 +1,127 @@
+{
+ "alert": {
+ "name": "Azure Automation Runbook Created or Modified",
+ "tags": ["Elastic", "Cloud", "Azure", "Continuous Monitoring", "SecOps", "Configuration Audit"],
+ "alertTypeId": "siem.queryRule",
+ "consumer": "siem",
+ "params": {
+ "author": ["Elastic"],
+ "description": "test",
+ "ruleId": "id-test",
+ "falsePositives": [],
+ "from": "now-25m",
+ "immutable": true,
+ "license": "Elastic License v2",
+ "outputIndex": ".siem-signals-default",
+ "maxSignals": 100,
+ "relatedIntegrations": [
+ {
+ "integration": "activitylogs",
+ "package": "azure",
+ "version": "^1.0.0"
+ }
+ ],
+ "requiredFields": [
+ {
+ "ecs": false,
+ "name": "azure.activitylogs.operation_name",
+ "type": "keyword"
+ },
+ {
+ "ecs": true,
+ "name": "event.dataset",
+ "type": "keyword"
+ },
+ {
+ "ecs": true,
+ "name": "event.outcome",
+ "type": "keyword"
+ }
+ ],
+ "riskScore": 21,
+ "riskScoreMapping": [],
+ "setup": "The Azure Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule.",
+ "severity": "low",
+ "severityMapping": [],
+ "threat": [],
+ "timestampOverride": "event.ingested",
+ "to": "now",
+ "references": [
+ "https://powerzure.readthedocs.io/en/latest/Functions/operational.html#create-backdoor",
+ "https://github.com/hausec/PowerZure",
+ "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a",
+ "https://azure.microsoft.com/en-in/blog/azure-automation-runbook-management/"
+ ],
+ "note": "",
+ "version": 101,
+ "exceptionsList": [
+ {
+ "type": "detection",
+ "id": "123456",
+ "list_id": "endpoint_trusted_apps",
+ "namespace_type": "single"
+ }
+ ],
+ "type": "query",
+ "language": "kuery",
+ "index": ["filebeat-*", "logs-azure*"],
+ "query": ""
+ },
+ "schedule": {
+ "interval": "5m"
+ },
+ "enabled": false,
+ "actions": [],
+ "throttle": null,
+ "notifyWhen": "onActiveAlert",
+ "apiKeyOwner": null,
+ "apiKey": null,
+ "createdBy": "a@b.co",
+ "updatedBy": "a@b.co",
+ "createdAt": "2021-11-25T15:44:44.682Z",
+ "updatedAt": "2023-01-04T14:20:54.727Z",
+ "muteAll": false,
+ "mutedInstanceIds": [],
+ "executionStatus": {
+ "status": "pending",
+ "lastExecutionDate": "2021-11-25T15:44:44.682Z",
+ "error": null
+ },
+ "meta": {
+ "versionApiKeyLastmodified": "8.5.0"
+ },
+ "legacyId": "123456",
+ "mapped_params": {
+ "risk_score": 21,
+ "severity": "20-low"
+ },
+ "snoozeSchedule": [],
+ "monitoring": {
+ "run": {
+ "history": [],
+ "calculated_metrics": {
+ "success_ratio": 0
+ },
+ "last_run": {
+ "timestamp": "2021-11-25T15:44:44.682Z",
+ "metrics": {
+ "total_search_duration_ms": null,
+ "total_indexing_duration_ms": null,
+ "total_alerts_detected": null,
+ "total_alerts_created": null,
+ "gap_duration_s": null,
+ "duration": 2212
+ }
+ }
+ }
+ },
+ "revision": 101
+ },
+ "type": "alert",
+ "references": [],
+ "managed": false,
+ "namespaces": ["default"],
+ "coreMigrationVersion": "8.8.0",
+ "typeMigrationVersion": "8.8.0",
+ "updated_at": "2023-01-04T14:20:54.727Z"
+}
diff --git a/x-pack/plugins/security_solution/server/integration_tests/lib/helpers.ts b/x-pack/plugins/security_solution/server/integration_tests/lib/helpers.ts
new file mode 100644
index 0000000000000..20d98303a62a7
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/integration_tests/lib/helpers.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import Fs from 'fs';
+import Util from 'util';
+import deepmerge from 'deepmerge';
+import { createTestServers, createRootWithCorePlugins } from '@kbn/core-test-helpers-kbn-server';
+const asyncUnlink = Util.promisify(Fs.unlink);
+
+/**
+ * Eventually runs a callback until it succeeds or times out.
+ * Inspired in https://kotest.io/docs/assertions/eventually.html
+ *
+ * @param cb The callback to run/retry
+ * @param duration The maximum duration to run the callback, default 10000 millisecs
+ * @param interval The interval between each run, default 100 millisecs
+ */
+export async function eventually(
+ cb: () => Promise,
+ duration: number = 10000,
+ interval: number = 200
+) {
+ let elapsed = 0;
+
+ while (true) {
+ const startedAt: number = performance.now();
+ try {
+ return await cb();
+ } catch (e) {
+ if (elapsed >= duration) {
+ throw e;
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ elapsed += performance.now() - startedAt;
+ }
+}
+
+export async function setupTestServers(logFilePath: string, settings = {}) {
+ const { startES } = createTestServers({
+ adjustTimeout: (t) => jest.setTimeout(t),
+ settings: {
+ es: {
+ license: 'trial',
+ },
+ },
+ });
+
+ const esServer = await startES();
+
+ const root = createRootWithCorePlugins(
+ deepmerge(
+ {
+ server: {
+ port: 9991,
+ },
+ logging: {
+ appenders: {
+ file: {
+ type: 'file',
+ fileName: logFilePath,
+ layout: {
+ type: 'json',
+ },
+ },
+ },
+ root: {
+ level: 'warn',
+ },
+ loggers: [
+ {
+ name: 'plugins.taskManager',
+ level: 'warn',
+ appenders: ['file'],
+ },
+ {
+ name: 'plugins.securitySolution.telemetry_events',
+ level: 'all',
+ appenders: ['file'],
+ },
+ ],
+ },
+ },
+ settings
+ ),
+ { oss: false }
+ );
+
+ await root.preboot();
+ const coreSetup = await root.setup();
+ const coreStart = await root.start();
+
+ return {
+ esServer,
+ kibanaServer: {
+ root,
+ coreSetup,
+ coreStart,
+ stop: async () => root.shutdown(),
+ },
+ };
+}
+
+export async function removeFile(path: string) {
+ await asyncUnlink(path).catch(() => void 0);
+}
diff --git a/x-pack/plugins/security_solution/server/integration_tests/lib/telemetry_helpers.ts b/x-pack/plugins/security_solution/server/integration_tests/lib/telemetry_helpers.ts
new file mode 100644
index 0000000000000..3eb8d7aca8883
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/integration_tests/lib/telemetry_helpers.ts
@@ -0,0 +1,269 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type {
+ CoreStart,
+ ElasticsearchClient,
+ KibanaRequest,
+ SavedObjectsServiceStart,
+} from '@kbn/core/server';
+import type {
+ ExceptionListItemSchema,
+ ExceptionListSchema,
+} from '@kbn/securitysolution-io-ts-list-types';
+import { asyncForEach } from '@kbn/std';
+
+import {
+ createExceptionList,
+ createExceptionListItem,
+ deleteExceptionList,
+ deleteExceptionListItem,
+} from '@kbn/lists-plugin/server/services/exception_lists';
+import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
+import { DETECTION_TYPE, NAMESPACE_TYPE } from '@kbn/lists-plugin/common/constants.mock';
+import { TelemetryEventsSender } from '../../lib/telemetry/sender';
+import type {
+ SecuritySolutionPluginStart,
+ SecuritySolutionPluginStartDependencies,
+} from '../../plugin_contract';
+import type { SecurityTelemetryTask } from '../../lib/telemetry/task';
+import { Plugin as SecuritySolutionPlugin } from '../../plugin';
+import { AsyncTelemetryEventsSender } from '../../lib/telemetry/async_sender';
+import { DEFAULT_DIAGNOSTIC_INDEX } from '../../lib/telemetry/constants';
+import mockEndpointAlert from '../__mocks__/endpoint-alert.json';
+import mockedRule from '../__mocks__/rule.json';
+
+export function getTelemetryTasks(
+ spy: jest.SpyInstance<
+ SecuritySolutionPluginStart,
+ [core: CoreStart, plugins: SecuritySolutionPluginStartDependencies]
+ >
+): SecurityTelemetryTask[] {
+ const pluginInstances = spy.mock.instances;
+ if (pluginInstances.length === 0) {
+ throw new Error('Telemetry sender not started');
+ }
+ const plugin = pluginInstances[0];
+ if (plugin instanceof SecuritySolutionPlugin) {
+ /* eslint dot-notation: "off" */
+ const sender = plugin['telemetryEventsSender'];
+ if (sender instanceof TelemetryEventsSender) {
+ /* eslint dot-notation: "off" */
+ return sender['telemetryTasks'] ?? [];
+ } else {
+ throw new Error('Telemetry sender not started');
+ }
+ } else {
+ throw new Error('Telemetry sender not started');
+ }
+}
+
+export function getAsyncTelemetryEventSender(
+ spy: jest.SpyInstance<
+ SecuritySolutionPluginStart,
+ [core: CoreStart, plugins: SecuritySolutionPluginStartDependencies]
+ >
+): AsyncTelemetryEventsSender {
+ const pluginInstances = spy.mock.instances;
+ if (pluginInstances.length === 0) {
+ throw new Error('Telemetry sender not started');
+ }
+ const plugin = pluginInstances[0];
+ if (plugin instanceof SecuritySolutionPlugin) {
+ /* eslint dot-notation: "off" */
+ const sender = plugin['asyncTelemetryEventsSender'];
+ if (sender instanceof AsyncTelemetryEventsSender) {
+ return sender;
+ } else {
+ throw new Error('Telemetry sender not started');
+ }
+ } else {
+ throw new Error('Telemetry sender not started');
+ }
+}
+
+export function getTelemetryTask(
+ tasks: SecurityTelemetryTask[],
+ id: string
+): SecurityTelemetryTask {
+ const task = tasks.find((t) => t['config'].type === id);
+
+ expect(task).toBeDefined();
+ if (task === undefined) {
+ throw new Error(`Task ${id} not found`);
+ }
+ return task;
+}
+
+export async function createMockedEndpointAlert(esClient: ElasticsearchClient) {
+ const index = `${DEFAULT_DIAGNOSTIC_INDEX.replace('-*', '')}-001`;
+
+ await esClient.indices.create({ index, body: { settings: { hidden: true } } });
+
+ if (mockEndpointAlert['event']) {
+ mockEndpointAlert['event']['ingested'] = new Date().toISOString();
+ }
+
+ await esClient.create({
+ index,
+ id: 'diagnostic-test-id',
+ body: mockEndpointAlert,
+ refresh: 'wait_for',
+ });
+}
+
+export async function createMockedAlert(
+ esClient: ElasticsearchClient,
+ so: SavedObjectsServiceStart
+) {
+ const alertIndex = so.getIndexForType('alert');
+ const aliasInfo = await esClient.indices.getAlias({ index: alertIndex });
+ const alias = Object.keys(aliasInfo);
+
+ await esClient.create({
+ index: alias[0],
+ id: 'test-id',
+ body: mockedRule,
+ refresh: 'wait_for',
+ });
+}
+
+export async function cleanupMockedEndpointAlerts(esClient: ElasticsearchClient) {
+ const index = `${DEFAULT_DIAGNOSTIC_INDEX.replace('-*', '')}-001`;
+
+ await esClient.indices.delete({ index }).catch(() => {
+ // ignore errors
+ });
+}
+
+export async function cleanupMockedAlerts(
+ esClient: ElasticsearchClient,
+ so: SavedObjectsServiceStart
+) {
+ const alertIndex = so.getIndexForType('alert');
+ const aliasInfo = await esClient.indices.getAlias({ index: alertIndex });
+ const alias = Object.keys(aliasInfo);
+
+ await esClient
+ .deleteByQuery({
+ index: alias[0],
+ body: {
+ query: {
+ match_all: {},
+ },
+ },
+ })
+ .catch(() => {
+ // ignore errors
+ });
+}
+
+export async function createMockedExceptionList(so: SavedObjectsServiceStart) {
+ const type = DETECTION_TYPE;
+ const listId = ENDPOINT_ARTIFACT_LISTS.trustedApps.id;
+
+ const immutable = true;
+ const savedObjectsClient = so.getScopedClient(fakeKibanaRequest);
+
+ const namespaceType = NAMESPACE_TYPE;
+ const name = 'test';
+ const description = 'test';
+ const meta = undefined;
+ const user = '';
+ const version = 1;
+ const tags: string[] = [];
+ const tieBreaker = '';
+
+ const exceptionList = await createExceptionList({
+ listId,
+ immutable,
+ savedObjectsClient,
+ namespaceType,
+ name,
+ description,
+ meta,
+ user,
+ tags,
+ tieBreaker,
+ type,
+ version,
+ });
+
+ const exceptionListItem = await createExceptionListItem({
+ comments: [],
+ entries: [
+ {
+ field: 'process.hash.md5',
+ operator: 'included',
+ type: 'match',
+ value: 'ae27a4b4821b13cad2a17a75d219853e',
+ },
+ ],
+ expireTime: undefined,
+ itemId: '1',
+ listId,
+ savedObjectsClient,
+ namespaceType,
+ name: 'item1',
+ osTypes: ['linux'],
+ description,
+ meta,
+ user,
+ tags,
+ tieBreaker,
+ type: 'simple',
+ });
+
+ return { exceptionList, exceptionListItem };
+}
+
+export async function cleanupMockedExceptionLists(
+ exceptionsList: ExceptionListSchema[],
+ exceptionsListItem: ExceptionListItemSchema[],
+ so: SavedObjectsServiceStart
+) {
+ const savedObjectsClient = so.getScopedClient(fakeKibanaRequest);
+ await asyncForEach(exceptionsListItem, async (exceptionListItem) => {
+ await deleteExceptionListItem({
+ itemId: exceptionListItem.item_id,
+ id: exceptionListItem.id,
+ namespaceType: NAMESPACE_TYPE,
+ savedObjectsClient,
+ });
+ });
+ await asyncForEach(exceptionsList, async (exceptionList) => {
+ await deleteExceptionList({
+ listId: exceptionList.list_id,
+ id: exceptionList.id,
+ namespaceType: NAMESPACE_TYPE,
+ savedObjectsClient,
+ });
+ });
+}
+
+const fakeKibanaRequest = {
+ headers: {},
+ getBasePath: () => '',
+ path: '/',
+ route: { settings: {} },
+ url: {
+ href: '/',
+ },
+ raw: {
+ req: {
+ url: '/',
+ },
+ },
+} as unknown as KibanaRequest;
+
+export function getTelemetryTaskType(task: SecurityTelemetryTask): string {
+ if (task !== null && typeof task === 'object') {
+ return task['config']['type'];
+ } else {
+ return '';
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/integration_tests/telemetry.test.ts b/x-pack/plugins/security_solution/server/integration_tests/telemetry.test.ts
new file mode 100644
index 0000000000000..e2357d6d614ed
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/integration_tests/telemetry.test.ts
@@ -0,0 +1,351 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import Path from 'path';
+import axios, { type AxiosRequestConfig } from 'axios';
+
+import type {
+ ExceptionListItemSchema,
+ ExceptionListSchema,
+} from '@kbn/securitysolution-io-ts-list-types';
+
+import { ENDPOINT_STAGING } from '@kbn/telemetry-plugin/common/constants';
+
+import { eventually, setupTestServers, removeFile } from './lib/helpers';
+import {
+ cleanupMockedAlerts,
+ cleanupMockedExceptionLists,
+ cleanupMockedEndpointAlerts,
+ createMockedAlert,
+ createMockedEndpointAlert,
+ createMockedExceptionList,
+ getAsyncTelemetryEventSender,
+ getTelemetryTask,
+ getTelemetryTaskType,
+ getTelemetryTasks,
+} from './lib/telemetry_helpers';
+
+import {
+ type TestElasticsearchUtils,
+ type TestKibanaUtils,
+} from '@kbn/core-test-helpers-kbn-server';
+import { Plugin as SecuritySolutionPlugin } from '../plugin';
+import {
+ TaskManagerPlugin,
+ type TaskManagerStartContract,
+} from '@kbn/task-manager-plugin/server/plugin';
+import type { SecurityTelemetryTask } from '../lib/telemetry/task';
+import { TelemetryChannel } from '../lib/telemetry/types';
+import type { AsyncTelemetryEventsSender } from '../lib/telemetry/async_sender';
+
+jest.mock('axios');
+
+const logFilePath = Path.join(__dirname, 'logs.log');
+
+const taskManagerStartSpy = jest.spyOn(TaskManagerPlugin.prototype, 'start');
+const telemetrySenderStartSpy = jest.spyOn(SecuritySolutionPlugin.prototype, 'start');
+const mockedAxiosGet = jest.spyOn(axios, 'get');
+const mockedAxiosPost = jest.spyOn(axios, 'post');
+
+describe('telemetry tasks', () => {
+ let esServer: TestElasticsearchUtils;
+ let kibanaServer: TestKibanaUtils;
+ let taskManagerPlugin: TaskManagerStartContract;
+ let tasks: SecurityTelemetryTask[];
+ let asyncTelemetryEventSender: AsyncTelemetryEventsSender;
+ let exceptionsList: ExceptionListSchema[] = [];
+ let exceptionsListItem: ExceptionListItemSchema[] = [];
+
+ beforeAll(async () => {
+ await removeFile(logFilePath);
+
+ const servers = await setupTestServers(logFilePath);
+ esServer = servers.esServer;
+ kibanaServer = servers.kibanaServer;
+
+ expect(taskManagerStartSpy).toHaveBeenCalledTimes(1);
+ taskManagerPlugin = taskManagerStartSpy.mock.results[0].value;
+
+ expect(telemetrySenderStartSpy).toHaveBeenCalledTimes(1);
+
+ tasks = getTelemetryTasks(telemetrySenderStartSpy);
+ asyncTelemetryEventSender = getAsyncTelemetryEventSender(telemetrySenderStartSpy);
+
+ // update queue config to not wait for a long bufferTimeSpanMillis
+ asyncTelemetryEventSender.updateQueueConfig(TelemetryChannel.TASK_METRICS, {
+ bufferTimeSpanMillis: 100,
+ inflightEventsThreshold: 1_000,
+ maxPayloadSizeBytes: 1024 * 1024,
+ });
+ });
+
+ afterAll(async () => {
+ if (kibanaServer) {
+ await kibanaServer.stop();
+ }
+ if (esServer) {
+ await esServer.stop();
+ }
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ mockAxiosGet();
+ });
+
+ afterEach(async () => {
+ await cleanupMockedExceptionLists(
+ exceptionsList,
+ exceptionsListItem,
+ kibanaServer.coreStart.savedObjects
+ );
+ await cleanupMockedAlerts(
+ kibanaServer.coreStart.elasticsearch.client.asInternalUser,
+ kibanaServer.coreStart.savedObjects
+ ).then(() => {
+ exceptionsList = [];
+ exceptionsListItem = [];
+ });
+ await cleanupMockedEndpointAlerts(kibanaServer.coreStart.elasticsearch.client.asInternalUser);
+ });
+
+ describe('detection-rules', () => {
+ it('should execute when scheduled', async () => {
+ await mockAndScheduleDetectionRulesTask();
+
+ // wait until the events are sent to the telemetry server
+ const body = await eventually(async () => {
+ const found = mockedAxiosPost.mock.calls.find(([url]) => {
+ return url.startsWith(ENDPOINT_STAGING) && url.endsWith('security-lists-v2');
+ });
+
+ expect(found).not.toBeFalsy();
+
+ return JSON.parse((found ? found[1] : '{}') as string);
+ });
+
+ expect(body).not.toBeFalsy();
+ expect(body.detection_rule).not.toBeFalsy();
+ });
+
+ it('should send task metrics', async () => {
+ const task = await mockAndScheduleDetectionRulesTask();
+
+ const requests = await getTaskMetricsRequests(task);
+
+ expect(requests.length).toBeGreaterThan(0);
+ requests.forEach(({ body }) => {
+ const asJson = JSON.parse(body);
+ expect(asJson).not.toBeFalsy();
+ expect(asJson.passed).toEqual(true);
+ });
+ });
+ });
+
+ describe('sender configuration', () => {
+ it('should use legacy sender by default', async () => {
+ // launch a random task and verify it uses the new configuration
+ const task = await mockAndScheduleDetectionRulesTask();
+
+ const requests = await getTaskMetricsRequests(task);
+ expect(requests.length).toBeGreaterThan(0);
+ requests.forEach(({ config }) => {
+ expect(config).not.toBeFalsy();
+ if (config && config.headers) {
+ expect(config.headers['X-Telemetry-Sender']).not.toEqual('async');
+ }
+ });
+ });
+
+ it('should use new sender when configured', async () => {
+ const configTaskType = 'security:telemetry-configuration';
+ const configTask = getTelemetryTask(tasks, configTaskType);
+
+ mockAxiosGet(fakeBufferAndSizesConfigAsyncEnabled);
+ await eventually(async () => {
+ await taskManagerPlugin.runSoon(configTask.getTaskId());
+ });
+
+ // wait until the task finishes
+ await eventually(async () => {
+ const found = (await taskManagerPlugin.fetch()).docs.find(
+ (t) => t.taskType === configTaskType
+ );
+ expect(found).toBeFalsy();
+ });
+
+ const task = await mockAndScheduleDetectionRulesTask();
+
+ const requests = await getTaskMetricsRequests(task);
+ expect(requests.length).toBeGreaterThan(0);
+ requests.forEach(({ config }) => {
+ expect(config).not.toBeFalsy();
+ if (config && config.headers) {
+ expect(config.headers['X-Telemetry-Sender']).toEqual('async');
+ }
+ });
+ });
+
+ it('should update sender queue config', async () => {
+ const expectedConfig = fakeBufferAndSizesConfigWithQueues.sender_channels['task-metrics'];
+ const configTaskType = 'security:telemetry-configuration';
+ const configTask = getTelemetryTask(tasks, configTaskType);
+
+ mockAxiosGet(fakeBufferAndSizesConfigWithQueues);
+ await eventually(async () => {
+ await taskManagerPlugin.runSoon(configTask.getTaskId());
+ });
+
+ await eventually(async () => {
+ /* eslint-disable dot-notation */
+ const taskMetricsConfigAfter = asyncTelemetryEventSender['queues']?.get(
+ TelemetryChannel.TASK_METRICS
+ );
+
+ expect(taskMetricsConfigAfter?.bufferTimeSpanMillis).toEqual(
+ expectedConfig.buffer_time_span_millis
+ );
+ expect(taskMetricsConfigAfter?.inflightEventsThreshold).toEqual(
+ expectedConfig.inflight_events_threshold
+ );
+ expect(taskMetricsConfigAfter?.maxPayloadSizeBytes).toEqual(
+ expectedConfig.max_payload_size_bytes
+ );
+ });
+ });
+ });
+
+ describe('endpoint-diagnostics', () => {
+ it('should execute when scheduled', async () => {
+ await mockAndScheduleEndpointDiagnosticsTask();
+
+ // wait until the events are sent to the telemetry server
+ const body = await eventually(async () => {
+ const found = mockedAxiosPost.mock.calls.find(([url]) => {
+ return url.startsWith(ENDPOINT_STAGING) && url.endsWith('alerts-endpoint');
+ });
+
+ expect(found).not.toBeFalsy();
+
+ return JSON.parse((found ? found[1] : '{}') as string);
+ });
+
+ expect(body).not.toBeFalsy();
+ expect(body.Endpoint).not.toBeFalsy();
+ });
+ });
+
+ async function mockAndScheduleDetectionRulesTask(): Promise {
+ const task = getTelemetryTask(tasks, 'security:telemetry-detection-rules');
+
+ // create some data
+ await createMockedAlert(
+ kibanaServer.coreStart.elasticsearch.client.asInternalUser,
+ kibanaServer.coreStart.savedObjects
+ );
+ const { exceptionList, exceptionListItem } = await createMockedExceptionList(
+ kibanaServer.coreStart.savedObjects
+ );
+
+ exceptionsList.push(exceptionList);
+ exceptionsListItem.push(exceptionListItem);
+
+ // schedule task to run ASAP
+ await eventually(async () => {
+ await taskManagerPlugin.runSoon(task.getTaskId());
+ });
+
+ return task;
+ }
+
+ async function mockAndScheduleEndpointDiagnosticsTask(): Promise {
+ const task = getTelemetryTask(tasks, 'security:endpoint-diagnostics');
+
+ await createMockedEndpointAlert(kibanaServer.coreStart.elasticsearch.client.asInternalUser);
+
+ // schedule task to run ASAP
+ await eventually(async () => {
+ await taskManagerPlugin.runSoon(task.getTaskId());
+ });
+
+ return task;
+ }
+
+ function mockAxiosGet(bufferConfig: unknown = fakeBufferAndSizesConfigAsyncDisabled) {
+ mockedAxiosGet.mockImplementation(async (url: string) => {
+ if (url.startsWith(ENDPOINT_STAGING) && url.endsWith('ping')) {
+ return { status: 200 };
+ } else if (url.indexOf('kibana/manifest/artifacts') !== -1) {
+ return {
+ status: 200,
+ data: 'x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip',
+ };
+ } else if (url.indexOf('telemetry-buffer-and-batch-sizes-v1') !== -1) {
+ return {
+ status: 200,
+ data: bufferConfig,
+ };
+ }
+ return { status: 404 };
+ });
+ }
+
+ async function getTaskMetricsRequests(task: SecurityTelemetryTask): Promise<
+ Array<{
+ url: string;
+ body: string;
+ config: AxiosRequestConfig | undefined;
+ }>
+ > {
+ return eventually(async () => {
+ const calls = mockedAxiosPost.mock.calls.flatMap(([url, data, config]) => {
+ return (data as string).split('\n').map((body) => {
+ return { url, body, config };
+ });
+ });
+
+ const requests = calls.filter(({ url, body }) => {
+ return (
+ body.indexOf(getTelemetryTaskType(task)) !== -1 &&
+ url.startsWith(ENDPOINT_STAGING) &&
+ url.endsWith('task-metrics')
+ );
+ });
+ expect(requests.length).toBeGreaterThan(0);
+ return requests;
+ });
+ }
+});
+
+const fakeBufferAndSizesConfigAsyncDisabled = {
+ telemetry_max_buffer_size: 100,
+ max_security_list_telemetry_batch: 100,
+ max_endpoint_telemetry_batch: 300,
+ max_detection_rule_telemetry_batch: 1000,
+ max_detection_alerts_batch: 50,
+};
+
+const fakeBufferAndSizesConfigAsyncEnabled = {
+ ...fakeBufferAndSizesConfigAsyncDisabled,
+ use_async_sender: true,
+};
+
+const fakeBufferAndSizesConfigWithQueues = {
+ ...fakeBufferAndSizesConfigAsyncDisabled,
+ sender_channels: {
+ // should be ignored
+ 'invalid-channel': {
+ buffer_time_span_millis: 500,
+ inflight_events_threshold: 10,
+ max_payload_size_bytes: 20,
+ },
+ 'task-metrics': {
+ buffer_time_span_millis: 500,
+ inflight_events_threshold: 10,
+ max_payload_size_bytes: 20,
+ },
+ },
+};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_detecton_rules_preview.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_detecton_rules_preview.ts
index 5797164e8b2ae..30c9211ef4a95 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_detecton_rules_preview.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_detecton_rules_preview.ts
@@ -8,6 +8,7 @@
import type { Logger } from '@kbn/core/server';
import { PreviewTelemetryEventsSender } from '../../../../telemetry/preview_sender';
+import { PreviewTaskMetricsService } from '../../../../telemetry/preview_task_metrics';
import type { ITelemetryReceiver } from '../../../../telemetry/receiver';
import type { ITelemetryEventsSender } from '../../../../telemetry/sender';
import { createTelemetryDetectionRuleListsTaskConfig } from '../../../../telemetry/tasks/detection_rule';
@@ -28,14 +29,17 @@ export const getDetectionRulesPreview = async ({
};
const taskSender = new PreviewTelemetryEventsSender(logger, telemetrySender);
+ const taskMetricsService = new PreviewTaskMetricsService(logger, taskSender);
const task = createTelemetryDetectionRuleListsTaskConfig(1000);
await task.runTask(
'detection-rules-preview',
logger,
telemetryReceiver,
taskSender,
+ taskMetricsService,
taskExecutionPeriod
);
const messages = taskSender.getSentMessages();
- return parseNdjson(messages);
+ const taskMetrics = taskMetricsService.getSentMessages();
+ return parseNdjson([...messages, ...taskMetrics]);
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_diagnostics_preview.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_diagnostics_preview.ts
index f065d140d8556..ffa035e2571ea 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_diagnostics_preview.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_diagnostics_preview.ts
@@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server';
import { PreviewTelemetryEventsSender } from '../../../../telemetry/preview_sender';
import type { ITelemetryReceiver } from '../../../../telemetry/receiver';
+import { PreviewTaskMetricsService } from '../../../../telemetry/preview_task_metrics';
import type { ITelemetryEventsSender } from '../../../../telemetry/sender';
import { createTelemetryDiagnosticsTaskConfig } from '../../../../telemetry/tasks/diagnostic';
import { parseNdjson } from './parse_ndjson';
@@ -28,14 +29,17 @@ export const getDiagnosticsPreview = async ({
};
const taskSender = new PreviewTelemetryEventsSender(logger, telemetrySender);
+ const taskMetricsService = new PreviewTaskMetricsService(logger, taskSender);
const task = createTelemetryDiagnosticsTaskConfig();
await task.runTask(
'diagnostics-preview',
logger,
telemetryReceiver,
taskSender,
+ taskMetricsService,
taskExecutionPeriod
);
const messages = taskSender.getSentMessages();
- return parseNdjson(messages);
+ const taskMetrics = taskMetricsService.getSentMessages();
+ return parseNdjson([...messages, ...taskMetrics]);
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_endpoint_preview.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_endpoint_preview.ts
index 008a9d9b1d89d..e2f92811ed0e4 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_endpoint_preview.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_endpoint_preview.ts
@@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server';
import { PreviewTelemetryEventsSender } from '../../../../telemetry/preview_sender';
import type { ITelemetryReceiver } from '../../../../telemetry/receiver';
+import { PreviewTaskMetricsService } from '../../../../telemetry/preview_task_metrics';
import type { ITelemetryEventsSender } from '../../../../telemetry/sender';
import { createTelemetryEndpointTaskConfig } from '../../../../telemetry/tasks/endpoint';
import { parseNdjson } from './parse_ndjson';
@@ -28,14 +29,17 @@ export const getEndpointPreview = async ({
};
const taskSender = new PreviewTelemetryEventsSender(logger, telemetrySender);
+ const taskMetricsService = new PreviewTaskMetricsService(logger, taskSender);
const task = createTelemetryEndpointTaskConfig(1000);
await task.runTask(
'endpoint-preview',
logger,
telemetryReceiver,
taskSender,
+ taskMetricsService,
taskExecutionPeriod
);
const messages = taskSender.getSentMessages();
- return parseNdjson(messages);
+ const taskMetrics = taskMetricsService.getSentMessages();
+ return parseNdjson([...messages, ...taskMetrics]);
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_security_lists_preview.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_security_lists_preview.ts
index 4cb9fec83ff58..9df3637c0ecd0 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_security_lists_preview.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/telemetry/utils/get_security_lists_preview.ts
@@ -9,6 +9,7 @@ import type { Logger } from '@kbn/core/server';
import { PreviewTelemetryEventsSender } from '../../../../telemetry/preview_sender';
import type { ITelemetryReceiver } from '../../../../telemetry/receiver';
+import { PreviewTaskMetricsService } from '../../../../telemetry/preview_task_metrics';
import type { ITelemetryEventsSender } from '../../../../telemetry/sender';
import { createTelemetrySecurityListTaskConfig } from '../../../../telemetry/tasks/security_lists';
import { parseNdjson } from './parse_ndjson';
@@ -28,14 +29,17 @@ export const getSecurityListsPreview = async ({
};
const taskSender = new PreviewTelemetryEventsSender(logger, telemetrySender);
+ const taskMetricsService = new PreviewTaskMetricsService(logger, taskSender);
const task = createTelemetrySecurityListTaskConfig(1000);
await task.runTask(
'security-lists-preview',
logger,
telemetryReceiver,
taskSender,
+ taskMetricsService,
taskExecutionPeriod
);
const messages = taskSender.getSentMessages();
- return parseNdjson(messages);
+ const taskMetrics = taskMetricsService.getSentMessages();
+ return parseNdjson([...messages, ...taskMetrics]);
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
index 13c7e9e53df54..f91f806073ff1 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts
@@ -144,6 +144,6 @@ describe('Custom Query Alerts', () => {
await executor({ params });
expect((await ruleDataClient.getWriter()).bulk).toHaveBeenCalled();
- expect(eventsTelemetry.queueTelemetryEvents).toHaveBeenCalled();
+ expect(eventsTelemetry.sendAsync).toHaveBeenCalled();
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts
index 713428ca08557..c72be7edc9be8 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/send_telemetry_events.ts
@@ -9,6 +9,7 @@ import type { ITelemetryEventsSender } from '../../../telemetry/sender';
import type { TelemetryEvent } from '../../../telemetry/types';
import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring';
import type { SignalSource, SignalSourceHit } from '../types';
+import { TelemetryChannel } from '../../../telemetry/types';
interface SearchResultSource {
_source: SignalSource;
@@ -70,7 +71,7 @@ export function sendAlertTelemetryEvents(
selectedEvents = enrichEndpointAlertsSignalID(selectedEvents, signalIdMap);
}
try {
- eventsTelemetry.queueTelemetryEvents(selectedEvents);
+ eventsTelemetry.sendAsync(TelemetryChannel.ENDPOINT_ALERTS, selectedEvents);
} catch (exc) {
ruleExecutionLogger.error(`Queuing telemetry events failed: ${exc}`);
}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts
index a5d38b430ec92..5f430ca9e41e0 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts
@@ -5,13 +5,18 @@
* 2.0.
*/
+import { URL } from 'url';
import moment from 'moment';
import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server';
+import type { TelemetryPluginSetup, TelemetryPluginStart } from '@kbn/telemetry-plugin/server';
import { TaskStatus } from '@kbn/task-manager-plugin/server';
import type { TelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver, TelemetryReceiver } from '../receiver';
import type { SecurityTelemetryTaskConfig } from '../task';
import type { PackagePolicy } from '@kbn/fleet-plugin/common/types/models/package_policy';
+import type { ITaskMetricsService } from '../task_metrics.types';
+import { type IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter';
+import { type UsageCounters } from '@kbn/usage-collection-plugin/common/types';
import { stubEndpointAlertResponse, stubProcessTree, stubFetchTimelineEvents } from './timeline';
import { stubEndpointMetricsResponse } from './metrics';
import { prebuiltRuleAlertsResponse } from './prebuilt_rule_alerts';
@@ -30,6 +35,7 @@ export const createMockTelemetryEventsSender = (
getTelemetryUsageCluster: jest.fn(),
fetchTelemetryUrl: jest.fn(),
queueTelemetryEvents: jest.fn(),
+ sendAsync: jest.fn(),
processEvents: jest.fn(),
isTelemetryOptedIn: jest.fn().mockReturnValue(enableTelemetry ?? jest.fn()),
isTelemetryServicesReachable: jest.fn().mockReturnValue(canConnect ?? jest.fn()),
@@ -69,6 +75,34 @@ export const stubLicenseInfo: ESLicense = {
start_date_in_millis: -1,
};
+export const createMockTelemetryPluginSetup = (): jest.Mocked => {
+ return {
+ getTelemetryUrl: jest.fn(() => Promise.resolve(new URL('http://localhost/v3/send'))),
+ } as unknown as jest.Mocked;
+};
+
+export const createMockTelemetryPluginStart = (): jest.Mocked => {
+ return {
+ getIsOptedIn: jest.fn(() => Promise.resolve(true)),
+ } as unknown as jest.Mocked;
+};
+
+export const createMockUsageCounter = (): jest.Mocked => {
+ return {
+ incrementCounter: jest.fn((_: UsageCounters.v1.IncrementCounterParams) => {}),
+ } as unknown as jest.Mocked;
+};
+
+export const createMockTaskMetrics = (): jest.Mocked => {
+ return {
+ incrementCounter: jest.fn((_: UsageCounters.v1.IncrementCounterParams) => {}),
+ start: jest.fn(() => {
+ return { name: 'test-trace', startedAt: 0 };
+ }),
+ end: jest.fn(),
+ } as unknown as jest.Mocked;
+};
+
export const createMockTelemetryReceiver = (
diagnosticsAlert?: unknown,
emptyTimelineTree?: boolean
@@ -80,6 +114,7 @@ export const createMockTelemetryReceiver = (
return {
start: jest.fn(),
fetchClusterInfo: jest.fn().mockReturnValue(stubClusterInfo),
+ getClusterInfo: jest.fn().mockReturnValue(stubClusterInfo),
fetchLicenseInfo: jest.fn().mockReturnValue(stubLicenseInfo),
copyLicenseFields: jest.fn(),
fetchFleetAgents: jest.fn().mockReturnValue(stubFleetAgentResponse),
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip
index 779e8d257b7b0bf2406e31cc64250d2507df4c35..12affbe9861423bd4662abbea45f275a607ffea7 100644
GIT binary patch
delta 826
zcmeyvyH`LWz?+#xgaHJk;tV6^h&`VZ%gVqI%eqnGIrHSbOrqRrnK>n?MLC(pCB>8P
zGpRcmCmC5LrkYroBpDf|nxEsSf*N}rWz$0BpMo}rKY7?
zrX(39o0uD!Sy&jPB%2ye+@ru^ZfVOB
zQ)|>)t$n9|o>aeFD|IpP&8>|88fR3P=2UOWbK1E2==Z(sx6H2Jn3|bWz%ze0_t~@#
zxpzJXRGz7qc1zq;YO3o0^YmR@{p@wqaxG66Tc^~q$#_p+?`ax+GJ2O;;oQXmSp^p}
zC*PHu>R%XB>i_=p=ZqielAcqxPrUcj=7))>?s`)**90vszNG?bW^w;B9|Yby>&C^S
znVfw3#*?4FR^4lx*~j}Y_P4achuJchRE~aonRI1$_{?ou-pR(*OXp^{9=m#jXFErI
zQ)%3!OU&*UmndGSP1CboxoydW%X73lWG^@TzW3oW^I{xXm7;|k_-ne6#*S=}v
z)#+30Hw3&&erUZlwLapD)0-Q&I~Klva6SaW&5T%B1!
zrc%7$6#X|_H!O-X(#$OSxFFqTr{j{xAD+!xHmToJ%9jZbBrSmTr)Kd-}bfz8e5PH}000p5&E
iBFxxQ|Kx5qT@F|hntXswjH!cl@(ngQwoUAytO5YcQ(PYa
delta 647
zcmdlh@P}6-z?+#xgaHIp+7m;$4maA!urM&luxylg&OF(kRc&$_t4=*gN?rHZ3I-;i
zIw0l&Qn`tFnQ5uTC3?k~>4z-V=6_aq&A)fHX7dy27cYb!Br-U)o;i51dE5Lw^*@{^
z+>}o|6aKq2Ky}5_#Is&oE+JW_T>5O_uuRw}DUZ
zxiZh|M9+OPtd9AS;d|?+?z+SBJZCRQ1`B^0Ph*t$dVvqjr+(*G2mPyfy>%k{*MP9;
z;wy4FKUo;Gzw%o7e$HD~;{
zNpEV`$A27cSJh1Id!I}8t_Dy0JBwwZG{9eI*qO*RB_R1s)iyD*dDfM~>B78!W
z9QoZQ`76pyVXJ;7Eh;_#`qk>#t7(&O1TtjgWx4!wRaWd=rXn~sH)vY@t8Br>p3MBLz3=NG4~}W~
z|E07jH(v=BTTs@0ZHi*)+B+%t)`_dt*SYi-T5-Gd&uY0=^LOI+Jmw_98^eD>zB
z=`*|Y_tFE^6I-q--m1R4`nKH^`RMOFA8W<8%H|$t`yJ#aTCrFEO@KEelL#}mq%`>q
ayDkST2~B3?5My#@nJmd6$5zM&N_PN>h8)}g
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/artifact.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/artifact.test.ts
index a4e8d38875b8f..d3aabc5b28d41 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/artifact.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/artifact.test.ts
@@ -14,6 +14,10 @@ jest.mock('axios');
const mockedAxios = axios as jest.Mocked;
describe('telemetry artifact test', () => {
+ beforeEach(() => {
+ mockedAxios.get.mockReset();
+ });
+
test('start should set manifest url for snapshot version', async () => {
const expectedManifestUrl =
'https://artifacts.security.elastic.co/downloads/kibana/manifest/artifacts-8.0.0.zip';
@@ -61,6 +65,7 @@ describe('telemetry artifact test', () => {
const artifact = new Artifact();
await artifact.start(mockTelemetryReceiver);
const axiosResponse = {
+ status: 200,
data: 'x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip',
};
mockedAxios.get.mockImplementationOnce(() => Promise.resolve(axiosResponse));
@@ -74,12 +79,14 @@ describe('telemetry artifact test', () => {
const artifact = new Artifact();
await artifact.start(mockTelemetryReceiver);
const axiosResponse = {
+ status: 200,
data: 'x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip',
};
mockedAxios.get
.mockImplementationOnce(() => Promise.resolve(axiosResponse))
.mockImplementationOnce(() =>
Promise.resolve({
+ status: 200,
data: {
telemetry_max_buffer_size: 100,
max_security_list_telemetry_batch: 100,
@@ -89,13 +96,46 @@ describe('telemetry artifact test', () => {
},
})
);
- const artifactObject: TelemetryConfiguration = (await artifact.getArtifact(
- 'telemetry-buffer-and-batch-sizes-v1'
- )) as unknown as TelemetryConfiguration;
+ const manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1');
+ expect(manifest).not.toBeFalsy();
+ const artifactObject: TelemetryConfiguration =
+ manifest.data as unknown as TelemetryConfiguration;
expect(artifactObject.telemetry_max_buffer_size).toEqual(100);
expect(artifactObject.max_security_list_telemetry_batch).toEqual(100);
expect(artifactObject.max_endpoint_telemetry_batch).toEqual(300);
expect(artifactObject.max_detection_rule_telemetry_batch).toEqual(1_000);
expect(artifactObject.max_detection_alerts_batch).toEqual(50);
});
+
+ test('getArtifact should cache response', async () => {
+ const fakeEtag = '123';
+ const axiosResponse = {
+ status: 200,
+ data: 'x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/kibana-artifacts.zip',
+ headers: { etag: fakeEtag },
+ };
+ const artifact = new Artifact();
+
+ await artifact.start(createMockTelemetryReceiver());
+
+ mockedAxios.get
+ .mockImplementationOnce(() => Promise.resolve(axiosResponse))
+ .mockImplementationOnce(() => Promise.resolve({ status: 200, data: {} }))
+ .mockImplementationOnce(() => Promise.resolve({ status: 304 }));
+
+ let manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1');
+ expect(manifest).not.toBeFalsy();
+ expect(manifest.notModified).toEqual(false);
+ expect(mockedAxios.get.mock.calls.length).toBe(2);
+
+ manifest = await artifact.getArtifact('telemetry-buffer-and-batch-sizes-v1');
+ expect(manifest).not.toBeFalsy();
+ expect(manifest.notModified).toEqual(true);
+ expect(mockedAxios.get.mock.calls.length).toBe(3);
+
+ const [_url, config] = mockedAxios.get.mock.calls[2];
+ const headers = config?.headers ?? {};
+ expect(headers).not.toBeFalsy();
+ expect(headers['If-None-Match']).toEqual(fakeEtag);
+ });
});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/artifact.ts b/x-pack/plugins/security_solution/server/lib/telemetry/artifact.ts
index 8d4cd82ebb152..610b6eaac18c4 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/artifact.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/artifact.ts
@@ -6,22 +6,34 @@
*/
import axios from 'axios';
+import { cloneDeep } from 'lodash';
import AdmZip from 'adm-zip';
import type { ITelemetryReceiver } from './receiver';
import type { ESClusterInfo } from './types';
export interface IArtifact {
start(receiver: ITelemetryReceiver): Promise;
- getArtifact(name: string): Promise;
+ getArtifact(name: string): Promise;
getManifestUrl(): string | undefined;
}
+export interface Manifest {
+ data: unknown;
+ notModified: boolean;
+}
+
+interface CacheEntry {
+ manifest: Manifest;
+ etag: string;
+}
+
export class Artifact implements IArtifact {
private manifestUrl?: string;
private readonly CDN_URL = 'https://artifacts.security.elastic.co';
private readonly AXIOS_TIMEOUT_MS = 10_000;
private receiver?: ITelemetryReceiver;
private esClusterInfo?: ESClusterInfo;
+ private cache: Map = new Map();
public async start(receiver: ITelemetryReceiver) {
this.receiver = receiver;
@@ -36,30 +48,85 @@ export class Artifact implements IArtifact {
}
}
- public async getArtifact(name: string): Promise {
- if (this.manifestUrl) {
- const response = await axios.get(this.manifestUrl, {
+ public async getArtifact(name: string): Promise {
+ return axios
+ .get(this.getManifestUrl(), {
+ headers: this.headers(name),
timeout: this.AXIOS_TIMEOUT_MS,
+ validateStatus: (status) => status < 500,
responseType: 'arraybuffer',
+ })
+ .then(async (response) => {
+ switch (response.status) {
+ case 200:
+ const manifest = {
+ data: await this.getManifest(name, response.data),
+ notModified: false,
+ };
+ // only update etag if we got a valid manifest
+ if (response.headers && response.headers.etag) {
+ const cacheEntry = {
+ manifest: { ...manifest, notModified: true },
+ etag: response.headers?.etag ?? '',
+ };
+ this.cache.set(name, cacheEntry);
+ }
+ return cloneDeep(manifest);
+ case 304:
+ return cloneDeep(this.getCachedManifest(name));
+ case 404:
+ throw Error(`No manifest resource found at url: ${this.manifestUrl}`);
+ default:
+ throw Error(`Failed to download manifest, unexpected status code: ${response.status}`);
+ }
});
- const zip = new AdmZip(response.data);
- const entries = zip.getEntries();
- const manifest = JSON.parse(entries[0].getData().toString());
- const relativeUrl = manifest.artifacts[name]?.relative_url;
- if (relativeUrl) {
- const url = `${this.CDN_URL}${relativeUrl}`;
- const artifactResponse = await axios.get(url, { timeout: this.AXIOS_TIMEOUT_MS });
- return artifactResponse.data;
- } else {
- throw Error(`No artifact for name ${name}`);
- }
+ }
+
+ public getManifestUrl() {
+ if (this.manifestUrl) {
+ return this.manifestUrl;
} else {
throw Error(`No manifest url for version ${this.esClusterInfo?.version?.number}`);
}
}
- public getManifestUrl() {
- return this.manifestUrl;
+ public getCachedManifest(name: string): Manifest {
+ const entry = this.cache.get(name);
+ if (!entry) {
+ throw Error(`No cached manifest for name ${name}`);
+ }
+ return entry.manifest;
+ }
+
+ private async getManifest(name: string, data: Buffer): Promise {
+ const zip = new AdmZip(data);
+
+ const manifestFile = zip.getEntries().find((entry) => {
+ return entry.entryName === 'manifest.json';
+ });
+
+ if (!manifestFile) {
+ throw Error('No manifest.json in artifact zip');
+ }
+
+ const manifest = JSON.parse(manifestFile.getData().toString());
+ const relativeUrl = manifest.artifacts[name]?.relative_url;
+ if (relativeUrl) {
+ const url = `${this.CDN_URL}${relativeUrl}`;
+ const artifactResponse = await axios.get(url, { timeout: this.AXIOS_TIMEOUT_MS });
+ return artifactResponse.data;
+ } else {
+ throw Error(`No artifact for name ${name}`);
+ }
+ }
+
+ // morre info https://www.rfc-editor.org/rfc/rfc9110#name-etag
+ private headers(name: string): Record {
+ const etag = this.cache.get(name)?.etag;
+ if (etag) {
+ return { 'If-None-Match': etag };
+ }
+ return {};
}
}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.test.ts
new file mode 100644
index 0000000000000..5545983ec9c17
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.test.ts
@@ -0,0 +1,1148 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import axios from 'axios';
+
+import type { QueueConfig, IAsyncTelemetryEventsSender } from './async_sender.types';
+import {
+ DEFAULT_QUEUE_CONFIG,
+ DEFAULT_RETRY_CONFIG,
+ AsyncTelemetryEventsSender,
+} from './async_sender';
+import { TelemetryChannel, TelemetryCounter } from './types';
+import { loggingSystemMock } from '@kbn/core/server/mocks';
+import {
+ createMockTelemetryReceiver,
+ createMockTelemetryPluginSetup,
+ createMockTelemetryPluginStart,
+ createMockUsageCounter,
+} from './__mocks__';
+import { TelemetryEventsSender } from './sender';
+
+jest.mock('axios');
+jest.mock('./receiver');
+
+describe('AsyncTelemetryEventsSender', () => {
+ const mockedAxiosPost = jest.spyOn(axios, 'post');
+ const mockedAxiosGet = jest.spyOn(axios, 'get');
+ const telemetryPluginSetup = createMockTelemetryPluginSetup();
+ const telemetryPluginStart = createMockTelemetryPluginStart();
+ const receiver = createMockTelemetryReceiver();
+ const telemetryUsageCounter = createMockUsageCounter();
+ const ch1 = TelemetryChannel.INSIGHTS;
+ const ch2 = TelemetryChannel.LISTS;
+ const ch3 = TelemetryChannel.DETECTION_ALERTS;
+ const ch1Config: QueueConfig = {
+ bufferTimeSpanMillis: 100,
+ inflightEventsThreshold: 1000,
+ maxPayloadSizeBytes: 10_000,
+ };
+ const ch2Config: QueueConfig = {
+ bufferTimeSpanMillis: 1000,
+ inflightEventsThreshold: 500,
+ maxPayloadSizeBytes: 10_000,
+ };
+ const ch3Config: QueueConfig = {
+ bufferTimeSpanMillis: 5000,
+ inflightEventsThreshold: 10,
+ maxPayloadSizeBytes: 10_000,
+ };
+
+ let service: IAsyncTelemetryEventsSender;
+
+ beforeEach(() => {
+ service = new AsyncTelemetryEventsSender(loggingSystemMock.createLogger());
+ jest.useFakeTimers({ advanceTimers: true });
+ mockedAxiosPost.mockClear();
+ telemetryUsageCounter.incrementCounter.mockClear();
+ mockedAxiosPost.mockResolvedValue({ status: 201 });
+ mockedAxiosGet.mockResolvedValue({ status: 200 });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('initialization', () => {
+ it('uses default configu', async () => {
+ const events = ['e1', 'e2', 'e3'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, events);
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not lose data during startup', async () => {
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+
+ const events = ['e1', 'e2', 'e3'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ service.send(ch1, events);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ service.start(telemetryPluginStart);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not start without being configured', () => {
+ expect(() => {
+ service.start(telemetryPluginStart);
+ }).toThrow('CREATED: invalid status. Expected [CONFIGURED]');
+ });
+
+ it('should not start twice', () => {
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+
+ service.start(telemetryPluginStart);
+
+ expect(() => {
+ service.start(telemetryPluginStart);
+ }).toThrow('STARTED: invalid status. Expected [CONFIGURED]');
+ });
+
+ it('should not send events if the servise is not configured', () => {
+ expect(() => {
+ service.send(ch1, ['hello']);
+ }).toThrow('CREATED: invalid status. Expected [CONFIGURED,STARTED]');
+ });
+ });
+
+ describe('simple use cases', () => {
+ it('should chunk events by size', async () => {
+ const events = ['aaaaa', 'b', 'c'];
+ const expectedBodies = [
+ events
+ .slice(0, 2)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ events
+ .slice(2)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ ];
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, maxPayloadSizeBytes: 10 });
+ service.start(telemetryPluginStart);
+
+ // at most 10 bytes per payload (after serialized to JSON): it should send
+ // two posts: ["aaaaa", "b"] and ["c"]
+ service.send(ch1, events);
+
+ await service.stop();
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+ });
+
+ it('should chunk events by size, even if one event is bigger than `maxTelemetryPayloadSizeBytes`', async () => {
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, maxPayloadSizeBytes: 3 });
+ service.start(telemetryPluginStart);
+
+ // at most 10 bytes per payload (after serialized to JSON): it should
+ // send two posts: ["aaaaa", "b"] and ["c"]
+ const events = ['aaaaa', 'b', 'c'];
+ const expectedBodies = [
+ events
+ .slice(0, 1)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ events
+ .slice(1, 2)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ events
+ .slice(2)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ ];
+
+ service.send(ch1, events);
+
+ await service.stop();
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+ });
+
+ it('should buffer for a specific time period', async () => {
+ const bufferTimeSpanMillis = 2000;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, bufferTimeSpanMillis });
+ service.start(telemetryPluginStart);
+
+ const events = ['a', 'b', 'c'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ // send some events
+ service.send(ch1, events);
+
+ // advance time by less than the buffer time span
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 0.2);
+
+ // check that no events are sent before the buffer time span
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ // advance time by more than the buffer time span
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.2);
+
+ // check that the events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('error handling', () => {
+ it('retries when the backend fails', async () => {
+ mockedAxiosPost
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValue(Promise.resolve({ status: 201 }));
+
+ const bufferTimeSpanMillis = 3;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, bufferTimeSpanMillis });
+ service.start(telemetryPluginStart);
+
+ // send some events
+ service.send(ch1, ['a']);
+
+ // advance time by more than the retry delay for all the retries
+ await jest.advanceTimersByTimeAsync(
+ DEFAULT_RETRY_CONFIG.retryCount * DEFAULT_RETRY_CONFIG.retryDelayMillis
+ );
+
+ // check that the events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount);
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount);
+ });
+
+ it('retries runtime errors', async () => {
+ mockedAxiosPost
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValue(Promise.resolve({ status: 201 }));
+
+ const bufferTimeSpanMillis = 3;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, bufferTimeSpanMillis });
+ service.start(telemetryPluginStart);
+
+ // send some events
+ service.send(ch1, ['a']);
+
+ // advance time by more than the retry delay for all the retries
+ await jest.advanceTimersByTimeAsync(
+ DEFAULT_RETRY_CONFIG.retryCount * DEFAULT_RETRY_CONFIG.retryDelayMillis
+ );
+
+ // check that the events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount);
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount);
+ });
+
+ it('only retries `retryCount` times', async () => {
+ mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 500 }));
+ const bufferTimeSpanMillis = 100;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, bufferTimeSpanMillis });
+ service.start(telemetryPluginStart);
+
+ // send some events
+ service.send(ch1, ['a']);
+
+ // advance time by more than the buffer time span
+ await jest.advanceTimersByTimeAsync(
+ (DEFAULT_RETRY_CONFIG.retryCount + 1) * DEFAULT_RETRY_CONFIG.retryDelayMillis * 1.2
+ );
+
+ // check that the events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1);
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1);
+ });
+
+ it('should catch fatal errors', async () => {
+ mockedAxiosPost.mockImplementation(() => {
+ throw Error('fatal error');
+ });
+ const bufferTimeSpanMillis = 100;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, { ...ch1Config, bufferTimeSpanMillis });
+ service.start(telemetryPluginStart);
+
+ // send some events
+ service.send(ch1, ['a']);
+
+ // advance time by more than the buffer time span
+ await jest.advanceTimersByTimeAsync(
+ (DEFAULT_RETRY_CONFIG.retryCount + 1) * DEFAULT_RETRY_CONFIG.retryDelayMillis * 1.2
+ );
+
+ // check that the events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1);
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(DEFAULT_RETRY_CONFIG.retryCount + 1);
+ });
+ });
+
+ describe('throttling', () => {
+ it('drop events above inflightEventsThreshold', async () => {
+ const inflightEventsThreshold = 3;
+ const bufferTimeSpanMillis = 2000;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, {
+ ...ch1Config,
+ bufferTimeSpanMillis,
+ inflightEventsThreshold,
+ });
+ service.start(telemetryPluginStart);
+
+ // send five events
+ service.send(ch1, ['a', 'b', 'c', 'd']);
+
+ // check that no events are sent before the buffer time span
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ // advance time
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2);
+
+ // check that only `inflightEventsThreshold` events were sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ '"a"\n"b"\n"c"',
+ expect.anything()
+ );
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('do not drop events if they are processed before the next batch', async () => {
+ const batches = 3;
+ const inflightEventsThreshold = 3;
+ const bufferTimeSpanMillis = 2000;
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, {
+ ...ch1Config,
+ bufferTimeSpanMillis,
+ inflightEventsThreshold,
+ });
+
+ service.start(telemetryPluginStart);
+
+ // check that no events are sent before the buffer time span
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ for (let i = 0; i < batches; i++) {
+ // send the next batch
+ service.send(ch1, ['a', 'b', 'c']);
+
+ // advance time
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2);
+ }
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(batches);
+ for (let i = 0; i < batches; i++) {
+ const expected = '"a"\n"b"\n"c"';
+
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ i + 1,
+ expect.anything(),
+ expected,
+ expect.anything()
+ );
+ }
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(batches);
+ });
+ });
+
+ describe('priority queues', () => {
+ it('manage multiple queues for a single channel', async () => {
+ const ch1Events = ['high-a', 'high-b', 'high-c', 'high-d'];
+ const ch2Events = ['med-a', 'med-b', 'med-c', 'med-d'];
+ const ch3Events = ['low-a', 'low-b', 'low-c', 'low-d'];
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch1, ch1Config);
+ service.updateQueueConfig(ch2, ch2Config);
+ service.updateQueueConfig(ch3, ch3Config);
+ service.start(telemetryPluginStart);
+
+ // send low-priority events
+ service.send(ch3, ch3Events.slice(0, 2));
+
+ // wait less than low priority latency
+ await jest.advanceTimersByTimeAsync(ch2Config.bufferTimeSpanMillis);
+
+ // send more low-priority events
+ service.send(ch3, ch3Events.slice(2, ch3Events.length));
+
+ // also send mid-priority events
+ service.send(ch2, ch2Events);
+
+ // and finally send some high-priority events
+ service.send(ch1, ch1Events);
+
+ // wait a little bit, just the high priority queue latency
+ await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis);
+
+ // only high priority events should have been sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 1,
+ expect.anything(),
+ ch1Events.map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ // wait just the medium priority queue latency
+ await jest.advanceTimersByTimeAsync(ch2Config.bufferTimeSpanMillis);
+
+ // only medium priority events should have been sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 2,
+ expect.anything(),
+ ch2Events.map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ // wait more time
+ await jest.advanceTimersByTimeAsync(ch3Config.bufferTimeSpanMillis);
+
+ // all events should have been sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 3,
+ expect.anything(),
+ ch3Events.map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ // no more events sent after the service was stopped
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+ });
+
+ it('discard events when inflightEventsThreshold is reached and process other queues', async () => {
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch2, ch2Config);
+ service.updateQueueConfig(ch3, ch3Config);
+ service.start(telemetryPluginStart);
+
+ const ch2Events = Array.from(
+ { length: ch2Config.inflightEventsThreshold },
+ (_, i) => `ch2-${i}`
+ );
+ // double the inflightEventsThreshold for ch3 events, the service should drop half of them
+ const ch3Events = Array.from(
+ { length: ch3Config.inflightEventsThreshold * 2 },
+ (_, i) => `ch3-${i}`
+ );
+
+ service.send(ch3, ch3Events);
+ service.send(ch2, ch2Events);
+
+ await jest.advanceTimersByTimeAsync(ch2Config.bufferTimeSpanMillis * 1.2);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ await jest.advanceTimersByTimeAsync(ch3Config.bufferTimeSpanMillis * 1.2);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 1,
+ expect.anything(),
+ // gets all ch2 events
+ ch2Events.map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 2,
+ expect.anything(),
+ // only got `inflightEventsThreshold` events, the remaining ch3 events were dropped
+ ch3Events
+ .slice(0, ch3Config.inflightEventsThreshold)
+ .map((e) => JSON.stringify(e))
+ .join('\n'),
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ });
+
+ it('should manage queue priorities and channels', async () => {
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(ch2, ch2Config);
+ service.updateQueueConfig(ch3, ch3Config);
+ service.start(telemetryPluginStart);
+
+ const cases = [
+ {
+ events: ['ch3-1', 'ch3-2'],
+ channel: ch3,
+ wait: ch3Config.bufferTimeSpanMillis * 0.2,
+ },
+ {
+ events: ['ch2-1', 'ch2-2', 'ch2-3'],
+ channel: ch2,
+ wait: ch2Config.bufferTimeSpanMillis * 0.3,
+ },
+ {
+ events: ['ch2-4', 'ch2-5'],
+ channel: ch2,
+ wait: ch2Config.bufferTimeSpanMillis * 0.9,
+ },
+ {
+ events: ['ch2-6', 'ch2-7', 'ch2-8'],
+ channel: ch2,
+ wait: ch2Config.bufferTimeSpanMillis * 0.2,
+ },
+ {
+ events: ['ch3-3', 'ch3-4', 'ch3-5'],
+ channel: ch3,
+ wait: ch3Config.bufferTimeSpanMillis * 1.1,
+ },
+ ];
+
+ for (let i = 0; i < cases.length; i++) {
+ const testCase = cases[i];
+
+ service.send(testCase.channel, testCase.events);
+ await jest.advanceTimersByTimeAsync(testCase.wait);
+ }
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringMatching(`.*${ch2}.*`), // url contains the channel name
+ [...cases[1].events, ...cases[2].events].map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringMatching(`.*${ch2}.*`), // url contains the channel name
+ cases[3].events.map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ expect(mockedAxiosPost).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringMatching(`.*${ch3}.*`), // url contains the channel name
+ [...cases[0].events, ...cases[4].events].map((e) => JSON.stringify(e)).join('\n'),
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ describe('dynamic configuration', () => {
+ it('should update default queue config', async () => {
+ const initialTimeSpan = DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis;
+ const bufferTimeSpanMillis = initialTimeSpan * 10;
+ const events = ['e1', 'e2', 'e3'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ service.updateDefaultQueueConfig({ ...DEFAULT_QUEUE_CONFIG, bufferTimeSpanMillis });
+
+ // send data and wait the initial time span
+ service.send(ch1, events);
+ await jest.advanceTimersByTimeAsync(initialTimeSpan * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ // wait the new timespan, now we should have data
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('should update buffer time config dinamically', async () => {
+ const channel = TelemetryChannel.DETECTION_ALERTS;
+ const bufferTimeSpanMillis = 5001;
+ const detectionAlertsAfter = {
+ ...ch1Config,
+ bufferTimeSpanMillis,
+ };
+ const events = ['a', 'b', 'c'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(channel, ch1Config);
+ service.start(telemetryPluginStart);
+
+ service.send(channel, events);
+
+ await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ service.updateQueueConfig(channel, detectionAlertsAfter);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ service.send(channel, events);
+ // the old buffer time shouldn't trigger a new buffer (we increased it)
+ await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ // wait more time...
+ await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ });
+
+ it('should update max payload size dinamically', async () => {
+ const channel = TelemetryChannel.DETECTION_ALERTS;
+ const detectionAlertsBefore = {
+ bufferTimeSpanMillis: 1000,
+ inflightEventsThreshold: 10,
+ maxPayloadSizeBytes: 10,
+ };
+ const detectionAlertsAfter = {
+ ...detectionAlertsBefore,
+ maxPayloadSizeBytes: 10_000,
+ };
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(channel, detectionAlertsBefore);
+ service.start(telemetryPluginStart);
+
+ service.send(channel, ['aaaaa', 'b', 'c']);
+ let expectedBodies = ['"aaaaa"\n"b"', '"c"'];
+
+ await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+
+ service.updateQueueConfig(channel, detectionAlertsAfter);
+
+ service.send(channel, ['aaaaa', 'b', 'c']);
+ expectedBodies = ['"aaaaa"\n"b"\n"c"'];
+
+ await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(3);
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+
+ await service.stop();
+ });
+
+ it('should configure a new queue', async () => {
+ const bufferTimeSpanMillis = DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 10;
+ const events = ['a', 'b', 'c'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, events);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ service.updateQueueConfig(ch1, { ...DEFAULT_QUEUE_CONFIG, bufferTimeSpanMillis });
+
+ service.send(ch1, events);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('http headers', () => {
+ it('should add X-Telemetry-Sender header', async () => {
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a']);
+ await service.stop();
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ const found = mockedAxiosPost.mock.calls.some(
+ ([_url, _body, config]) =>
+ config && config.headers && config.headers['X-Telemetry-Sender'] === 'async'
+ );
+
+ expect(found).not.toBeFalsy();
+ });
+ });
+
+ describe('usage counter', () => {
+ it('should increment the counter when sending events ok', async () => {
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a', 'b', 'c']);
+ await service.stop();
+
+ const found = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.DOCS_SENT && param.incrementBy === 3
+ );
+ expect(found).not.toBeFalsy();
+ });
+
+ it('should increment the counter when sending events with errors', async () => {
+ mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 500 }));
+
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+ service.updateQueueConfig(ch1, ch1Config);
+ service.updateQueueConfig(ch2, ch2Config);
+ service.updateQueueConfig(ch3, ch3Config);
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a', 'b', 'c']);
+ await service.stop();
+
+ const found = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.DOCS_LOST && param.incrementBy === 3
+ );
+ expect(found).not.toBeFalsy();
+ });
+
+ it('should increment the counter when sending events with errors and without errors', async () => {
+ // retries count is set to 3
+ mockedAxiosPost
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }))
+ .mockReturnValueOnce(Promise.resolve({ status: 500 }));
+
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+ service.updateQueueConfig(ch1, ch1Config);
+ service.updateQueueConfig(ch2, ch2Config);
+ service.updateQueueConfig(ch3, ch3Config);
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a', 'b', 'c']);
+ await jest.advanceTimersByTimeAsync(ch1Config.bufferTimeSpanMillis * 1.1);
+ service.send(ch1, ['a', 'b']);
+ await service.stop();
+
+ const foundLost = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.DOCS_LOST && param.incrementBy === 3
+ );
+ expect(foundLost).not.toBeFalsy();
+
+ const foundSent = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.DOCS_SENT && param.incrementBy === 2
+ );
+ expect(foundSent).not.toBeFalsy();
+ });
+
+ it('should increment the counter when drops events', async () => {
+ const inflightEventsThreshold = 3;
+ const bufferTimeSpanMillis = 2000;
+
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+ service.updateQueueConfig(ch1, {
+ ...ch1Config,
+ bufferTimeSpanMillis,
+ inflightEventsThreshold,
+ });
+ service.start(telemetryPluginStart);
+
+ // send five events
+ service.send(ch1, ['a', 'b', 'c', 'd']);
+
+ // check that no events are sent before the buffer time span
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ // advance time
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 2);
+
+ // check that only `inflightEventsThreshold` events were sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ '"a"\n"b"\n"c"',
+ expect.anything()
+ );
+
+ const found = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.DOCS_DROPPED && param.incrementBy === 1
+ );
+ expect(found).not.toBeFalsy();
+
+ await service.stop();
+
+ // check that no more events are sent
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('should increment runtime error counter for expected errors', async () => {
+ mockedAxiosPost.mockReturnValue(Promise.resolve({ status: 401 }));
+
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a']);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 10);
+ await service.stop();
+
+ const foundFatal = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.FATAL_ERROR && param.incrementBy === 1
+ );
+ expect(foundFatal).toBeFalsy();
+
+ const foundRuntime = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.RUNTIME_ERROR && param.incrementBy === 1
+ );
+ expect(foundRuntime).not.toBeFalsy();
+ });
+
+ it('should increment fatal error counter when applies', async () => {
+ mockedAxiosPost.mockImplementation(() => {
+ throw Error('fatal error');
+ });
+
+ service.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ receiver,
+ telemetryPluginSetup,
+ telemetryUsageCounter
+ );
+
+ service.start(telemetryPluginStart);
+
+ service.send(ch1, ['a']);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 10);
+ await service.stop();
+
+ const foundFatal = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.FATAL_ERROR && param.incrementBy === 1
+ );
+ expect(foundFatal).not.toBeFalsy();
+
+ const foundRuntime = telemetryUsageCounter.incrementCounter.mock.calls.some(
+ ([param]) => param.counterType === TelemetryCounter.RUNTIME_ERROR && param.incrementBy === 1
+ );
+ expect(foundRuntime).toBeFalsy();
+ });
+ });
+
+ describe('ITelemetryEventsSender integration', () => {
+ it('should send events using the async service', async () => {
+ const serviceV1 = new TelemetryEventsSender(loggingSystemMock.createLogger());
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ serviceV1.setup(receiver, telemetryPluginSetup, undefined, telemetryUsageCounter, service);
+
+ const events = ['a', 'b', 'c'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+
+ serviceV1.sendAsync(ch1, events);
+
+ await jest.advanceTimersByTimeAsync(DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ serviceV1.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('should configure the default queue config in the async service', async () => {
+ const initialTimeSpan = DEFAULT_QUEUE_CONFIG.bufferTimeSpanMillis;
+ const bufferTimeSpanMillis = initialTimeSpan * 10;
+ const events = ['e1', 'e2', 'e3'];
+ const expectedBody = events.map((e) => JSON.stringify(e)).join('\n');
+ const serviceV1 = new TelemetryEventsSender(loggingSystemMock.createLogger());
+
+ serviceV1.setup(receiver, telemetryPluginSetup, undefined, telemetryUsageCounter, service);
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ serviceV1.updateDefaultQueueConfig({ ...DEFAULT_QUEUE_CONFIG, bufferTimeSpanMillis });
+
+ // send data and wait the initial time span
+ serviceV1.sendAsync(ch1, events);
+ await jest.advanceTimersByTimeAsync(initialTimeSpan * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ // wait the new timespan, now we should have data
+ await jest.advanceTimersByTimeAsync(bufferTimeSpanMillis * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+
+ await service.stop();
+ serviceV1.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ });
+
+ it('should configure a queue config in the async service', async () => {
+ const channel = TelemetryChannel.DETECTION_ALERTS;
+ const detectionAlertsBefore = {
+ bufferTimeSpanMillis: 900,
+ inflightEventsThreshold: 10,
+ maxPayloadSizeBytes: 10_000,
+ };
+ const detectionAlertsAfter = {
+ ...detectionAlertsBefore,
+ bufferTimeSpanMillis: 5001,
+ };
+ const serviceV1 = new TelemetryEventsSender(loggingSystemMock.createLogger());
+
+ serviceV1.setup(receiver, telemetryPluginSetup, undefined, telemetryUsageCounter, service);
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.updateQueueConfig(channel, detectionAlertsBefore);
+ service.start(telemetryPluginStart);
+
+ serviceV1.sendAsync(channel, ['a', 'b', 'c']);
+ const expectedBodies = ['"a"\n"b"\n"c"'];
+
+ await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+
+ serviceV1.updateQueueConfig(channel, detectionAlertsAfter);
+
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ serviceV1.sendAsync(channel, ['a', 'b', 'c']);
+ // the old buffer time shouldn't trigger a new buffer (we increased it)
+ await jest.advanceTimersByTimeAsync(detectionAlertsBefore.bufferTimeSpanMillis * 1.1);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
+
+ // wait more time...
+ await jest.advanceTimersByTimeAsync(detectionAlertsAfter.bufferTimeSpanMillis);
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+
+ expectedBodies.forEach((expectedBody) => {
+ expect(mockedAxiosPost).toHaveBeenCalledWith(
+ expect.anything(),
+ expectedBody,
+ expect.anything()
+ );
+ });
+
+ await service.stop();
+ serviceV1.stop();
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('simulateSend', () => {
+ it('should send events using the async service', async () => {
+ jest.useRealTimers();
+ const events = ['a', 'b', 'c'];
+ const expectedResult = events.map((e) => JSON.stringify(e));
+
+ service.setup(DEFAULT_RETRY_CONFIG, DEFAULT_QUEUE_CONFIG, receiver, telemetryPluginSetup);
+ service.start(telemetryPluginStart);
+
+ const result = service.simulateSend(ch1, events);
+
+ await service.stop();
+
+ // no events sent to the telemetry service
+ expect(mockedAxiosPost).toHaveBeenCalledTimes(0);
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.ts
new file mode 100644
index 0000000000000..48afbddc4e1d7
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.ts
@@ -0,0 +1,414 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import axios from 'axios';
+import * as rx from 'rxjs';
+import _, { cloneDeep } from 'lodash';
+
+import type { Logger } from '@kbn/core/server';
+import type { TelemetryPluginSetup, TelemetryPluginStart } from '@kbn/telemetry-plugin/server';
+import { type IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter';
+import type { ITelemetryReceiver } from './receiver';
+import {
+ type IAsyncTelemetryEventsSender,
+ type QueueConfig,
+ type RetryConfig,
+} from './async_sender.types';
+import { TelemetryChannel, TelemetryCounter } from './types';
+import * as collections from './collections_helpers';
+import { CachedSubject, retryOnError$ } from './rxjs_helpers';
+import { SenderUtils } from './sender_helpers';
+import { newTelemetryLogger, type TelemetryLogger } from './helpers';
+
+export const DEFAULT_QUEUE_CONFIG: QueueConfig = {
+ bufferTimeSpanMillis: 30 * 1_000,
+ inflightEventsThreshold: 1_000,
+ maxPayloadSizeBytes: 1024 * 1024, // 1MiB
+};
+export const DEFAULT_RETRY_CONFIG: RetryConfig = {
+ retryCount: 3,
+ retryDelayMillis: 1000,
+};
+
+export class AsyncTelemetryEventsSender implements IAsyncTelemetryEventsSender {
+ private retryConfig: RetryConfig | undefined;
+ private fallbackQueueConfig: QueueConfig | undefined;
+ private queues: Map | undefined;
+
+ private readonly flush$ = new rx.Subject();
+
+ private readonly events$ = new rx.Subject();
+
+ private readonly finished$ = new rx.Subject();
+ private cache: CachedSubject | undefined;
+
+ private status: ServiceStatus = ServiceStatus.CREATED;
+
+ private readonly logger: TelemetryLogger;
+
+ private telemetryReceiver?: ITelemetryReceiver;
+ private telemetrySetup?: TelemetryPluginSetup;
+ private telemetryUsageCounter?: IUsageCounter;
+ private senderUtils: SenderUtils | undefined;
+
+ constructor(logger: Logger) {
+ this.logger = newTelemetryLogger(logger.get('telemetry_events.async_sender'));
+ }
+
+ public setup(
+ retryConfig: RetryConfig,
+ fallbackQueueConfig: QueueConfig,
+ telemetryReceiver: ITelemetryReceiver,
+ telemetrySetup?: TelemetryPluginSetup,
+ telemetryUsageCounter?: IUsageCounter
+ ): void {
+ this.logger.l(`Setting up ${AsyncTelemetryEventsSender.name}`);
+
+ this.ensureStatus(ServiceStatus.CREATED);
+
+ this.retryConfig = retryConfig;
+ this.fallbackQueueConfig = fallbackQueueConfig;
+ this.queues = new Map();
+ this.cache = new CachedSubject(this.events$);
+ this.telemetryReceiver = telemetryReceiver;
+ this.telemetrySetup = telemetrySetup;
+ this.telemetryUsageCounter = telemetryUsageCounter;
+
+ this.updateStatus(ServiceStatus.CONFIGURED);
+ }
+
+ public start(telemetryStart?: TelemetryPluginStart): void {
+ this.logger.l(`Starting ${AsyncTelemetryEventsSender.name}`);
+
+ this.ensureStatus(ServiceStatus.CONFIGURED);
+
+ this.senderUtils = new SenderUtils(
+ this.telemetrySetup,
+ telemetryStart,
+ this.telemetryReceiver,
+ this.telemetryUsageCounter
+ );
+
+ this.cache?.stop();
+ this.events$
+ .pipe(
+ rx.connect((shared$) => {
+ const queues$ = Object.values(TelemetryChannel).map((channel) =>
+ this.queue$(shared$, channel, this.sendEvents.bind(this))
+ );
+ return rx.merge(...queues$);
+ })
+ )
+ .subscribe({
+ next: (result: Result) => {
+ if (isFailure(result)) {
+ this.logger.l(
+ `Failure! unable to send ${result.events} events to channel "${result.channel}": ${result.message}`
+ );
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.DOCS_LOST,
+ result.events,
+ result.channel
+ );
+ } else {
+ this.logger.l(`Success! ${result.events} events sent to channel "${result.channel}"`);
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.DOCS_SENT,
+ result.events,
+ result.channel
+ );
+ }
+ },
+ error: (err) => {
+ this.logger.l(`Unexpected error: "${err}"`);
+ },
+ complete: () => {
+ this.logger.l('Shutting down');
+ this.finished$.next();
+ },
+ });
+
+ this.cache?.flush();
+ this.updateStatus(ServiceStatus.STARTED);
+ }
+
+ public async stop(): Promise {
+ this.logger.l(`Stopping ${AsyncTelemetryEventsSender.name}`);
+
+ this.ensureStatus(ServiceStatus.CONFIGURED, ServiceStatus.STARTED);
+
+ const finishPromise = rx.firstValueFrom(this.finished$);
+ this.events$.complete();
+ this.cache?.stop();
+ await finishPromise;
+
+ this.updateStatus(ServiceStatus.CONFIGURED);
+ }
+
+ public send(channel: TelemetryChannel, events: unknown[]): void {
+ this.ensureStatus(ServiceStatus.CONFIGURED, ServiceStatus.STARTED);
+
+ events.forEach((event) => {
+ this.events$.next({ channel, payload: event });
+ });
+ }
+
+ public simulateSend(channel: TelemetryChannel, events: unknown[]): string[] {
+ const payloads: string[] = [];
+
+ const localEvents$: rx.Observable = rx.of(
+ ...events.map((e) => {
+ return { channel, payload: e };
+ })
+ );
+
+ const localSubscription$ = this.queue$(localEvents$, channel, (_ch, p) => {
+ const result = { events: events.length, channel };
+ payloads.push(...p);
+ return Promise.resolve(result);
+ }).subscribe();
+
+ localSubscription$.unsubscribe();
+
+ return payloads;
+ }
+
+ public updateQueueConfig(channel: TelemetryChannel, config: QueueConfig): void {
+ const currentConfig = this.getQueues().get(channel);
+ if (!_.isEqual(config, currentConfig)) {
+ this.getQueues().set(channel, cloneDeep(config));
+ // flush the queues to get the new configuration asap
+ this.flush$.next();
+ }
+ }
+
+ public updateDefaultQueueConfig(config: QueueConfig): void {
+ if (!_.isEqual(config, this.fallbackQueueConfig)) {
+ this.fallbackQueueConfig = cloneDeep(config);
+ // flush the queues to get the new configuration asap
+ this.flush$.next();
+ }
+ }
+
+ // internal methods
+ private queue$(
+ upstream$: rx.Observable,
+ channel: TelemetryChannel,
+ send: (channel: TelemetryChannel, events: string[]) => Promise
+ ): rx.Observable {
+ let inflightEventsCounter: number = 0;
+ const inflightEvents$: rx.Subject = new rx.Subject();
+
+ inflightEvents$.subscribe((value) => (inflightEventsCounter += value));
+
+ return upstream$.pipe(
+ // only take events for the configured channel
+ rx.filter((event) => event.channel === channel),
+
+ rx.switchMap((event) => {
+ if (inflightEventsCounter < this.getConfigFor(channel).inflightEventsThreshold) {
+ return rx.of(event);
+ }
+ this.logger.l(
+ `>> Dropping event ${event} (channel: ${channel}, inflightEventsCounter: ${inflightEventsCounter})`
+ );
+ this.senderUtils?.incrementCounter(TelemetryCounter.DOCS_DROPPED, 1, channel);
+
+ return rx.EMPTY;
+ }),
+
+ // update inflight events counter
+ rx.tap(() => {
+ inflightEvents$.next(1);
+ }),
+
+ // buffer events for a while or after a flush$ event is sent (see updateConfig)
+ rx.bufferWhen(() =>
+ rx.merge(rx.interval(this.getConfigFor(channel).bufferTimeSpanMillis), this.flush$)
+ ),
+
+ // exclude empty buffers
+ rx.filter((n: Event[]) => n.length > 0),
+
+ // serialize the payloads
+ rx.map((events) => events.map((e) => JSON.stringify(e.payload))),
+
+ // chunk by size
+ rx.map((values) =>
+ collections.chunkedBy(
+ values,
+ this.getConfigFor(channel).maxPayloadSizeBytes,
+ (payload) => payload.length
+ )
+ ),
+ rx.concatAll(),
+
+ // send events to the telemetry server
+ rx.concatMap((payloads: string[]) =>
+ retryOnError$(
+ this.getRetryConfig().retryCount,
+ this.getRetryConfig().retryDelayMillis,
+ async () => send(channel, payloads)
+ )
+ ),
+
+ // update inflight events counter
+ rx.tap((result: Result) => {
+ inflightEvents$.next(-result.events);
+ })
+ ) as rx.Observable;
+ }
+
+ private async sendEvents(channel: TelemetryChannel, events: string[]): Promise {
+ this.logger.l(`Sending ${events.length} telemetry events to channel "${channel}"`);
+
+ try {
+ const senderMetadata = await this.getSenderMetadata(channel);
+
+ const isTelemetryOptedIn = await senderMetadata.isTelemetryOptedIn();
+ if (!isTelemetryOptedIn) {
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.TELEMETRY_OPTED_OUT,
+ events.length,
+ channel
+ );
+
+ this.logger.l(`Unable to send events to channel "${channel}": Telemetry is not opted-in.`);
+ throw newFailure('Telemetry is not opted-in', channel, events.length);
+ }
+
+ const isElasticTelemetryReachable = await senderMetadata.isTelemetryServicesReachable();
+ if (!isElasticTelemetryReachable) {
+ this.logger.l('Telemetry Services are not reachable.');
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.TELEMETRY_NOT_REACHABLE,
+ events.length,
+ channel
+ );
+
+ this.logger.l(
+ `Unable to send events to channel "${channel}": Telemetry services are not reachable.`
+ );
+ throw newFailure('Telemetry Services are not reachable', channel, events.length);
+ }
+
+ const body = events.join('\n');
+
+ const telemetryUrl = senderMetadata.telemetryUrl;
+
+ return await axios
+ .post(telemetryUrl, body, {
+ headers: {
+ ...senderMetadata.telemetryRequestHeaders(),
+ 'X-Telemetry-Sender': 'async',
+ },
+ timeout: 10000,
+ })
+ .then((r) => {
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.HTTP_STATUS,
+ events.length,
+ channel,
+ r.status.toString()
+ );
+
+ if (r.status < 400) {
+ return { events: events.length, channel };
+ } else {
+ this.logger.l(`Unexpected response, got ${r.status}`);
+ throw newFailure(`Got ${r.status}`, channel, events.length);
+ }
+ })
+ .catch((err) => {
+ this.senderUtils?.incrementCounter(
+ TelemetryCounter.RUNTIME_ERROR,
+ events.length,
+ channel
+ );
+
+ this.logger.l(`Runtime error: ${err.message}`);
+ throw newFailure(`Error posting events: ${err}`, channel, events.length);
+ });
+ } catch (err: unknown) {
+ if (isFailure(err)) {
+ throw err;
+ } else {
+ this.senderUtils?.incrementCounter(TelemetryCounter.FATAL_ERROR, events.length, channel);
+ throw newFailure(`Unexpected error posting events: ${err}`, channel, events.length);
+ }
+ }
+ }
+
+ private getQueues(): Map {
+ if (this.queues === undefined) throw new Error('Service not initialized');
+ return this.queues;
+ }
+
+ private getConfigFor(channel: TelemetryChannel): QueueConfig {
+ const config = this.queues?.get(channel) ?? this.fallbackQueueConfig;
+ if (config === undefined) throw new Error(`No queue config found for channel "${channel}"`);
+ return config;
+ }
+
+ private async getSenderMetadata(channel: TelemetryChannel) {
+ if (this.senderUtils === undefined) throw new Error('Service not initialized');
+ return this.senderUtils?.fetchSenderMetadata(channel);
+ }
+
+ private getRetryConfig(): RetryConfig {
+ if (this.retryConfig === undefined) throw new Error('Service not initialized');
+ return this.retryConfig;
+ }
+
+ private ensureStatus(...expected: ServiceStatus[]): void {
+ if (!expected.includes(this.status)) {
+ throw new Error(`${this.status}: invalid status. Expected [${expected.join(',')}]`);
+ }
+ }
+
+ private updateStatus(newStatus: ServiceStatus): void {
+ this.status = newStatus;
+ }
+}
+
+function newFailure(message: string, channel: TelemetryChannel, events: number): Failure {
+ const failure: Failure = { name: 'Failure', message, channel, events };
+ return failure;
+}
+
+function isFailure(result: unknown): result is Failure {
+ return (
+ result !== null &&
+ typeof result === 'object' &&
+ 'name' in result &&
+ 'message' in result &&
+ 'events' in result &&
+ 'channel' in result
+ );
+}
+
+interface Event {
+ channel: TelemetryChannel;
+ payload: unknown;
+}
+
+type Result = Success | Failure;
+
+interface Success {
+ events: number;
+ channel: TelemetryChannel;
+}
+
+interface Failure extends Error {
+ events: number;
+ channel: TelemetryChannel;
+}
+
+export enum ServiceStatus {
+ CREATED = 'CREATED',
+ CONFIGURED = 'CONFIGURED',
+ STARTED = 'STARTED',
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.types.ts
new file mode 100644
index 0000000000000..249493cfdbfc8
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/async_sender.types.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import type { TelemetryPluginSetup, TelemetryPluginStart } from '@kbn/telemetry-plugin/server';
+import { type IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter';
+import { type TelemetryChannel } from './types';
+import type { ITelemetryReceiver } from './receiver';
+
+/**
+ * This service sends telemetry events to the telemetry service asynchronously. Managing
+ * different configurations per channel and changing them dynamically without restarting
+ * the service is possible.
+ */
+export interface IAsyncTelemetryEventsSender {
+ setup: (
+ retryConfig: RetryConfig,
+ fallbackQueueConfig: QueueConfig,
+ telemetryReceiver: ITelemetryReceiver,
+ telemetrySetup?: TelemetryPluginSetup,
+ telemetryUsageCounter?: IUsageCounter
+ ) => void;
+ start: (telemetryStart?: TelemetryPluginStart) => void;
+ stop: () => Promise;
+ send: (channel: TelemetryChannel, events: unknown[]) => void;
+ simulateSend: (channel: TelemetryChannel, events: unknown[]) => string[];
+ updateQueueConfig: (channel: TelemetryChannel, config: QueueConfig) => void;
+ updateDefaultQueueConfig: (config: QueueConfig) => void;
+}
+
+/**
+ * Values used to configure each queue.
+ *
+ * @property bufferTimeSpanMillis - The time span to buffer events before sending them.
+ * @property inflightEventsThreshold - The maximum number of events that can be buffered at the same time, waiting to be sent.
+ * @property maxPayloadSizeBytes - The maximum size of the payload sent to the server, in bytes.
+ */
+export interface QueueConfig {
+ bufferTimeSpanMillis: number;
+ inflightEventsThreshold: number;
+ maxPayloadSizeBytes: number;
+}
+
+/**
+ * Values used to configure the retry logic.
+ *
+ * @property retryCount - The number of times to retry before propagate the error.
+ * @property retryDelayMillis - The delay between retries, in milliseconds.
+ */
+export interface RetryConfig {
+ retryCount: number;
+ retryDelayMillis: number;
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.test.ts
new file mode 100644
index 0000000000000..3d67d6cd22b17
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.test.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { chunked, chunkedBy } from './collections_helpers';
+
+describe('telemetry.utils.chunked', () => {
+ it('should chunk simple case', async () => {
+ const input = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+ const output = chunked(input, 3);
+ expect(output).toEqual([
+ [1, 2, 3],
+ [4, 5, 6],
+ [7, 8, 9],
+ ]);
+ });
+
+ it('should chunk with remainder', async () => {
+ const input = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+ const output = chunked(input, 4);
+ expect(output).toEqual([[1, 2, 3, 4], [5, 6, 7, 8], [9]]);
+ });
+
+ it('should chunk with empty list', async () => {
+ const input: unknown[] = [];
+ const output = chunked(input, 4);
+ expect(output).toEqual([]);
+ });
+
+ it('should chunk with single element', async () => {
+ const input = [1];
+ const output = chunked(input, 4);
+ expect(output).toEqual([[1]]);
+ });
+
+ it('should chunk with single element and chunk size 1', async () => {
+ const input = [1];
+ const output = chunked(input, 1);
+ expect(output).toEqual([[1]]);
+ });
+
+ it('should chunk arrays smaller than the chunk size', async () => {
+ const input = [1];
+ const output = chunked(input, 10);
+ expect(output).toEqual([[1]]);
+ });
+});
+
+describe('telemetry.utils.chunkedBy', () => {
+ it('should chunk simple case', async () => {
+ const input = ['aa', 'b', 'ccc', 'ddd'];
+ const output = chunkedBy(input, 3, (v) => v.length);
+ expect(output).toEqual([['aa', 'b'], ['ccc'], ['ddd']]);
+ });
+
+ it('should chunk with remainder', async () => {
+ const input = ['aaa', 'b'];
+ const output = chunkedBy(input, 3, (v) => v.length);
+ expect(output).toEqual([['aaa'], ['b']]);
+ });
+
+ it('should chunk with empty list', async () => {
+ const input: string[] = [];
+ const output = chunkedBy(input, 3, (v) => v.length);
+ expect(output).toEqual([]);
+ });
+
+ it('should chunk with single element smaller than max weight', async () => {
+ const input = ['aa'];
+ const output = chunkedBy(input, 3, (v) => v.length);
+ expect(output).toEqual([['aa']]);
+ });
+
+ it('should chunk with single element bigger than max weight', async () => {
+ const input = ['aaaa'];
+ const output = chunkedBy(input, 3, (v) => v.length);
+ expect(output).toEqual([['aaaa']]);
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.ts
new file mode 100644
index 0000000000000..a104ea1d55bf8
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/collections_helpers.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/**
+ * Splits the list into a list of lists each not exceeding the given size.
+ * The order of the elements is preserved.
+ *
+ * @param list - The list to split
+ * @param size - The maximum size of each chunk
+ *
+ * @returns - The list of chunks
+ */
+export const chunked = function (list: T[], size: number): T[][] {
+ return chunkedBy(list, size, () => 1);
+};
+
+/**
+ * Splits the list into a list of lists each not exceeding the given size.
+ * The size of each element is determined by the weight function, that is called
+ * for each element in the list.
+ * The sum of the weights of the elements in each chunk will not exceed the given size.
+ * The order of the elements is preserved.
+ *
+ * @param list - The list to split
+ * @param size - The maximum size of each chunk
+ * @param weight - The function that determines the weight of each element
+ * @returns - The list of chunks
+ */
+export const chunkedBy = function (list: T[], size: number, weight: (v: T) => number): T[][] {
+ function chunk(acc: Chunked, value: T): Chunked {
+ const currentWeight = weight(value);
+ if (acc.weight + currentWeight <= size) {
+ acc.current.push(value);
+ acc.weight += currentWeight;
+ } else {
+ acc.chunks.push(acc.current);
+ acc.current = [value];
+ acc.weight = currentWeight;
+ }
+ return acc;
+ }
+
+ return list.reduce(chunk, new Chunked()).flush();
+};
+
+/**
+ * Helper class used internally.
+ */
+class Chunked {
+ public weight: number = 0;
+
+ constructor(public chunks: T[][] = [], public current: T[] = []) {}
+
+ public flush(): T[][] {
+ if (this.current.length !== 0) {
+ this.chunks.push(this.current);
+ this.current = [];
+ }
+ return this.chunks.filter((chunk) => chunk.length > 0);
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/configuration.ts b/x-pack/plugins/security_solution/server/lib/telemetry/configuration.ts
index f1957705dcfd8..d5c321ec8bc13 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/configuration.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/configuration.ts
@@ -5,17 +5,25 @@
* 2.0.
*/
-class TelemetryConfiguration {
+import type { TelemetrySenderChannelConfiguration } from './types';
+
+class TelemetryConfigurationDTO {
private readonly DEFAULT_TELEMETRY_MAX_BUFFER_SIZE = 100;
private readonly DEFAULT_MAX_SECURITY_LIST_TELEMETRY_BATCH = 100;
private readonly DEFAULT_MAX_ENDPOINT_TELEMETRY_BATCH = 300;
private readonly DEFAULT_MAX_DETECTION_RULE_TELEMETRY_BATCH = 1_000;
private readonly DEFAULT_MAX_DETECTION_ALERTS_BATCH = 50;
+ private readonly DEFAULT_ASYNC_SENDER = false;
+ private readonly DEFAULT_SENDER_CHANNELS = {};
private _telemetry_max_buffer_size = this.DEFAULT_TELEMETRY_MAX_BUFFER_SIZE;
private _max_security_list_telemetry_batch = this.DEFAULT_MAX_SECURITY_LIST_TELEMETRY_BATCH;
private _max_endpoint_telemetry_batch = this.DEFAULT_MAX_ENDPOINT_TELEMETRY_BATCH;
private _max_detection_rule_telemetry_batch = this.DEFAULT_MAX_DETECTION_RULE_TELEMETRY_BATCH;
private _max_detection_alerts_batch = this.DEFAULT_MAX_DETECTION_ALERTS_BATCH;
+ private _use_async_sender = this.DEFAULT_ASYNC_SENDER;
+ private _sender_channels: {
+ [key: string]: TelemetrySenderChannelConfiguration;
+ } = this.DEFAULT_SENDER_CHANNELS;
public get telemetry_max_buffer_size(): number {
return this._telemetry_max_buffer_size;
@@ -57,6 +65,22 @@ class TelemetryConfiguration {
this._max_detection_alerts_batch = num;
}
+ public get use_async_sender(): boolean {
+ return this._use_async_sender;
+ }
+
+ public set use_async_sender(num: boolean) {
+ this._use_async_sender = num;
+ }
+
+ public set sender_channels(config: { [key: string]: TelemetrySenderChannelConfiguration }) {
+ this._sender_channels = config;
+ }
+
+ public get sender_channels(): { [key: string]: TelemetrySenderChannelConfiguration } {
+ return this._sender_channels;
+ }
+
public resetAllToDefault() {
this._telemetry_max_buffer_size = this.DEFAULT_TELEMETRY_MAX_BUFFER_SIZE;
this._max_security_list_telemetry_batch = this.DEFAULT_MAX_SECURITY_LIST_TELEMETRY_BATCH;
@@ -66,4 +90,4 @@ class TelemetryConfiguration {
}
}
-export const telemetryConfiguration = new TelemetryConfiguration();
+export const telemetryConfiguration = new TelemetryConfigurationDTO();
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts
index e01eb21cbe68c..8d19d653cf5ed 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts
@@ -24,7 +24,6 @@ import {
formatValueListMetaData,
tlog,
setIsElasticCloudDeployment,
- createTaskMetric,
processK8sUsernames,
} from './helpers';
import type { ESClusterInfo, ESLicense, ExceptionListItem } from './types';
@@ -944,47 +943,6 @@ describe('test tlog', () => {
});
});
-// FLAKY: https://github.com/elastic/kibana/issues/141356
-describe.skip('test create task metrics', () => {
- test('can succeed when all parameters are given', async () => {
- const stubTaskName = 'test';
- const stubPassed = true;
- const stubStartTime = Date.now();
- await new Promise((r) => setTimeout(r, 11));
- const response = createTaskMetric(stubTaskName, stubPassed, stubStartTime);
- const {
- time_executed_in_ms: timeExecutedInMs,
- start_time: startTime,
- end_time: endTime,
- ...rest
- } = response;
- expect(timeExecutedInMs).toBeGreaterThan(10);
- expect(rest).toEqual({
- name: 'test',
- passed: true,
- });
- });
-
- test('can succeed when error given', async () => {
- const stubTaskName = 'test';
- const stubPassed = false;
- const stubStartTime = Date.now();
- const errorMessage = 'failed';
- const response = createTaskMetric(stubTaskName, stubPassed, stubStartTime, errorMessage);
- const {
- time_executed_in_ms: timeExecutedInMs,
- start_time: startTime,
- end_time: endTime,
- ...rest
- } = response;
- expect(rest).toEqual({
- name: 'test',
- passed: false,
- error_message: 'failed',
- });
- });
-});
-
describe('Pii is removed from a kubernetes prebuilt rule alert', () => {
test('a document without the sensitive values is ignored', async () => {
const clusterUuid = '7c5f1d31-ce87-4090-8dbf-decaac0261ca';
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts
index 32ac1cf7f4dd0..de49dad3b1c26 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts
@@ -22,7 +22,6 @@ import type {
ExceptionListItem,
ExtraInfo,
ListTemplate,
- TaskMetric,
TelemetryEvent,
TimeFrame,
TimelineResult,
@@ -294,20 +293,18 @@ export const tlog = (logger: Logger, message: string) => {
}
};
-export const createTaskMetric = (
- name: string,
- passed: boolean,
- startTime: number,
- errorMessage?: string
-): TaskMetric => {
- const endTime = Date.now();
+export interface TelemetryLogger extends Logger {
+ l: (message: string) => void;
+}
+
+export const newTelemetryLogger = (logger: Logger): TelemetryLogger => {
return {
- name,
- passed,
- time_executed_in_ms: endTime - startTime,
- start_time: startTime,
- end_time: endTime,
- error_message: errorMessage,
+ ...logger,
+ error: logger.error,
+ info: logger.info,
+ debug: logger.debug,
+ warn: logger.warn,
+ l: (message: string) => tlog(logger, message),
};
};
@@ -366,14 +363,12 @@ export const ranges = (
};
export class TelemetryTimelineFetcher {
- startTime: number;
private receiver: ITelemetryReceiver;
private extraInfo: Promise;
private timeFrame: TimeFrame;
constructor(receiver: ITelemetryReceiver) {
this.receiver = receiver;
- this.startTime = Date.now();
this.extraInfo = this.lookupExtraInfo();
this.timeFrame = this.calculateTimeFrame();
}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/preview_sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/preview_sender.ts
index a26bbef5ee9d7..fc195098787dd 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/preview_sender.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/preview_sender.ts
@@ -16,9 +16,10 @@ import type {
TaskManagerStartContract,
} from '@kbn/task-manager-plugin/server';
import type { ITelemetryEventsSender } from './sender';
-import type { TelemetryEvent } from './types';
+import { TelemetryChannel, type TelemetryEvent } from './types';
import type { ITelemetryReceiver } from './receiver';
import { tlog } from './helpers';
+import type { QueueConfig } from './async_sender.types';
/**
* Preview telemetry events sender for the telemetry route.
@@ -28,7 +29,9 @@ export class PreviewTelemetryEventsSender implements ITelemetryEventsSender {
/** Inner composite telemetry events sender */
private composite: ITelemetryEventsSender;
- /** Axios local instance */
+ /**
+ * Axios local instance
+ * @deprecated `IAsyncTelemetryEventsSender` has a dedicated method for preview. */
private axiosInstance = axios.create();
/** Last sent message */
@@ -115,9 +118,9 @@ export class PreviewTelemetryEventsSender implements ITelemetryEventsSender {
}
public async queueTelemetryEvents(events: TelemetryEvent[]) {
- const result = this.composite.queueTelemetryEvents(events);
- await this.composite.sendIfDue(this.axiosInstance);
- return result;
+ const result = this.composite.simulateSendAsync(TelemetryChannel.ENDPOINT_ALERTS, events);
+
+ this.sentMessages = [...this.sentMessages, ...result];
}
public getTelemetryUsageCluster(): UsageCounter | undefined {
@@ -141,11 +144,34 @@ export class PreviewTelemetryEventsSender implements ITelemetryEventsSender {
}
public async sendOnDemand(channel: string, toSend: unknown[]) {
- const result = await this.composite.sendOnDemand(channel, toSend, this.axiosInstance);
- return result;
+ const ch = Object.values(TelemetryChannel).find((c) => c === channel);
+ if (ch === undefined) {
+ throw new Error(`Channel ${channel} not found`);
+ }
+ const result = this.composite.simulateSendAsync(ch, toSend);
+
+ this.sentMessages = [...this.sentMessages, ...result];
+
+ return Promise.resolve();
}
public getV3UrlFromV2(v2url: string, channel: string): string {
return this.composite.getV3UrlFromV2(v2url, channel);
}
+
+ public sendAsync(channel: TelemetryChannel, events: unknown[]): void {
+ this.composite.sendAsync(channel, events);
+ }
+
+ public simulateSendAsync(channel: TelemetryChannel, events: unknown[]): string[] {
+ return this.composite.simulateSendAsync(channel, events);
+ }
+
+ public updateQueueConfig(channel: TelemetryChannel, config: QueueConfig): void {
+ this.composite.updateQueueConfig(channel, config);
+ }
+
+ public updateDefaultQueueConfig(config: QueueConfig): void {
+ this.composite.updateDefaultQueueConfig(config);
+ }
}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/preview_task_metrics.ts b/x-pack/plugins/security_solution/server/lib/telemetry/preview_task_metrics.ts
new file mode 100644
index 0000000000000..96b23e1ebe087
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/preview_task_metrics.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { Logger } from '@kbn/core/server';
+import { TelemetryChannel } from './types';
+import type { ITaskMetricsService, TaskMetric, Trace } from './task_metrics.types';
+import { TaskMetricsService } from './task_metrics';
+import type { ITelemetryEventsSender } from './sender';
+
+/**
+ * Preview telemetry events sender for the telemetry route.
+ * @see telemetry_detection_rules_preview_route
+ */
+export class PreviewTaskMetricsService implements ITaskMetricsService {
+ /** Last sent message */
+ private sentMessages: string[] = [];
+
+ /** Logger for this class */
+ private readonly logger: Logger;
+
+ private readonly composite: TaskMetricsService;
+
+ constructor(logger: Logger, private readonly sender: ITelemetryEventsSender) {
+ this.logger = logger;
+ this.composite = new TaskMetricsService(logger, sender);
+ }
+
+ public getSentMessages() {
+ return this.sentMessages;
+ }
+
+ public start(name: string): Trace {
+ this.logger.error('Simulating TaskMetricsService.start');
+ return this.composite.start(name);
+ }
+
+ public createTaskMetric(trace: Trace, error?: Error): TaskMetric {
+ this.logger.error('Simulating TaskMetricsService.createTaskMetric');
+ return this.composite.createTaskMetric(trace, error);
+ }
+
+ public async end(trace: Trace, error?: Error): Promise {
+ this.logger.error('Simulating TaskMetricsService.end');
+ const metric = this.composite.createTaskMetric(trace, error);
+ const result = this.sender.simulateSendAsync(TelemetryChannel.TASK_METRICS, [metric]);
+ this.sentMessages = [...this.sentMessages, ...result];
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.test.ts
new file mode 100644
index 0000000000000..cc95172b6bcc6
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.test.ts
@@ -0,0 +1,166 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CachedSubject, retryOnError$ } from './rxjs_helpers';
+import * as rx from 'rxjs';
+
+describe('telemetry.helpers.rxjs.retryOnError$', () => {
+ const retries = 5;
+ const delay = 100;
+
+ beforeEach(() => {
+ jest.useFakeTimers({ advanceTimers: true });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should not retry if the computation does not fail', async () => {
+ const callback = jest.fn(() => 'success');
+
+ retryOnError$(1, 100, callback).subscribe({
+ next: (response) => {
+ expect(response).toBe('success');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(delay * 1.1);
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should retry runtime errors until the computation works', async () => {
+ const callback = jest
+ .fn()
+ .mockImplementationOnce(() => {
+ throw new Error('first');
+ })
+ .mockImplementationOnce(() => {
+ throw new Error('second');
+ })
+ .mockImplementationOnce(() => 'success');
+
+ retryOnError$(retries, delay, callback).subscribe({
+ next: (response) => {
+ expect(response).toBe('success');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(delay * 2 * 1.1);
+
+ expect(callback).toHaveBeenCalledTimes(3);
+ });
+
+ it('should exhaust retries with runtime errors and emit an error', async () => {
+ const callback = jest.fn().mockImplementation(() => {
+ throw new Error('boom!');
+ });
+
+ retryOnError$(retries, delay, callback).subscribe({
+ next: (response) => {
+ expect(response.message).toBe('boom!');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(retries * delay * 1.1);
+
+ expect(callback).toHaveBeenCalledTimes(retries + 1);
+ });
+
+ it('should exhaust retries with rejected promises and emit an error', async () => {
+ const callback = jest.fn().mockImplementation(() => Promise.reject(new Error('boom!')));
+
+ retryOnError$(retries, delay, callback).subscribe({
+ next: (response) => {
+ expect(response.message).toBe('boom!');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(retries * delay * 1.1);
+
+ expect(callback).toHaveBeenCalledTimes(retries + 1);
+ });
+
+ it('should retry rejected promises until the computation works', async () => {
+ const callback = jest
+ .fn()
+ .mockImplementationOnce(() => Promise.reject(new Error('first')))
+ .mockImplementationOnce(() => Promise.reject(new Error('second')))
+ .mockImplementationOnce(() => Promise.resolve('success'));
+
+ retryOnError$(retries, delay, callback).subscribe({
+ next: (response) => {
+ expect(response).toBe('success');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(delay * 3 * 1.1);
+
+ expect(callback).toHaveBeenCalledTimes(3);
+ });
+
+ it('should retry rejected promises and runtime errors until the computation works', async () => {
+ const callback = jest
+ .fn()
+ .mockImplementationOnce(() => Promise.reject(new Error('first')))
+ .mockImplementationOnce(() => {
+ throw new Error('second');
+ })
+ .mockImplementationOnce(() => Promise.resolve('success'));
+
+ retryOnError$(retries, delay, callback).subscribe({
+ next: (response) => {
+ expect(response).toBe('success');
+ },
+ error: (err) => {
+ throw new Error(`Unexpected error: ${err}`);
+ },
+ });
+
+ await jest.advanceTimersByTimeAsync(delay * 3 * 1.1);
+
+ expect(callback).toHaveBeenCalledTimes(3);
+ });
+});
+
+describe('CachedSubject', () => {
+ it('should cache values until is flushed', () => {
+ const elements = [1, 2, 3, 4, 5];
+ const subject$ = new rx.Subject();
+ const cache = new CachedSubject(subject$);
+ const values: number[] = [];
+
+ elements.forEach((v) => subject$.next(v));
+
+ subject$.subscribe({
+ next: (v) => values.push(v),
+ });
+
+ expect(values).toHaveLength(0);
+
+ cache.stop();
+ expect(values).toHaveLength(0);
+
+ cache.flush();
+ expect(values).toEqual(elements);
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.ts
new file mode 100644
index 0000000000000..ce8df3df40b45
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/rxjs_helpers.ts
@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import * as rx from 'rxjs';
+
+/**
+ * This utility class stores the values sent to a reactive flow while its state
+ * is `started`. Once it's stopped (after calling `flush()`) it will send all
+ * the cached values to the configured `subject$`.
+ */
+export class CachedSubject {
+ public readonly flushCache$ = new rx.Subject();
+ public readonly stopCaching$ = new rx.Subject();
+
+ constructor(subject$: rx.Subject) {
+ this.setup(subject$);
+ }
+
+ public stop(): void {
+ this.stopCaching$.next();
+ }
+
+ public flush(): void {
+ this.flushCache$.next();
+ }
+
+ private setup(subject$: rx.Subject): void {
+ // Cache the incoming events that are sent during the timeframe between
+ // `service.setup()` and `service.start()`, otherwise, they would be lost
+ const cache$ = new rx.ReplaySubject();
+ const storingCache$ = new rx.BehaviorSubject(true);
+
+ // 1. sends incoming values to the cache$, works only while
+ // `storingCache$` is set to true
+ storingCache$
+ .pipe(
+ rx.distinctUntilChanged(),
+ rx.switchMap((isCaching) => (isCaching ? subject$ : rx.EMPTY)),
+ rx.takeUntil(rx.merge(this.stopCaching$))
+ )
+ .subscribe((data) => {
+ cache$.next(data);
+ });
+
+ // 2. when flushCache is triggered, stop caching values and send the cached
+ // ones to the real flow (i.e. `subject$`).
+ this.flushCache$.pipe(rx.exhaustMap(() => cache$)).subscribe((data) => {
+ storingCache$.next(false);
+ subject$.next(data);
+ });
+ }
+}
+
+/**
+ * Executes the given `body()` function wrappig it in a retry logic by using
+ * the rxjs `retry` operator.
+ *
+ * @param retryCount the number of times to retry the `body()` function
+ * @param retryDelayMillis the delay between each retry
+ * @param body the function to execute
+ * @returns an observable that emits either the result returned by the `body()`
+ * function or the latest caught error after exhausting the retryCount.
+ */
+export function retryOnError$(
+ retryCount: number,
+ retryDelayMillis: number,
+ body: () => R
+): rx.Observable {
+ return rx
+ .defer(async () => body())
+ .pipe(
+ rx.retry({
+ count: retryCount,
+ delay: retryDelayMillis,
+ }),
+ rx.catchError((error) => {
+ return rx.of(error);
+ })
+ );
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts
index 041d9b67720d9..7addae79f148d 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts
@@ -22,10 +22,12 @@ import type { ITelemetryReceiver } from './receiver';
import { copyAllowlistedFields, filterList } from './filterlists';
import { createTelemetryTaskConfigs } from './tasks';
import { createUsageCounterLabel, tlog } from './helpers';
-import type { TelemetryEvent } from './types';
+import type { TelemetryChannel, TelemetryEvent } from './types';
import type { SecurityTelemetryTaskConfig } from './task';
import { SecurityTelemetryTask } from './task';
import { telemetryConfiguration } from './configuration';
+import type { IAsyncTelemetryEventsSender, QueueConfig } from './async_sender.types';
+import { TaskMetricsService } from './task_metrics';
const usageLabelPrefix: string[] = ['security_telemetry', 'sender'];
@@ -34,7 +36,8 @@ export interface ITelemetryEventsSender {
telemetryReceiver: ITelemetryReceiver,
telemetrySetup?: TelemetryPluginSetup,
taskManager?: TaskManagerSetupContract,
- telemetryUsageCounter?: UsageCounter
+ telemetryUsageCounter?: UsageCounter,
+ asyncTelemetrySender?: IAsyncTelemetryEventsSender
): void;
getTelemetryUsageCluster(): UsageCounter | undefined;
@@ -47,6 +50,9 @@ export interface ITelemetryEventsSender {
): void;
stop(): void;
+ /**
+ * @deprecated Use `sendAsync` instead.
+ */
queueTelemetryEvents(events: TelemetryEvent[]): void;
isTelemetryOptedIn(): Promise;
isTelemetryServicesReachable(): Promise;
@@ -54,6 +60,32 @@ export interface ITelemetryEventsSender {
processEvents(events: TelemetryEvent[]): TelemetryEvent[];
sendOnDemand(channel: string, toSend: unknown[], axiosInstance?: AxiosInstance): Promise;
getV3UrlFromV2(v2url: string, channel: string): string;
+
+ // As a transition to the new sender, `IAsyncTelemetryEventsSender`, we wrap
+ // its "public API" here to expose a single Sender interface to the rest
+ // of the code. The `queueTelemetryEvents` is deprecated in favor of
+ // `sendAsync`.
+
+ /**
+ * Sends events to a given telemetry channel asynchronously.
+ */
+ sendAsync: (channel: TelemetryChannel, events: unknown[]) => void;
+
+ /**
+ * Simulates sending events to a given telemetry channel asynchronously
+ * and returns the request that should be sent to the server
+ */
+ simulateSendAsync: (channel: TelemetryChannel, events: unknown[]) => string[];
+
+ /**
+ * Updates the queue configuration for a given channel.
+ */
+ updateQueueConfig: (channel: TelemetryChannel, config: QueueConfig) => void;
+
+ /**
+ * Updates the default queue configuration.
+ */
+ updateDefaultQueueConfig: (config: QueueConfig) => void;
}
export class TelemetryEventsSender implements ITelemetryEventsSender {
@@ -75,6 +107,8 @@ export class TelemetryEventsSender implements ITelemetryEventsSender {
private telemetryUsageCounter?: UsageCounter;
private telemetryTasks?: SecurityTelemetryTask[];
+ private asyncTelemetrySender?: IAsyncTelemetryEventsSender;
+
constructor(logger: Logger) {
this.logger = logger.get('telemetry_events');
}
@@ -83,19 +117,28 @@ export class TelemetryEventsSender implements ITelemetryEventsSender {
telemetryReceiver: ITelemetryReceiver,
telemetrySetup?: TelemetryPluginSetup,
taskManager?: TaskManagerSetupContract,
- telemetryUsageCounter?: UsageCounter
+ telemetryUsageCounter?: UsageCounter,
+ asyncTelemetrySender?: IAsyncTelemetryEventsSender
) {
this.telemetrySetup = telemetrySetup;
this.telemetryUsageCounter = telemetryUsageCounter;
if (taskManager) {
+ const taskMetricsService = new TaskMetricsService(this.logger, this);
this.telemetryTasks = createTelemetryTaskConfigs().map(
(config: SecurityTelemetryTaskConfig) => {
- const task = new SecurityTelemetryTask(config, this.logger, this, telemetryReceiver);
+ const task = new SecurityTelemetryTask(
+ config,
+ this.logger,
+ this,
+ telemetryReceiver,
+ taskMetricsService
+ );
task.register(taskManager);
return task;
}
);
}
+ this.asyncTelemetrySender = asyncTelemetrySender;
}
public getTelemetryUsageCluster(): UsageCounter | undefined {
@@ -365,6 +408,29 @@ export class TelemetryEventsSender implements ITelemetryEventsSender {
return url.toString();
}
+ public sendAsync(channel: TelemetryChannel, events: unknown[]): void {
+ this.getAsyncTelemetrySender().send(channel, events);
+ }
+
+ public simulateSendAsync(channel: TelemetryChannel, events: unknown[]): string[] {
+ return this.getAsyncTelemetrySender().simulateSend(channel, events);
+ }
+
+ public updateQueueConfig(channel: TelemetryChannel, config: QueueConfig): void {
+ this.getAsyncTelemetrySender().updateQueueConfig(channel, config);
+ }
+
+ public updateDefaultQueueConfig(config: QueueConfig): void {
+ this.getAsyncTelemetrySender().updateDefaultQueueConfig(config);
+ }
+
+ private getAsyncTelemetrySender(): IAsyncTelemetryEventsSender {
+ if (!this.asyncTelemetrySender) {
+ throw new Error('Telemetry Sender V2 not initialized');
+ }
+ return this.asyncTelemetrySender;
+ }
+
private async fetchTelemetryPingUrl(): Promise {
const telemetryUrl = await this.telemetrySetup?.getTelemetryUrl();
if (!telemetryUrl) {
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender_helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender_helpers.ts
new file mode 100644
index 0000000000000..c7bb9d74ce1c5
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender_helpers.ts
@@ -0,0 +1,125 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import axios from 'axios';
+
+import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server';
+import type { RawAxiosRequestHeaders } from 'axios';
+import { type IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter';
+import type { ITelemetryReceiver } from './receiver';
+import type { ESClusterInfo, ESLicense, TelemetryChannel, TelemetryCounter } from './types';
+import { createUsageCounterLabel } from './helpers';
+
+export interface SenderMetadata {
+ telemetryUrl: string;
+ licenseInfo: ESLicense | undefined;
+ clusterInfo: ESClusterInfo | undefined;
+ telemetryRequestHeaders: () => RawAxiosRequestHeaders;
+ isTelemetryOptedIn(): Promise;
+ isTelemetryServicesReachable(): Promise;
+}
+
+export class SenderUtils {
+ private readonly usageLabelPrefix: string[] = ['security_telemetry', 'sender'];
+
+ constructor(
+ private readonly telemetrySetup?: TelemetryPluginSetup,
+ private readonly telemetryStart?: TelemetryPluginStart,
+ private readonly receiver?: ITelemetryReceiver,
+ private readonly telemetryUsageCounter?: IUsageCounter
+ ) {}
+
+ public async fetchSenderMetadata(channel: TelemetryChannel): Promise {
+ const [telemetryUrl, licenseInfo] = await Promise.all([
+ this.fetchTelemetryUrl(channel),
+ this.receiver?.fetchLicenseInfo(),
+ ]);
+ const clusterInfo = this.receiver?.getClusterInfo();
+
+ const isTelemetryOptedIn = async () => (await this.telemetryStart?.getIsOptedIn()) === true;
+
+ return {
+ telemetryUrl,
+ licenseInfo,
+ clusterInfo,
+ telemetryRequestHeaders: () => {
+ const clusterName = clusterInfo?.cluster_name;
+ const clusterUuid = clusterInfo?.cluster_uuid;
+ const clusterVersionNumber = clusterInfo?.version?.number;
+ const licenseId = licenseInfo?.uid;
+
+ return {
+ 'Content-Type': 'application/x-ndjson',
+ ...(clusterName ? { 'X-Elastic-Cluster-Name': clusterName } : undefined),
+ ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined),
+ 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '8.0.0',
+ ...(licenseId ? { 'X-Elastic-License-ID': licenseId } : {}),
+ };
+ },
+ isTelemetryOptedIn,
+ isTelemetryServicesReachable: async () => {
+ const isOptedIn = await isTelemetryOptedIn();
+ if (!isOptedIn) {
+ return false;
+ }
+
+ try {
+ const telemetryPingUrl = await this.fetchTelemetryPingUrl();
+ const resp = await axios.get(telemetryPingUrl, { timeout: 3000 });
+ if (resp.status === 200) {
+ return true;
+ }
+
+ return false;
+ } catch (_) {
+ return false;
+ }
+ },
+ };
+ }
+
+ public incrementCounter(
+ counterType: TelemetryCounter,
+ incrementBy: number,
+ ...tags: string[]
+ ): void {
+ const counterName = createUsageCounterLabel([...this.usageLabelPrefix, ...tags]);
+ this.telemetryUsageCounter?.incrementCounter({ counterName, counterType, incrementBy });
+ }
+
+ private async fetchTelemetryUrl(channel: TelemetryChannel): Promise {
+ const telemetryUrl = await this.telemetrySetup?.getTelemetryUrl();
+ if (!telemetryUrl) {
+ throw Error("Couldn't get telemetry URL");
+ }
+ return this.getV3UrlFromV2(telemetryUrl.toString(), channel);
+ }
+
+ private async fetchTelemetryPingUrl(): Promise {
+ const telemetryUrl = await this.telemetrySetup?.getTelemetryUrl();
+ if (!telemetryUrl) {
+ throw Error("Couldn't get telemetry URL");
+ }
+
+ telemetryUrl.pathname = `/ping`;
+ return telemetryUrl.toString();
+ }
+
+ /**
+ * This method converts a v2 URL to a v3 URL like:
+ * - https://telemetry.elastic.co/v3/send/my-channel-name
+ * - https://telemetry-staging.elastic.co/v3-dev/send/my-channel-name
+ */
+ private getV3UrlFromV2(v2url: string, channel: TelemetryChannel): string {
+ const url = new URL(v2url);
+ if (!url.hostname.includes('staging')) {
+ url.pathname = `/v3/send/${channel}`;
+ } else {
+ url.pathname = `/v3-dev/send/${channel}`;
+ }
+ return url.toString();
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/task.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/task.test.ts
index 6c7dd8e9afc6e..91cb7d881d4c9 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/task.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/task.test.ts
@@ -13,6 +13,7 @@ import {
createMockTaskInstance,
createMockTelemetryEventsSender,
createMockTelemetryReceiver,
+ createMockTaskMetrics,
createMockSecurityTelemetryTask,
} from './__mocks__';
@@ -28,7 +29,8 @@ describe('test security telemetry task', () => {
createMockSecurityTelemetryTask(),
logger,
createMockTelemetryEventsSender(true),
- createMockTelemetryReceiver()
+ createMockTelemetryReceiver(),
+ createMockTaskMetrics()
);
expect(telemetryTask).toBeInstanceOf(SecurityTelemetryTask);
@@ -41,7 +43,8 @@ describe('test security telemetry task', () => {
createMockSecurityTelemetryTask(),
logger,
createMockTelemetryEventsSender(true),
- createMockTelemetryReceiver()
+ createMockTelemetryReceiver(),
+ createMockTaskMetrics()
);
telemetryTask.register(mockTaskManagerSetup);
await telemetryTask.start(mockTaskManagerStart);
@@ -58,6 +61,7 @@ describe('test security telemetry task', () => {
mockTelemetryTaskConfig,
mockTelemetryEventsSender,
mockTelemetryReceiver,
+ mockTaskMetrics,
} = await testTelemetryTaskRun(true, true);
expect(mockTelemetryTaskConfig.runTask).toHaveBeenCalledWith(
@@ -65,6 +69,7 @@ describe('test security telemetry task', () => {
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
{
last: testLastTimestamp,
current: testResult.state.lastExecutionTimestamp,
@@ -92,11 +97,13 @@ describe('test security telemetry task', () => {
const mockTelemetryTaskConfig = createMockSecurityTelemetryTask(testType, testLastTimestamp);
const mockTelemetryEventsSender = createMockTelemetryEventsSender(optedIn, canConnect);
const mockTelemetryReceiver = createMockTelemetryReceiver();
+ const mockTaskMetrics = createMockTaskMetrics();
const telemetryTask = new SecurityTelemetryTask(
mockTelemetryTaskConfig,
logger,
mockTelemetryEventsSender,
- mockTelemetryReceiver
+ mockTelemetryReceiver,
+ mockTaskMetrics
);
const mockTaskInstance = createMockTaskInstance(telemetryTask.getTaskId(), testType);
@@ -122,6 +129,7 @@ describe('test security telemetry task', () => {
mockTelemetryTaskConfig,
mockTelemetryEventsSender,
mockTelemetryReceiver,
+ mockTaskMetrics,
};
}
});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/task.ts b/x-pack/plugins/security_solution/server/lib/telemetry/task.ts
index 84558d35df9ff..62d827930e6a9 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/task.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/task.ts
@@ -14,6 +14,7 @@ import type {
} from '@kbn/task-manager-plugin/server';
import type { ITelemetryReceiver } from './receiver';
import type { ITelemetryEventsSender } from './sender';
+import type { ITaskMetricsService } from './task_metrics.types';
import { tlog } from './helpers';
import { stateSchemaByVersion, emptyState, type LatestTaskStateSchema } from './task_state';
@@ -32,6 +33,7 @@ export type SecurityTelemetryTaskRunner = (
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => Promise;
@@ -50,17 +52,20 @@ export class SecurityTelemetryTask {
private readonly logger: Logger;
private readonly sender: ITelemetryEventsSender;
private readonly receiver: ITelemetryReceiver;
+ private readonly taskMetricsService: ITaskMetricsService;
constructor(
config: SecurityTelemetryTaskConfig,
logger: Logger,
sender: ITelemetryEventsSender,
- receiver: ITelemetryReceiver
+ receiver: ITelemetryReceiver,
+ taskMetricsService: ITaskMetricsService
) {
this.config = config;
this.logger = logger;
this.sender = sender;
this.receiver = receiver;
+ this.taskMetricsService = taskMetricsService;
}
public getLastExecutionTime = (
@@ -154,6 +159,13 @@ export class SecurityTelemetryTask {
}
tlog(this.logger, `[task ${taskId}]: running task`);
- return this.config.runTask(taskId, this.logger, this.receiver, this.sender, executionPeriod);
+ return this.config.runTask(
+ taskId,
+ this.logger,
+ this.receiver,
+ this.sender,
+ this.taskMetricsService,
+ executionPeriod
+ );
};
}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.test.ts
new file mode 100644
index 0000000000000..784c34bf11d7d
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.test.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { loggingSystemMock } from '@kbn/core/server/mocks';
+import type { ITaskMetricsService, TaskMetric } from './task_metrics.types';
+import { TaskMetricsService } from './task_metrics';
+import { createMockTelemetryEventsSender } from './__mocks__';
+import type { ITelemetryEventsSender } from './sender';
+import { telemetryConfiguration } from './configuration';
+
+describe('task metrics', () => {
+ let logger: ReturnType;
+ let taskMetricsService: ITaskMetricsService;
+ let mockTelemetryEventsSender: jest.Mocked;
+
+ beforeEach(() => {
+ logger = loggingSystemMock.createLogger();
+ mockTelemetryEventsSender = createMockTelemetryEventsSender();
+ taskMetricsService = new TaskMetricsService(logger, mockTelemetryEventsSender);
+ jest.spyOn(telemetryConfiguration, 'use_async_sender', 'get').mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should start trace', async () => {
+ const trace = taskMetricsService.start('test');
+ expect(trace).toBeDefined();
+ expect(trace.name).toEqual('test');
+ });
+
+ it('should record passed task metrics', async () => {
+ const metric = sendMetric('test');
+
+ expect(metric.name).toEqual('test');
+ expect(metric.passed).toBeTruthy();
+ expect(metric.error_message).toBeUndefined();
+ expect(metric.time_executed_in_ms).toBeGreaterThan(0);
+ expect(metric.start_time).toBeGreaterThan(0);
+ expect(metric.end_time).toBeGreaterThan(0);
+ });
+
+ it('should use legacy sender when feature flag is disabled', async () => {
+ jest.spyOn(telemetryConfiguration, 'use_async_sender', 'get').mockReturnValue(false);
+
+ const trace = taskMetricsService.start('test');
+ taskMetricsService.end(trace);
+ expect(mockTelemetryEventsSender.sendAsync).toHaveBeenCalledTimes(0);
+
+ expect(mockTelemetryEventsSender.sendAsync).toHaveBeenCalledTimes(0);
+ expect(mockTelemetryEventsSender.sendOnDemand).toHaveBeenCalledTimes(1);
+ });
+
+ it('should record failed task metrics', async () => {
+ const metric = sendMetric('test', Error('Boom!'));
+
+ expect(metric.name).toEqual('test');
+ expect(metric.passed).toBeFalsy();
+ expect(metric.error_message).toEqual('Boom!');
+ expect(metric.time_executed_in_ms).toBeGreaterThan(0);
+ expect(metric.start_time).toBeGreaterThan(0);
+ expect(metric.end_time).toBeGreaterThan(0);
+ });
+
+ function sendMetric(name: string, error?: Error): TaskMetric {
+ const trace = taskMetricsService.start(name);
+ taskMetricsService.end(trace, error);
+
+ expect(mockTelemetryEventsSender.sendAsync).toHaveBeenCalledTimes(1);
+ const events = mockTelemetryEventsSender.sendAsync.mock.calls[0][1];
+ expect(events).toHaveLength(1);
+
+ return events[0] as TaskMetric;
+ }
+});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.ts b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.ts
new file mode 100644
index 0000000000000..10041f46c4196
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import type { Logger } from '@kbn/core/server';
+import { newTelemetryLogger, type TelemetryLogger } from './helpers';
+import type { TaskMetric, ITaskMetricsService, Trace } from './task_metrics.types';
+import type { ITelemetryEventsSender } from './sender';
+import { TelemetryChannel } from './types';
+import { telemetryConfiguration } from './configuration';
+
+export class TaskMetricsService implements ITaskMetricsService {
+ private readonly logger: TelemetryLogger;
+
+ constructor(logger: Logger, private readonly sender: ITelemetryEventsSender) {
+ this.logger = newTelemetryLogger(logger.get('telemetry_events.task_metrics'));
+ }
+
+ public start(name: string): Trace {
+ return {
+ name,
+ startedAt: performance.now(),
+ };
+ }
+
+ public async end(trace: Trace, error?: Error): Promise {
+ const event = this.createTaskMetric(trace, error);
+
+ this.logger.l(`Task ${event.name} complete. Task run took ${event.time_executed_in_ms}ms`);
+
+ if (telemetryConfiguration.use_async_sender) {
+ this.sender.sendAsync(TelemetryChannel.TASK_METRICS, [event]);
+ } else {
+ await this.sender.sendOnDemand(TelemetryChannel.TASK_METRICS, [event]);
+ }
+ }
+
+ public createTaskMetric(trace: Trace, error?: Error): TaskMetric {
+ const finishedAt = performance.now();
+ return {
+ name: trace.name,
+ passed: error === undefined,
+ time_executed_in_ms: finishedAt - trace.startedAt,
+ start_time: trace.startedAt,
+ end_time: finishedAt,
+ error_message: error?.message,
+ };
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.types.ts
new file mode 100644
index 0000000000000..68baaacfb8602
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/task_metrics.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+export interface ITaskMetricsService {
+ start(name: string): Trace;
+ end(trace: Trace, error?: Error): Promise;
+ createTaskMetric(trace: Trace, error?: Error): TaskMetric;
+}
+
+export interface Trace {
+ name: string;
+ startedAt: number;
+}
+
+export interface TaskMetric {
+ name: string;
+ passed: boolean;
+ time_executed_in_ms: number;
+ start_time: number;
+ end_time: number;
+ error_message?: string;
+}
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.test.ts
index 65a1395a40e4c..9e65e7960c219 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryConfigurationTaskConfig } from './configuration';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
describe('telemetry configuration task test', () => {
let logger: ReturnType;
@@ -24,12 +28,14 @@ describe('telemetry configuration task test', () => {
const mockTelemetryReceiver = createMockTelemetryReceiver();
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const telemetryDiagnoticsTaskConfig = createTelemetryConfigurationTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryDiagnoticsTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts
index 75c7a9080b3b8..fdddc67b84d07 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts
@@ -6,37 +6,53 @@
*/
import type { Logger } from '@kbn/core/server';
-import { TASK_METRICS_CHANNEL } from '../constants';
import type { ITelemetryEventsSender } from '../sender';
-import type { TelemetryConfiguration } from '../types';
+import { TelemetryChannel, type TelemetryConfiguration } from '../types';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
+import type { ITaskMetricsService } from '../task_metrics.types';
import { artifactService } from '../artifact';
import { telemetryConfiguration } from '../configuration';
-import { createTaskMetric, tlog } from '../helpers';
+import { newTelemetryLogger } from '../helpers';
export function createTelemetryConfigurationTaskConfig() {
+ const taskName = 'Security Solution Telemetry Configuration Task';
+ const taskType = 'security:telemetry-configuration';
return {
- type: 'security:telemetry-configuration',
- title: 'Security Solution Telemetry Configuration Task',
+ type: taskType,
+ title: taskName,
interval: '1h',
timeout: '1m',
version: '1.0.0',
runTask: async (
taskId: string,
logger: Logger,
- receiver: ITelemetryReceiver,
+ _receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const startTime = Date.now();
- const taskName = 'Security Solution Telemetry Configuration Task';
+ const log = newTelemetryLogger(logger.get('configuration')).l;
+ const trace = taskMetricsService.start(taskType);
+
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
+
try {
const artifactName = 'telemetry-buffer-and-batch-sizes-v1';
- const configArtifact = (await artifactService.getArtifact(
- artifactName
- )) as unknown as TelemetryConfiguration;
- tlog(logger, `New telemetry configuration artifact: ${JSON.stringify(configArtifact)}`);
+ const manifest = await artifactService.getArtifact(artifactName);
+
+ if (manifest.notModified) {
+ log('No new configuration artifact found, skipping...');
+ taskMetricsService.end(trace);
+ return 0;
+ }
+
+ const configArtifact = manifest.data as unknown as TelemetryConfiguration;
+
+ log(`Got telemetry configuration artifact: ${JSON.stringify(configArtifact)}`);
+
telemetryConfiguration.max_detection_alerts_batch =
configArtifact.max_detection_alerts_batch;
telemetryConfiguration.telemetry_max_buffer_size = configArtifact.telemetry_max_buffer_size;
@@ -46,16 +62,51 @@ export function createTelemetryConfigurationTaskConfig() {
configArtifact.max_endpoint_telemetry_batch;
telemetryConfiguration.max_security_list_telemetry_batch =
configArtifact.max_security_list_telemetry_batch;
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+
+ if (configArtifact.use_async_sender) {
+ telemetryConfiguration.use_async_sender = configArtifact.use_async_sender;
+ }
+
+ if (configArtifact.sender_channels) {
+ log('Updating sender channels configuration');
+ telemetryConfiguration.sender_channels = configArtifact.sender_channels;
+ const channelsDict = Object.values(TelemetryChannel).reduce(
+ (acc, channel) => acc.set(channel as string, channel),
+ new Map()
+ );
+
+ Object.entries(configArtifact.sender_channels).forEach(([channelName, config]) => {
+ if (channelName === 'default') {
+ log('Updating default configuration');
+ sender.updateDefaultQueueConfig({
+ bufferTimeSpanMillis: config.buffer_time_span_millis,
+ inflightEventsThreshold: config.inflight_events_threshold,
+ maxPayloadSizeBytes: config.max_payload_size_bytes,
+ });
+ } else {
+ const channel = channelsDict.get(channelName);
+ if (!channel) {
+ log(`Ignoring unknown channel "${channelName}"`);
+ } else {
+ log(`Updating configuration for channel "${channelName}`);
+ sender.updateQueueConfig(channel, {
+ bufferTimeSpanMillis: config.buffer_time_span_millis,
+ inflightEventsThreshold: config.inflight_events_threshold,
+ maxPayloadSizeBytes: config.max_payload_size_bytes,
+ });
+ }
+ }
+ });
+ }
+
+ taskMetricsService.end(trace);
+
+ log(`Updated TelemetryConfiguration: ${JSON.stringify(telemetryConfiguration)}`);
return 0;
} catch (err) {
- tlog(logger, `Failed to set telemetry configuration due to ${err.message}`);
+ log(`Failed to set telemetry configuration due to ${err.message}`);
telemetryConfiguration.resetAllToDefault();
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts
index 9cfbaffbcaa42..de59f350865c7 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryDetectionRuleListsTaskConfig } from './detection_rule';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
describe('security detection rule task test', () => {
let logger: ReturnType;
@@ -24,12 +28,14 @@ describe('security detection rule task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetryDetectionRuleListsTaskConfig = createTelemetryDetectionRuleListsTaskConfig(1);
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryDetectionRuleListsTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts
index 00da024b0049e..e8acc4e222958 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts
@@ -5,28 +5,27 @@
* 2.0.
*/
+import { cloneDeep } from 'lodash';
import type { Logger } from '@kbn/core/server';
-import {
- LIST_DETECTION_RULE_EXCEPTION,
- TELEMETRY_CHANNEL_LISTS,
- TASK_METRICS_CHANNEL,
-} from '../constants';
+import { LIST_DETECTION_RULE_EXCEPTION, TELEMETRY_CHANNEL_LISTS } from '../constants';
import {
batchTelemetryRecords,
templateExceptionList,
- tlog,
- createTaskMetric,
+ newTelemetryLogger,
createUsageCounterLabel,
} from '../helpers';
import type { ITelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver } from '../receiver';
import type { ExceptionListItem, ESClusterInfo, ESLicense, RuleSearchResult } from '../types';
import type { TaskExecutionPeriod } from '../task';
+import type { ITaskMetricsService } from '../task_metrics.types';
export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: number) {
+ const taskName = 'Security Solution Detection Rule Lists Telemetry';
+ const taskType = 'security:telemetry-detection-rules';
return {
- type: 'security:telemetry-detection-rules',
- title: 'Security Solution Detection Rule Lists Telemetry',
+ type: taskType,
+ title: taskName,
interval: '24h',
timeout: '10m',
version: '1.0.0',
@@ -35,17 +34,15 @@ export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: n
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
+ const log = newTelemetryLogger(logger.get('detection_rule')).l;
const usageCollector = sender.getTelemetryUsageCluster();
-
const usageLabelPrefix: string[] = ['security_telemetry', 'detection-rules'];
+ const trace = taskMetricsService.start(taskType);
- const startTime = Date.now();
- const taskName = 'Security Solution Detection Rule Lists Telemetry';
-
- tlog(
- logger,
+ log(
`Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
);
@@ -69,10 +66,8 @@ export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: n
const { body: prebuiltRules } = await receiver.fetchDetectionRules();
if (!prebuiltRules) {
- tlog(logger, 'no prebuilt rules found');
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ log('no prebuilt rules found');
+ taskMetricsService.end(trace);
return 0;
}
@@ -113,7 +108,7 @@ export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: n
licenseInfo,
LIST_DETECTION_RULE_EXCEPTION
);
- tlog(logger, `Detection rule exception json length ${detectionRuleExceptionsJson.length}`);
+ log(`Detection rule exception json length ${detectionRuleExceptionsJson.length}`);
usageCollector?.incrementCounter({
counterName: createUsageCounterLabel(usageLabelPrefix),
@@ -121,18 +116,22 @@ export function createTelemetryDetectionRuleListsTaskConfig(maxTelemetryBatch: n
incrementBy: detectionRuleExceptionsJson.length,
});
- const batches = batchTelemetryRecords(detectionRuleExceptionsJson, maxTelemetryBatch);
+ const batches = batchTelemetryRecords(
+ cloneDeep(detectionRuleExceptionsJson),
+ maxTelemetryBatch
+ );
for (const batch of batches) {
await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch);
}
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ taskMetricsService.end(trace);
+
+ log(
+ `Task: ${taskId} executed. Processed ${detectionRuleExceptionsJson.length} exceptions`
+ );
+
return detectionRuleExceptionsJson.length;
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts
index c2f2b596583c0..8b1237c6f8222 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryDiagnosticsTaskConfig } from './diagnostic';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
describe('diagnostics telemetry task test', () => {
let logger: ReturnType;
@@ -29,18 +33,21 @@ describe('diagnostics telemetry task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver(testDiagnosticsAlerts);
const telemetryDiagnoticsTaskConfig = createTelemetryDiagnosticsTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryDiagnoticsTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
expect(mockTelemetryReceiver.fetchDiagnosticAlertsBatch).toHaveBeenCalledWith(
testTaskExecutionPeriod.last,
testTaskExecutionPeriod.current
);
- expect(mockTelemetryEventsSender.sendOnDemand).toBeCalledTimes(1);
+ expect(mockTaskMetrics.start).toBeCalledTimes(1);
+ expect(mockTaskMetrics.end).toBeCalledTimes(1);
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts
index b52caeb165a3b..9b91dd557526e 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/diagnostic.ts
@@ -6,18 +6,21 @@
*/
import type { Logger } from '@kbn/core/server';
-import { tlog, getPreviousDiagTaskTimestamp, createTaskMetric } from '../helpers';
+import { newTelemetryLogger, getPreviousDiagTaskTimestamp } from '../helpers';
import type { ITelemetryEventsSender } from '../sender';
import type { TelemetryEvent } from '../types';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
-import { TELEMETRY_CHANNEL_ENDPOINT_ALERTS, TASK_METRICS_CHANNEL } from '../constants';
+import type { ITaskMetricsService } from '../task_metrics.types';
+import { TELEMETRY_CHANNEL_ENDPOINT_ALERTS } from '../constants';
import { copyAllowlistedFields, filterList } from '../filterlists';
export function createTelemetryDiagnosticsTaskConfig() {
+ const taskName = 'Security Solution Telemetry Diagnostics task';
+ const taskType = 'security:endpoint-diagnostics';
return {
- type: 'security:endpoint-diagnostics',
- title: 'Security Solution Telemetry Diagnostics task',
+ type: taskType,
+ title: taskName,
interval: '5m',
timeout: '4m',
version: '1.1.0',
@@ -27,10 +30,16 @@ export function createTelemetryDiagnosticsTaskConfig() {
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const startTime = Date.now();
- const taskName = 'Security Solution Telemetry Diagnostics task';
+ const log = newTelemetryLogger(logger.get('diagnostic')).l;
+ const trace = taskMetricsService.start(taskType);
+
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
+
try {
if (!taskExecutionPeriod.last) {
throw new Error('last execution timestamp is required');
@@ -48,27 +57,20 @@ export function createTelemetryDiagnosticsTaskConfig() {
);
if (alerts.length === 0) {
- tlog(logger, 'no diagnostic alerts retrieved');
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ log('no diagnostic alerts retrieved');
+ taskMetricsService.end(trace);
return alertCount;
}
alertCount += alerts.length;
- tlog(logger, `Sending ${alerts.length} diagnostic alerts`);
+ log(`Sending ${alerts.length} diagnostic alerts`);
await sender.sendOnDemand(TELEMETRY_CHANNEL_ENDPOINT_ALERTS, processedAlerts);
}
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
-
+ taskMetricsService.end(trace);
return alertCount;
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts
index 4cffeccc0db3c..7b1b59e316c7e 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryEndpointTaskConfig } from './endpoint';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock';
const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract();
@@ -33,12 +37,14 @@ describe('endpoint telemetry task test', () => {
.mockReturnValue(telemetryUsageCounter);
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetryEndpointTaskConfig = createTelemetryEndpointTaskConfig(1);
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryEndpointTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
@@ -69,16 +75,19 @@ describe('endpoint telemetry task test', () => {
const mockTelemetryReceiver = createMockTelemetryReceiver();
mockTelemetryReceiver.fetchPolicyConfigs = jest.fn().mockRejectedValueOnce(new Error());
const telemetryEndpointTaskConfig = createTelemetryEndpointTaskConfig(1);
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryEndpointTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
expect(mockTelemetryReceiver.fetchPolicyConfigs).toHaveBeenCalled();
- expect(mockTelemetryEventsSender.sendOnDemand).toHaveBeenCalled();
+ expect(mockTaskMetrics.start).toBeCalledTimes(1);
+ expect(mockTaskMetrics.end).toBeCalledTimes(1);
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts
index f221a11204bf4..4455ae1833e4b 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts
@@ -21,6 +21,7 @@ import type {
} from '../types';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
+import type { ITaskMetricsService } from '../task_metrics.types';
import {
addDefaultAdvancedPolicyConfigSettings,
batchTelemetryRecords,
@@ -28,11 +29,10 @@ import {
extractEndpointPolicyConfig,
getPreviousDailyTaskTimestamp,
isPackagePolicyList,
- tlog,
- createTaskMetric,
+ newTelemetryLogger,
} from '../helpers';
import type { PolicyData } from '../../../../common/endpoint/types';
-import { TELEMETRY_CHANNEL_ENDPOINT_META, TASK_METRICS_CHANNEL } from '../constants';
+import { TELEMETRY_CHANNEL_ENDPOINT_META } from '../constants';
// Endpoint agent uses this Policy ID while it's installing.
const DefaultEndpointPolicyIdToIgnore = '00000000-0000-0000-0000-000000000000';
@@ -47,9 +47,11 @@ const EmptyFleetAgentResponse = {
const usageLabelPrefix: string[] = ['security_telemetry', 'endpoint_task'];
export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
+ const taskName = 'Security Solution Telemetry Endpoint Metrics and Info task';
+ const taskType = 'security:endpoint-meta-telemetry';
return {
- type: 'security:endpoint-meta-telemetry',
- title: 'Security Solution Telemetry Endpoint Metrics and Info task',
+ type: taskType,
+ title: taskName,
interval: '24h',
timeout: '5m',
version: '1.0.0',
@@ -59,10 +61,16 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const startTime = Date.now();
- const taskName = 'Security Solution Telemetry Endpoint Metrics and Info task';
+ const log = newTelemetryLogger(logger.get('endpoint')).l;
+ const trace = taskMetricsService.start(taskType);
+
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
+
try {
if (!taskExecutionPeriod.last) {
throw new Error('last execution timestamp is required');
@@ -95,10 +103,8 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
* a metric document(s) exists for an EP agent we map to fleet agent and policy
*/
if (endpointData.endpointMetrics === undefined) {
- tlog(logger, `no endpoint metrics to report`);
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ log('no endpoint metrics to report');
+ taskMetricsService.end(trace);
return 0;
}
@@ -107,10 +113,8 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
};
if (endpointMetricsResponse.aggregations === undefined) {
- tlog(logger, `no endpoint metrics to report`);
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ log(`no endpoint metrics to report`);
+ taskMetricsService.end(trace);
return 0;
}
@@ -143,10 +147,8 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
const agentsResponse = endpointData.fleetAgentsResponse;
if (agentsResponse === undefined) {
- tlog(logger, 'no fleet agent information available');
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ log('no fleet agent information available');
+ taskMetricsService.end(trace);
return 0;
}
@@ -173,7 +175,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
try {
agentPolicy = await receiver.fetchPolicyConfigs(policyInfo);
} catch (err) {
- tlog(logger, `error fetching policy config due to ${err?.message}`);
+ log(`error fetching policy config due to ${err?.message}`);
}
const packagePolicies = agentPolicy?.package_policies;
@@ -234,7 +236,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
* a metadata document(s) exists for an EP agent we map to fleet agent and policy
*/
if (endpointData.endpointMetadata === undefined) {
- tlog(logger, `no endpoint metadata to report`);
+ log(`no endpoint metadata to report`);
}
const { body: endpointMetadataResponse } = endpointData.endpointMetadata as unknown as {
@@ -242,7 +244,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
};
if (endpointMetadataResponse.aggregations === undefined) {
- tlog(logger, `no endpoint metadata to report`);
+ log(`no endpoint metadata to report`);
}
const endpointMetadata =
@@ -354,21 +356,15 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) {
for (const batch of batches) {
await sender.sendOnDemand(TELEMETRY_CHANNEL_ENDPOINT_META, batch);
}
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ taskMetricsService.end(trace);
return telemetryPayloads.length;
} catch (err) {
logger.warn(`could not complete endpoint alert telemetry task due to ${err?.message}`);
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts
index 50bf10c964e08..c09a5ec497a01 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts
@@ -6,50 +6,59 @@
*/
import type { Logger } from '@kbn/core/server';
-import { TASK_METRICS_CHANNEL } from '../constants';
import type { ITelemetryEventsSender } from '../sender';
import type { TelemetryFilterListArtifact } from '../types';
import type { ITelemetryReceiver } from '../receiver';
+import type { ITaskMetricsService } from '../task_metrics.types';
import type { TaskExecutionPeriod } from '../task';
import { artifactService } from '../artifact';
import { filterList } from '../filterlists';
-import { createTaskMetric, tlog } from '../helpers';
+import { newTelemetryLogger } from '../helpers';
export function createTelemetryFilterListArtifactTaskConfig() {
+ const taskName = 'Security Solution Telemetry Filter List Artifact Task';
+ const taskType = 'security:telemetry-filterlist-artifact';
return {
- type: 'security:telemetry-filterlist-artifact',
- title: 'Security Solution Telemetry Filter List Artifact Task',
+ type: taskType,
+ title: taskName,
interval: '45m',
timeout: '1m',
version: '1.0.0',
runTask: async (
taskId: string,
logger: Logger,
- receiver: ITelemetryReceiver,
- sender: ITelemetryEventsSender,
+ _receiver: ITelemetryReceiver,
+ _sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const startTime = Date.now();
- const taskName = 'Security Solution Telemetry Filter List Artifact Task';
+ const log = newTelemetryLogger(logger.get('filterlists')).l;
+ const trace = taskMetricsService.start(taskType);
+
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
+
try {
const artifactName = 'telemetry-filterlists-v1';
- const artifact = (await artifactService.getArtifact(
- artifactName
- )) as unknown as TelemetryFilterListArtifact;
- tlog(logger, `New filterlist artifact: ${JSON.stringify(artifact)}`);
+ const manifest = await artifactService.getArtifact(artifactName);
+ if (manifest.notModified) {
+ log('No new filterlist artifact found, skipping...');
+ taskMetricsService.end(trace);
+ return 0;
+ }
+
+ const artifact = manifest.data as unknown as TelemetryFilterListArtifact;
+ log(`New filterlist artifact: ${JSON.stringify(artifact)}`);
filterList.endpointAlerts = artifact.endpoint_alerts;
filterList.exceptionLists = artifact.exception_lists;
filterList.prebuiltRulesAlerts = artifact.prebuilt_rules_alerts;
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ taskMetricsService.end(trace);
return 0;
} catch (err) {
- tlog(logger, `Failed to set telemetry filterlist artifact due to ${err.message}`);
+ log(`Failed to set telemetry filterlist artifact due to ${err.message}`);
filterList.resetAllToDefault();
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts
index 479ceabd65a3b..cbca2b32e677d 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryPrebuiltRuleAlertsTaskConfig } from './prebuilt_rule_alerts';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock';
const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract();
@@ -33,16 +37,19 @@ describe('security telemetry - detection rule alerts task test', () => {
.mockReturnValue(telemetryUsageCounter);
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetryDetectionRuleAlertsTaskConfig = createTelemetryPrebuiltRuleAlertsTaskConfig(1);
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryDetectionRuleAlertsTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
expect(mockTelemetryReceiver.fetchDetectionRulesPackageVersion).toHaveBeenCalled();
expect(mockTelemetryReceiver.fetchPrebuiltRuleAlertsBatch).toHaveBeenCalled();
- expect(mockTelemetryEventsSender.sendOnDemand).toHaveBeenCalled();
+ expect(mockTaskMetrics.start).toHaveBeenCalled();
+ expect(mockTaskMetrics.end).toHaveBeenCalled();
});
});
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts
index 0765d1d5bbdae..9d0324b8144c2 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts
@@ -9,18 +9,20 @@ import type { Logger } from '@kbn/core/server';
import type { SortResults } from '@elastic/elasticsearch/lib/api/types';
import type { ITelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver } from '../receiver';
+import type { ITaskMetricsService } from '../task_metrics.types';
import type { ESClusterInfo, ESLicense, TelemetryEvent } from '../types';
import type { TaskExecutionPeriod } from '../task';
-import { TELEMETRY_CHANNEL_DETECTION_ALERTS, TASK_METRICS_CHANNEL } from '../constants';
-import { batchTelemetryRecords, createTaskMetric, processK8sUsernames, tlog } from '../helpers';
+import { TELEMETRY_CHANNEL_DETECTION_ALERTS } from '../constants';
+import { batchTelemetryRecords, processK8sUsernames, newTelemetryLogger } from '../helpers';
import { copyAllowlistedFields, filterList } from '../filterlists';
export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: number) {
+ const taskName = 'Security Solution - Prebuilt Rule and Elastic ML Alerts Telemetry';
const taskVersion = '1.2.0';
-
+ const taskType = 'security:telemetry-prebuilt-rule-alerts';
return {
- type: 'security:telemetry-prebuilt-rule-alerts',
- title: 'Security Solution - Prebuilt Rule and Elastic ML Alerts Telemetry',
+ type: taskType,
+ title: taskName,
interval: '1h',
timeout: '15m',
version: taskVersion,
@@ -29,10 +31,16 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const startTime = Date.now();
- const taskName = 'Security Solution - Prebuilt Rule and Elastic ML Alerts Telemetry';
+ const log = newTelemetryLogger(logger.get('prebuilt_rule_alerts')).l;
+ const trace = taskMetricsService.start(taskType);
+
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
+
try {
const [clusterInfoPromise, licenseInfoPromise, packageVersion] = await Promise.allSettled([
receiver.fetchClusterInfo(),
@@ -53,7 +61,8 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n
const index = receiver.getAlertsIndex();
if (index === undefined) {
- tlog(logger, `alerts index is not ready yet, skipping telemetry task`);
+ log(`alerts index is not ready yet, skipping telemetry task`);
+ taskMetricsService.end(trace);
return 0;
}
@@ -66,6 +75,7 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n
await receiver.fetchPrebuiltRuleAlertsBatch(pitId, searchAfterValue);
if (alerts.length === 0) {
+ taskMetricsService.end(trace);
return 0;
}
@@ -94,7 +104,7 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n
})
);
- tlog(logger, `sending ${enrichedAlerts.length} elastic prebuilt alerts`);
+ log(`sending ${enrichedAlerts.length} elastic prebuilt alerts`);
const batches = batchTelemetryRecords(enrichedAlerts, maxTelemetryBatch);
const promises = batches.map(async (batch) => {
@@ -104,13 +114,12 @@ export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: n
await Promise.all(promises);
}
+ taskMetricsService.end(trace);
await receiver.closePointInTime(pitId);
return 0;
} catch (err) {
logger.error('could not complete prebuilt alerts telemetry task');
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.test.ts
index 8c227dd56c13e..58a3c4720dab1 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetrySecurityListTaskConfig } from './security_lists';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
import {
ENDPOINT_LIST_ID,
ENDPOINT_EVENT_FILTERS_LIST_ID,
@@ -28,12 +32,14 @@ describe('security list telemetry task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetrySecurityListTaskConfig = createTelemetrySecurityListTaskConfig(1);
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetrySecurityListTaskConfig.runTask(
'test-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts
index 863d66d55c4e7..45fe385e76329 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/security_lists.ts
@@ -12,25 +12,26 @@ import {
LIST_ENDPOINT_EVENT_FILTER,
LIST_TRUSTED_APPLICATION,
TELEMETRY_CHANNEL_LISTS,
- TASK_METRICS_CHANNEL,
} from '../constants';
import type { ESClusterInfo, ESLicense } from '../types';
import {
batchTelemetryRecords,
templateExceptionList,
- createTaskMetric,
formatValueListMetaData,
createUsageCounterLabel,
- tlog,
+ newTelemetryLogger,
} from '../helpers';
import type { ITelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
+import type { ITaskMetricsService } from '../task_metrics.types';
export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number) {
+ const taskName = 'Security Solution Lists Telemetry';
+ const taskType = 'security:telemetry-lists';
return {
- type: 'security:telemetry-lists',
- title: 'Security Solution Lists Telemetry',
+ type: taskType,
+ title: taskName,
interval: '24h',
timeout: '3m',
version: '1.0.0',
@@ -39,14 +40,18 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number)
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- const usageCollector = sender.getTelemetryUsageCluster();
+ const log = newTelemetryLogger(logger.get('security_lists')).l;
+ const trace = taskMetricsService.start(taskType);
- const usageLabelPrefix: string[] = ['security_telemetry', 'lists'];
+ log(
+ `Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
+ );
- const startTime = Date.now();
- const taskName = 'Security Solution Lists Telemetry';
+ const usageCollector = sender.getTelemetryUsageCluster();
+ const usageLabelPrefix: string[] = ['security_telemetry', 'lists'];
try {
let trustedApplicationsCount = 0;
let endpointExceptionsCount = 0;
@@ -77,7 +82,7 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number)
LIST_TRUSTED_APPLICATION
);
trustedApplicationsCount = trustedAppsJson.length;
- tlog(logger, `Trusted Apps: ${trustedApplicationsCount}`);
+ log(`Trusted Apps: ${trustedApplicationsCount}`);
usageCollector?.incrementCounter({
counterName: createUsageCounterLabel(usageLabelPrefix),
@@ -102,7 +107,7 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number)
LIST_ENDPOINT_EXCEPTION
);
endpointExceptionsCount = epExceptionsJson.length;
- tlog(logger, `EP Exceptions: ${endpointExceptionsCount}`);
+ log(`EP Exceptions: ${endpointExceptionsCount}`);
usageCollector?.incrementCounter({
counterName: createUsageCounterLabel(usageLabelPrefix),
@@ -127,7 +132,7 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number)
LIST_ENDPOINT_EVENT_FILTER
);
endpointEventFiltersCount = epFiltersJson.length;
- tlog(logger, `EP Event Filters: ${endpointEventFiltersCount}`);
+ log(`EP Event Filters: ${endpointEventFiltersCount}`);
usageCollector?.incrementCounter({
counterName: createUsageCounterLabel(usageLabelPrefix),
@@ -153,14 +158,10 @@ export function createTelemetrySecurityListTaskConfig(maxTelemetryBatch: number)
if (valueListMetaData?.total_list_count) {
await sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, [valueListMetaData]);
}
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, startTime),
- ]);
+ taskMetricsService.end(trace);
return trustedApplicationsCount + endpointExceptionsCount + endpointEventFiltersCount;
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts
index 930ef076edd3f..e376de3fceb0f 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryTimelineTaskConfig } from './timelines';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
describe('timeline telemetry task test', () => {
let logger: ReturnType;
@@ -24,12 +28,14 @@ describe('timeline telemetry task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetryTelemetryTaskConfig = createTelemetryTimelineTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryTelemetryTaskConfig.runTask(
'test-timeline-task-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
@@ -48,12 +54,14 @@ describe('timeline telemetry task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver(null, true);
const telemetryTelemetryTaskConfig = createTelemetryTimelineTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryTelemetryTaskConfig.runTask(
'test-timeline-task-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts
index 7e0c41e57eec4..63e483cb77376 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines.ts
@@ -9,14 +9,15 @@ import type { Logger } from '@kbn/core/server';
import type { ITelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
-import { TELEMETRY_CHANNEL_TIMELINE, TASK_METRICS_CHANNEL } from '../constants';
-import { createTaskMetric, ranges, TelemetryTimelineFetcher, tlog } from '../helpers';
+import type { ITaskMetricsService } from '../task_metrics.types';
+import { TELEMETRY_CHANNEL_TIMELINE } from '../constants';
+import { ranges, TelemetryTimelineFetcher, newTelemetryLogger } from '../helpers';
export function createTelemetryTimelineTaskConfig() {
const taskName = 'Security Solution Timeline telemetry';
-
+ const taskType = 'security:telemetry-timelines';
return {
- type: 'security:telemetry-timelines',
+ type: taskType,
title: taskName,
interval: '1h',
timeout: '15m',
@@ -26,15 +27,17 @@ export function createTelemetryTimelineTaskConfig() {
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- tlog(
- logger,
+ const log = newTelemetryLogger(logger.get('timelines')).l;
+ const fetcher = new TelemetryTimelineFetcher(receiver);
+ const trace = taskMetricsService.start(taskType);
+
+ log(
`Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
);
- const fetcher = new TelemetryTimelineFetcher(receiver);
-
try {
let counter = 0;
@@ -46,7 +49,7 @@ export function createTelemetryTimelineTaskConfig() {
}
const alerts = await receiver.fetchTimelineAlerts(alertsIndex, rangeFrom, rangeTo);
- tlog(logger, `found ${alerts.length} alerts to process`);
+ log(`found ${alerts.length} alerts to process`);
for (const alert of alerts) {
const result = await fetcher.fetchTimeline(alert);
@@ -67,21 +70,17 @@ export function createTelemetryTimelineTaskConfig() {
sender.sendOnDemand(TELEMETRY_CHANNEL_TIMELINE, [result.timeline]);
counter += 1;
} else {
- tlog(logger, 'no events in timeline');
+ log('no events in timeline');
}
}
- tlog(logger, `sent ${counter} timelines. Concluding timeline task.`);
+ log(`sent ${counter} timelines. Concluding timeline task.`);
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, fetcher.startTime),
- ]);
+ taskMetricsService.end(trace);
return counter;
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, fetcher.startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.test.ts
index 887e0789e7754..491f87fe5a2e5 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.test.ts
@@ -7,7 +7,11 @@
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { createTelemetryDiagnosticTimelineTaskConfig } from './timelines_diagnostic';
-import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__';
+import {
+ createMockTelemetryEventsSender,
+ createMockTelemetryReceiver,
+ createMockTaskMetrics,
+} from '../__mocks__';
describe('timeline telemetry diagnostic task test', () => {
let logger: ReturnType;
@@ -24,12 +28,14 @@ describe('timeline telemetry diagnostic task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver();
const telemetryTelemetryDiagnosticTaskConfig = createTelemetryDiagnosticTimelineTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryTelemetryDiagnosticTaskConfig.runTask(
'test-timeline-diagnostic-task-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
@@ -48,12 +54,14 @@ describe('timeline telemetry diagnostic task test', () => {
const mockTelemetryEventsSender = createMockTelemetryEventsSender();
const mockTelemetryReceiver = createMockTelemetryReceiver(null, true);
const telemetryTelemetryDiagnosticTaskConfig = createTelemetryDiagnosticTimelineTaskConfig();
+ const mockTaskMetrics = createMockTaskMetrics();
await telemetryTelemetryDiagnosticTaskConfig.runTask(
'test-timeline-diagnostic-task-id',
logger,
mockTelemetryReceiver,
mockTelemetryEventsSender,
+ mockTaskMetrics,
testTaskExecutionPeriod
);
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.ts
index 7148848984b0a..46b248a36f63c 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/timelines_diagnostic.ts
@@ -9,18 +9,15 @@ import type { Logger } from '@kbn/core/server';
import type { ITelemetryEventsSender } from '../sender';
import type { ITelemetryReceiver } from '../receiver';
import type { TaskExecutionPeriod } from '../task';
-import {
- DEFAULT_DIAGNOSTIC_INDEX,
- TELEMETRY_CHANNEL_TIMELINE,
- TASK_METRICS_CHANNEL,
-} from '../constants';
-import { createTaskMetric, ranges, TelemetryTimelineFetcher, tlog } from '../helpers';
+import type { ITaskMetricsService } from '../task_metrics.types';
+import { DEFAULT_DIAGNOSTIC_INDEX, TELEMETRY_CHANNEL_TIMELINE } from '../constants';
+import { ranges, TelemetryTimelineFetcher, newTelemetryLogger } from '../helpers';
export function createTelemetryDiagnosticTimelineTaskConfig() {
const taskName = 'Security Solution Diagnostic Timeline telemetry';
-
+ const taskType = 'security:telemetry-diagnostic-timelines';
return {
- type: 'security:telemetry-diagnostic-timelines',
+ type: taskType,
title: taskName,
interval: '1h',
timeout: '15m',
@@ -30,15 +27,17 @@ export function createTelemetryDiagnosticTimelineTaskConfig() {
logger: Logger,
receiver: ITelemetryReceiver,
sender: ITelemetryEventsSender,
+ taskMetricsService: ITaskMetricsService,
taskExecutionPeriod: TaskExecutionPeriod
) => {
- tlog(
- logger,
+ const log = newTelemetryLogger(logger.get('timelines_diagnostic')).l;
+ const trace = taskMetricsService.start(taskType);
+ const fetcher = new TelemetryTimelineFetcher(receiver);
+
+ log(
`Running task: ${taskId} [last: ${taskExecutionPeriod.last} - current: ${taskExecutionPeriod.current}]`
);
- const fetcher = new TelemetryTimelineFetcher(receiver);
-
try {
let counter = 0;
@@ -50,7 +49,7 @@ export function createTelemetryDiagnosticTimelineTaskConfig() {
rangeTo
);
- tlog(logger, `found ${alerts.length} alerts to process`);
+ log(`found ${alerts.length} alerts to process`);
for (const alert of alerts) {
const result = await fetcher.fetchTimeline(alert);
@@ -71,21 +70,17 @@ export function createTelemetryDiagnosticTimelineTaskConfig() {
sender.sendOnDemand(TELEMETRY_CHANNEL_TIMELINE, [result.timeline]);
counter += 1;
} else {
- tlog(logger, 'no events in timeline');
+ log('no events in timeline');
}
}
- tlog(logger, `sent ${counter} timelines. Concluding timeline task.`);
+ log(`sent ${counter} timelines. Concluding timeline task.`);
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, true, fetcher.startTime),
- ]);
+ taskMetricsService.end(trace);
return counter;
} catch (err) {
- await sender.sendOnDemand(TASK_METRICS_CHANNEL, [
- createTaskMetric(taskName, false, fetcher.startTime, err.message),
- ]);
+ taskMetricsService.end(trace, err);
return 0;
}
},
diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts
index cbff7156eaf15..46a8bc21faaef 100644
--- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts
+++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts
@@ -43,6 +43,7 @@ export interface ESLicense {
start_date_in_millis?: number;
}
+// Telemetry
export interface TelemetryEvent {
[key: string]: SearchTypes;
'@timestamp'?: string;
@@ -79,6 +80,30 @@ export interface TelemetryEvent {
};
}
+/**
+ * List of supported telemetry channels.
+ */
+export enum TelemetryChannel {
+ LISTS = 'security-lists-v2',
+ ENDPOINT_META = 'endpoint-metadata',
+ ENDPOINT_ALERTS = 'alerts-endpoint',
+ DETECTION_ALERTS = 'alerts-detections',
+ TIMELINE = 'alerts-timeline',
+ INSIGHTS = 'security-insights-v1',
+ TASK_METRICS = 'task-metrics',
+}
+
+export enum TelemetryCounter {
+ DOCS_SENT = 'docs_sent',
+ DOCS_LOST = 'docs_lost',
+ DOCS_DROPPED = 'docs_dropped',
+ HTTP_STATUS = 'http_status',
+ RUNTIME_ERROR = 'runtime_error',
+ FATAL_ERROR = 'fatal_error',
+ TELEMETRY_OPTED_OUT = 'telemetry_opted_out',
+ TELEMETRY_NOT_REACHABLE = 'telemetry_not_reachable',
+}
+
// EP Policy Response
export interface EndpointPolicyResponseAggregation {
@@ -428,21 +453,22 @@ export interface ValueListIndicatorMatchResponseAggregation {
};
}
-export interface TaskMetric {
- name: string;
- passed: boolean;
- time_executed_in_ms: number;
- start_time: number;
- end_time: number;
- error_message?: string;
-}
-
export interface TelemetryConfiguration {
telemetry_max_buffer_size: number;
max_security_list_telemetry_batch: number;
max_endpoint_telemetry_batch: number;
max_detection_rule_telemetry_batch: number;
max_detection_alerts_batch: number;
+ use_async_sender: boolean;
+ sender_channels?: {
+ [key: string]: TelemetrySenderChannelConfiguration;
+ };
+}
+
+export interface TelemetrySenderChannelConfiguration {
+ buffer_time_span_millis: number;
+ inflight_events_threshold: number;
+ max_payload_size_bytes: number;
}
export interface TelemetryFilterListArtifact {
diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts
index 1af0d9171153b..005a42b320fbe 100644
--- a/x-pack/plugins/security_solution/server/plugin.ts
+++ b/x-pack/plugins/security_solution/server/plugin.ts
@@ -65,7 +65,13 @@ import { initUsageCollectors } from './usage';
import type { SecuritySolutionRequestHandlerContext } from './types';
import { securitySolutionSearchStrategyProvider } from './search_strategy/security_solution';
import type { ITelemetryEventsSender } from './lib/telemetry/sender';
+import { type IAsyncTelemetryEventsSender } from './lib/telemetry/async_sender.types';
import { TelemetryEventsSender } from './lib/telemetry/sender';
+import {
+ DEFAULT_QUEUE_CONFIG,
+ DEFAULT_RETRY_CONFIG,
+ AsyncTelemetryEventsSender,
+} from './lib/telemetry/async_sender';
import type { ITelemetryReceiver } from './lib/telemetry/receiver';
import { TelemetryReceiver } from './lib/telemetry/receiver';
import { licenseService } from './lib/license';
@@ -137,6 +143,7 @@ export class Plugin implements ISecuritySolutionPlugin {
private readonly endpointAppContextService = new EndpointAppContextService();
private readonly telemetryReceiver: ITelemetryReceiver;
private readonly telemetryEventsSender: ITelemetryEventsSender;
+ private readonly asyncTelemetryEventsSender: IAsyncTelemetryEventsSender;
private lists: ListPluginSetup | undefined; // TODO: can we create ListPluginStart?
private licensing$!: Observable;
@@ -158,6 +165,7 @@ export class Plugin implements ISecuritySolutionPlugin {
this.ruleMonitoringService = createRuleMonitoringService(this.config, this.logger);
this.telemetryEventsSender = new TelemetryEventsSender(this.logger);
+ this.asyncTelemetryEventsSender = new AsyncTelemetryEventsSender(this.logger);
this.telemetryReceiver = new TelemetryReceiver(this.logger);
this.logger.debug('plugin initialized');
@@ -458,11 +466,20 @@ export class Plugin implements ISecuritySolutionPlugin {
setIsElasticCloudDeployment(plugins.cloud.isCloudEnabled ?? false);
+ this.asyncTelemetryEventsSender.setup(
+ DEFAULT_RETRY_CONFIG,
+ DEFAULT_QUEUE_CONFIG,
+ this.telemetryReceiver,
+ plugins.telemetry,
+ this.telemetryUsageCounter
+ );
+
this.telemetryEventsSender.setup(
this.telemetryReceiver,
plugins.telemetry,
plugins.taskManager,
- this.telemetryUsageCounter
+ this.telemetryUsageCounter,
+ this.asyncTelemetryEventsSender
);
this.checkMetadataTransformsTask = new CheckMetadataTransformsTask({
@@ -632,6 +649,8 @@ export class Plugin implements ISecuritySolutionPlugin {
artifactService.start(this.telemetryReceiver);
+ this.asyncTelemetryEventsSender.start(plugins.telemetry);
+
this.telemetryEventsSender.start(
plugins.telemetry,
plugins.taskManager,
@@ -661,6 +680,7 @@ export class Plugin implements ISecuritySolutionPlugin {
public stop() {
this.logger.debug('Stopping plugin');
+ this.asyncTelemetryEventsSender.stop();
this.telemetryEventsSender.stop();
this.endpointAppContextService.stop();
this.policyWatcher?.stop();
diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json
index 99d6524aa3d7b..57c80161959fd 100644
--- a/x-pack/plugins/security_solution/tsconfig.json
+++ b/x-pack/plugins/security_solution/tsconfig.json
@@ -185,5 +185,6 @@
"@kbn/core-elasticsearch-server-mocks",
"@kbn/lens-embeddable-utils",
"@kbn/esql-utils",
+ "@kbn/core-test-helpers-kbn-server"
]
}
diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/all_types.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/all_types.ts
index 59da2429f01e8..64b4897dbc8a3 100644
--- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/all_types.ts
+++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/all_types.ts
@@ -52,7 +52,7 @@ export default ({ getService }: FtrProviderContext) => {
detection_rules: [
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext) => {
security_lists: [
[
{
- name: 'Security Solution Lists Telemetry',
+ name: 'security:telemetry-lists',
passed: true,
},
],
@@ -68,7 +68,7 @@ export default ({ getService }: FtrProviderContext) => {
endpoints: [
[
{
- name: 'Security Solution Telemetry Endpoint Metrics and Info task',
+ name: 'security:endpoint-meta-telemetry',
passed: true,
},
],
@@ -76,7 +76,7 @@ export default ({ getService }: FtrProviderContext) => {
diagnostics: [
[
{
- name: 'Security Solution Telemetry Diagnostics task',
+ name: 'security:endpoint-diagnostics',
passed: true,
},
],
diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/detection_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/detection_rules.ts
index 1298e9aeb9eaa..069484a338b3b 100644
--- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/detection_rules.ts
+++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/task_based/detection_rules.ts
@@ -101,7 +101,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(stats.detection_rules).to.eql([
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -157,7 +157,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(stats.detection_rules).to.eql([
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -213,7 +213,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(stats.detection_rules).to.eql([
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -269,7 +269,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(stats.detection_rules).to.eql([
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -325,7 +325,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(stats.detection_rules).to.eql([
[
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
],
@@ -471,7 +471,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[0].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
@@ -548,7 +548,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[0].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
@@ -625,7 +625,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[0].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
@@ -702,7 +702,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[0].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
@@ -779,7 +779,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[0].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
@@ -898,7 +898,7 @@ export default ({ getService }: FtrProviderContext) => {
rule_version: detectionRules[1].rule_version,
},
{
- name: 'Security Solution Detection Rule Lists Telemetry',
+ name: 'security:telemetry-detection-rules',
passed: true,
},
]);
From 7f4208ac9bdada419e8f75871e89370274e66ae1 Mon Sep 17 00:00:00 2001
From: Nathan Reese
Date: Mon, 12 Feb 2024 14:22:01 -0700
Subject: [PATCH 27/83] [maps][ESQL] fix esql layers do not support spatial
filters drawn on map (#176753)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes https://github.com/elastic/kibana/issues/176752
### test instructions
1. install sample web logs
2. create new map
3. add "ES|QL" layer
4. verify drawing tools are enable on map and drawing a filter narrows
ES|QL layer.
---
.../common/descriptor_types/source_descriptor_types.ts | 4 +++-
x-pack/plugins/maps/public/classes/layers/layer.tsx | 5 ++++-
.../maps/public/classes/sources/es_source/types.ts | 2 +-
.../public/classes/sources/esql_source/esql_source.tsx | 10 +++++++++-
4 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts
index ebbbfd3fde1c1..fe48ce446b5c7 100644
--- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts
+++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts
@@ -51,7 +51,9 @@ export type ESQLSourceDescriptor = AbstractSourceDescriptor & {
*/
dateField?: string;
/*
- * Geo field used to narrow ES|QL requests by visible map area
+ * Geo field used to narrow ES|QL requests by
+ * 1. by visible map area
+ * 2. spatial filters drawn on map
*/
geoField?: string;
narrowByGlobalSearch: boolean;
diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx
index aa39cf017eb0d..9f9cda88c78b2 100644
--- a/x-pack/plugins/maps/public/classes/layers/layer.tsx
+++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx
@@ -577,7 +577,10 @@ export class AbstractLayer implements ILayer {
getGeoFieldNames(): string[] {
const source = this.getSource();
- return hasESSourceMethod(source, 'getGeoFieldName') ? [source.getGeoFieldName()] : [];
+ const geoFieldName = hasESSourceMethod(source, 'getGeoFieldName')
+ ? source.getGeoFieldName()
+ : undefined;
+ return geoFieldName ? [geoFieldName] : [];
}
async getStyleMetaDescriptorFromLocalFeatures(): Promise {
diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/types.ts b/x-pack/plugins/maps/public/classes/sources/es_source/types.ts
index 385a00b900488..1f0d698176f7c 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_source/types.ts
+++ b/x-pack/plugins/maps/public/classes/sources/es_source/types.ts
@@ -49,7 +49,7 @@ export interface IESSource extends IVectorSource {
getIndexPatternId(): string;
- getGeoFieldName(): string;
+ getGeoFieldName(): string | undefined;
loadStylePropsMeta({
layerName,
diff --git a/x-pack/plugins/maps/public/classes/sources/esql_source/esql_source.tsx b/x-pack/plugins/maps/public/classes/sources/esql_source/esql_source.tsx
index 53745e7426b70..1f95158c34b2a 100644
--- a/x-pack/plugins/maps/public/classes/sources/esql_source/esql_source.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/esql_source/esql_source.tsx
@@ -28,6 +28,7 @@ import { isValidStringConfig } from '../../util/valid_string_config';
import type { SourceEditorArgs } from '../source';
import { AbstractVectorSource, getLayerFeaturesRequestName } from '../vector_source';
import type { IVectorSource, GeoJsonWithMeta, SourceStatus } from '../vector_source';
+import type { IESSource } from '../es_source';
import type { IField } from '../../fields/field';
import { InlineField } from '../../fields/inline_field';
import { getData, getUiSettings } from '../../../kibana_services';
@@ -44,7 +45,10 @@ export const sourceTitle = i18n.translate('xpack.maps.source.esqlSearchTitle', {
defaultMessage: 'ES|QL',
});
-export class ESQLSource extends AbstractVectorSource implements IVectorSource {
+export class ESQLSource
+ extends AbstractVectorSource
+ implements IVectorSource, Pick
+{
readonly _descriptor: ESQLSourceDescriptor;
static createDescriptor(descriptor: Partial): ESQLSourceDescriptor {
@@ -325,4 +329,8 @@ export class ESQLSource extends AbstractVectorSource implements IVectorSource {
getIndexPatternId() {
return this._descriptor.dataViewId;
}
+
+ getGeoFieldName() {
+ return this._descriptor.geoField;
+ }
}
From 509f9ed4fe7e598e809eeebfe804755043fb0c89 Mon Sep 17 00:00:00 2001
From: Rickyanto Ang
Date: Mon, 12 Feb 2024 13:43:13 -0800
Subject: [PATCH 28/83] [Cloud Security] Added Toast message that pops up when
user enables / disables rule (#176462)
## Summary
This PR adds a toast message that pops up whenever user enables or
disables rules.
The toast message will show the number of rules that got disabled/enable
and detection rules (if there's any)
https://github.com/elastic/kibana/assets/8703149/6b394fc4-82ca-4afa-a6b4-e504e8a83f0c
---
.../common/utils/detection_rules.test.ts | 59 +++++++++++++++++--
.../common/utils/detection_rules.ts | 33 ++++++++++-
.../api/use_fetch_detection_rules_by_tags.ts | 39 ++++++++----
.../public/components/take_action.tsx | 44 ++++++++++++--
.../public/pages/rules/rules_flyout.tsx | 18 +++++-
.../public/pages/rules/rules_table.tsx | 33 +++++++++--
.../public/pages/rules/rules_table_header.tsx | 15 +++++
.../benchmark_rules/bulk_action/utils.ts | 4 +-
8 files changed, 212 insertions(+), 33 deletions(-)
diff --git a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts
index f2a35944f0825..a067ef4e1871a 100644
--- a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts
+++ b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.test.ts
@@ -7,25 +7,45 @@
import { CspBenchmarkRuleMetadata } from '../types';
import {
- convertRuleTagsToKQL,
+ convertRuleTagsToMatchAllKQL,
+ convertRuleTagsToMatchAnyKQL,
generateBenchmarkRuleTags,
getFindingsDetectionRuleSearchTags,
+ getFindingsDetectionRuleSearchTagsFromArrayOfRules,
} from './detection_rules';
describe('Detection rules utils', () => {
- it('should convert tags to KQL format', () => {
+ it('should convert tags to KQL format with AND operator', () => {
const inputTags = ['tag1', 'tag2', 'tag3'];
- const result = convertRuleTagsToKQL(inputTags);
+ const result = convertRuleTagsToMatchAllKQL(inputTags);
const expectedKQL = 'alert.attributes.tags:("tag1" AND "tag2" AND "tag3")';
expect(result).toBe(expectedKQL);
});
- it('Should convert tags to KQL format', () => {
+ it('Should convert tags to KQL format with AND Operator (empty array)', () => {
const inputTags = [] as string[];
- const result = convertRuleTagsToKQL(inputTags);
+ const result = convertRuleTagsToMatchAllKQL(inputTags);
+
+ const expectedKQL = 'alert.attributes.tags:()';
+ expect(result).toBe(expectedKQL);
+ });
+
+ it('should convert tags to KQL format with OR Operator', () => {
+ const inputTags = ['tag1', 'tag2', 'tag3'];
+
+ const result = convertRuleTagsToMatchAnyKQL(inputTags);
+
+ const expectedKQL = 'alert.attributes.tags:("tag1" OR "tag2" OR "tag3")';
+ expect(result).toBe(expectedKQL);
+ });
+
+ it('Should convert tags to KQL format with OR Operator (empty array)', () => {
+ const inputTags = [] as string[];
+
+ const result = convertRuleTagsToMatchAnyKQL(inputTags);
const expectedKQL = 'alert.attributes.tags:()';
expect(result).toBe(expectedKQL);
@@ -63,6 +83,35 @@ describe('Detection rules utils', () => {
expect(result).toEqual(expectedTags);
});
+ it('Should generate search tags for a CSP benchmark rule given an array of Benchmarks', () => {
+ const cspBenchmarkRule = [
+ {
+ benchmark: {
+ id: 'cis_gcp',
+ rule_number: '1.1',
+ },
+ },
+ {
+ benchmark: {
+ id: 'cis_gcp',
+ rule_number: '1.2',
+ },
+ },
+ ] as unknown as CspBenchmarkRuleMetadata[];
+
+ const result = getFindingsDetectionRuleSearchTagsFromArrayOfRules(cspBenchmarkRule);
+
+ const expectedTags = ['CIS GCP 1.1', 'CIS GCP 1.2'];
+ expect(result).toEqual(expectedTags);
+ });
+
+ it('Should handle undefined benchmark object gracefully given an array of empty benchmark', () => {
+ const cspBenchmarkRule = [{ benchmark: {} }] as any;
+ const expectedTags: string[] = [];
+ const result = getFindingsDetectionRuleSearchTagsFromArrayOfRules(cspBenchmarkRule);
+ expect(result).toEqual(expectedTags);
+ });
+
it('Should generate tags for a CSPM benchmark rule', () => {
const cspBenchmarkRule = {
benchmark: {
diff --git a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts
index 42ea7561286c1..f178d412675e6 100644
--- a/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts
+++ b/x-pack/plugins/cloud_security_posture/common/utils/detection_rules.ts
@@ -13,9 +13,14 @@ const CSP_RULE_TAG_DATA_SOURCE_PREFIX = 'Data Source: ';
const STATIC_RULE_TAGS = [CSP_RULE_TAG, CSP_RULE_TAG_USE_CASE];
-export const convertRuleTagsToKQL = (tags: string[]): string => {
+export const convertRuleTagsToMatchAllKQL = (tags: string[]): string => {
const TAGS_FIELD = 'alert.attributes.tags';
- return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(' AND ')})`;
+ return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(` AND `)})`;
+};
+
+export const convertRuleTagsToMatchAnyKQL = (tags: string[]): string => {
+ const TAGS_FIELD = 'alert.attributes.tags';
+ return `${TAGS_FIELD}:(${tags.map((tag) => `"${tag}"`).join(` OR `)})`;
};
/*
@@ -42,6 +47,30 @@ export const getFindingsDetectionRuleSearchTags = (
return benchmarkIdTags.concat([benchmarkRuleNumberTag]);
};
+export const getFindingsDetectionRuleSearchTagsFromArrayOfRules = (
+ cspBenchmarkRules: CspBenchmarkRuleMetadata[]
+): string[] => {
+ if (
+ !cspBenchmarkRules ||
+ !cspBenchmarkRules.some((rule) => rule.benchmark) ||
+ !cspBenchmarkRules.some((rule) => rule.benchmark.id)
+ ) {
+ return [];
+ }
+
+ // we can just take the first benchmark id because we Know that the array will ONLY contain 1 kind of id
+ const benchmarkIds = cspBenchmarkRules.map((rule) => rule.benchmark.id);
+ if (benchmarkIds.length === 0) return [];
+ const benchmarkId = benchmarkIds[0];
+ const benchmarkRuleNumbers = cspBenchmarkRules.map((rule) => rule.benchmark.rule_number);
+ if (benchmarkRuleNumbers.length === 0) return [];
+ const benchmarkTagArray = benchmarkRuleNumbers.map(
+ (tag) => benchmarkId.replace('_', ' ').toUpperCase() + ' ' + tag
+ );
+ // we want the tags to only consist of a format like this CIS AWS 1.1.0
+ return benchmarkTagArray;
+};
+
export const generateBenchmarkRuleTags = (rule: CspBenchmarkRuleMetadata) => {
return [STATIC_RULE_TAGS]
.concat(getFindingsDetectionRuleSearchTags(rule))
diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts
index dfd6f13e38692..da95711cbb383 100644
--- a/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts
+++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_fetch_detection_rules_by_tags.ts
@@ -5,13 +5,16 @@
* 2.0.
*/
-import { CoreStart } from '@kbn/core/public';
+import { CoreStart, HttpSetup } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { DETECTION_RULE_RULES_API_CURRENT_VERSION } from '../../../common/constants';
import { RuleResponse } from '../types';
import { DETECTION_ENGINE_RULES_KEY } from '../constants';
-import { convertRuleTagsToKQL } from '../../../common/utils/detection_rules';
+import {
+ convertRuleTagsToMatchAllKQL,
+ convertRuleTagsToMatchAnyKQL,
+} from '../../../common/utils/detection_rules';
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
@@ -31,20 +34,32 @@ const DETECTION_ENGINE_URL = '/api/detection_engine' as const;
const DETECTION_ENGINE_RULES_URL = `${DETECTION_ENGINE_URL}/rules` as const;
export const DETECTION_ENGINE_RULES_URL_FIND = `${DETECTION_ENGINE_RULES_URL}/_find` as const;
-export const useFetchDetectionRulesByTags = (tags: string[]) => {
+export const useFetchDetectionRulesByTags = (
+ tags: string[],
+ option: { match: 'all' | 'any' } = { match: 'all' }
+) => {
const { http } = useKibana().services;
+ return useQuery([DETECTION_ENGINE_RULES_KEY, tags, option], () =>
+ fetchDetectionRulesByTags(tags, option, http)
+ );
+};
+export const fetchDetectionRulesByTags = (
+ tags: string[],
+ option: { match: 'all' | 'any' } = { match: 'all' },
+ http: HttpSetup
+) => {
const query = {
page: 1,
per_page: 1,
- filter: convertRuleTagsToKQL(tags),
+ filter:
+ option.match === 'all'
+ ? convertRuleTagsToMatchAllKQL(tags)
+ : convertRuleTagsToMatchAnyKQL(tags),
};
-
- return useQuery([DETECTION_ENGINE_RULES_KEY, tags], () =>
- http.fetch(DETECTION_ENGINE_RULES_URL_FIND, {
- method: 'GET',
- version: DETECTION_RULE_RULES_API_CURRENT_VERSION,
- query,
- })
- );
+ return http.fetch(DETECTION_ENGINE_RULES_URL_FIND, {
+ method: 'GET',
+ version: DETECTION_RULE_RULES_API_CURRENT_VERSION,
+ query,
+ });
};
diff --git a/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx b/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx
index caf7f34651997..054fc9f3759ff 100644
--- a/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/components/take_action.tsx
@@ -80,7 +80,11 @@ export const showCreateDetectionRuleSuccessToast = (
export const showChangeBenchmarkRuleStatesSuccessToast = (
notifications: NotificationsStart,
- isBenchmarkRuleMuted: boolean
+ isBenchmarkRuleMuted: boolean,
+ data: {
+ numberOfRules: number;
+ numberOfDetectionRules: number;
+ }
) => {
return notifications.toasts.addSuccess({
toastLifeTimeMs: 10000,
@@ -101,9 +105,23 @@ export const showChangeBenchmarkRuleStatesSuccessToast = (
+ {data.numberOfDetectionRules > 0 ? (
+
+
+
+ ) : undefined}
>
) : (
<>
@@ -115,10 +133,26 @@ export const showChangeBenchmarkRuleStatesSuccessToast = (
/>
+
+
+ {data.numberOfDetectionRules > 0 ? (
+
+
+
+ ) : undefined}
>
)}
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx
index 333f958de6fb1..5026884173b6e 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx
@@ -21,15 +21,20 @@ import {
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-
import { FormattedMessage } from '@kbn/i18n-react';
+import { useKibana } from '../../common/hooks/use_kibana';
+import { getFindingsDetectionRuleSearchTags } from '../../../common/utils/detection_rules';
import { CspBenchmarkRuleMetadata } from '../../../common/types/latest';
import { getRuleList } from '../configurations/findings_flyout/rule_tab';
import { getRemediationList } from '../configurations/findings_flyout/overview_tab';
import * as TEST_SUBJECTS from './test_subjects';
import { useChangeCspRuleState } from './change_csp_rule_state';
import { CspBenchmarkRulesWithStates } from './rules_container';
-import { TakeAction } from '../../components/take_action';
+import {
+ showChangeBenchmarkRuleStatesSuccessToast,
+ TakeAction,
+} from '../../components/take_action';
+import { useFetchDetectionRulesByTags } from '../../common/api/use_fetch_detection_rules_by_tags';
export const RULES_FLYOUT_SWITCH_BUTTON = 'rule-flyout-switch-button';
@@ -61,8 +66,11 @@ type RuleTab = typeof tabs[number]['id'];
export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProps) => {
const [tab, setTab] = useState('overview');
const postRequestChangeRulesStates = useChangeCspRuleState();
+ const { data: rulesData } = useFetchDetectionRulesByTags(
+ getFindingsDetectionRuleSearchTags(rule.metadata)
+ );
const isRuleMuted = rule?.state === 'muted';
-
+ const { notifications } = useKibana().services;
const switchRuleStates = async () => {
if (rule.metadata.benchmark.rule_number) {
const rulesObjectRequest = {
@@ -74,6 +82,10 @@ export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProp
const nextRuleStates = isRuleMuted ? 'unmute' : 'mute';
await postRequestChangeRulesStates(nextRuleStates, [rulesObjectRequest]);
await refetchRulesStates();
+ await showChangeBenchmarkRuleStatesSuccessToast(notifications, isRuleMuted, {
+ numberOfRules: 1,
+ numberOfDetectionRules: rulesData?.total || 0,
+ });
}
};
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx
index 81a04de69f174..586f0b9dee0cf 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx
@@ -20,10 +20,15 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { uniqBy } from 'lodash';
+import { CoreStart, HttpSetup, NotificationsStart } from '@kbn/core/public';
+import { useKibana } from '@kbn/kibana-react-plugin/public';
+import { getFindingsDetectionRuleSearchTags } from '../../../common/utils/detection_rules';
import { ColumnNameWithTooltip } from '../../components/column_name_with_tooltip';
import type { CspBenchmarkRulesWithStates, RulesState } from './rules_container';
import * as TEST_SUBJECTS from './test_subjects';
import { RuleStateAttributesWithoutStates, useChangeCspRuleState } from './change_csp_rule_state';
+import { showChangeBenchmarkRuleStatesSuccessToast } from '../../components/take_action';
+import { fetchDetectionRulesByTags } from '../../common/api/use_fetch_detection_rules_by_tags';
export const RULES_ROWS_ENABLE_SWITCH_BUTTON = 'rules-row-enable-switch-button';
export const RULES_ROW_SELECT_ALL_CURRENT_PAGE = 'cloud-security-fields-selector-item-all';
@@ -56,6 +61,8 @@ type GetColumnProps = Pick<
currentPageRulesArray: CspBenchmarkRulesWithStates[],
selectedRulesArray: CspBenchmarkRulesWithStates[]
) => boolean;
+ notifications: NotificationsStart;
+ http: HttpSetup;
};
export const RulesTable = ({
@@ -87,7 +94,6 @@ export const RulesTable = ({
direction: sortDirection,
},
};
-
const onTableChange = ({
page: pagination,
sort: sortOrder,
@@ -108,6 +114,7 @@ export const RulesTable = ({
});
const [isAllRulesSelectedThisPage, setIsAllRulesSelectedThisPage] = useState(false);
+
const postRequestChangeRulesStates = useChangeCspRuleState();
const isCurrentPageRulesASubset = (
@@ -125,6 +132,7 @@ export const RulesTable = ({
return true;
};
+ const { http, notifications } = useKibana().services;
useEffect(() => {
if (selectedRules.length >= items.length && items.length > 0 && selectedRules.length > 0)
setIsAllRulesSelectedThisPage(true);
@@ -143,6 +151,8 @@ export const RulesTable = ({
isAllRulesSelectedThisPage,
isCurrentPageRulesASubset,
onRuleClick,
+ notifications,
+ http,
}),
[
refetchRulesStates,
@@ -152,6 +162,8 @@ export const RulesTable = ({
items,
isAllRulesSelectedThisPage,
onRuleClick,
+ notifications,
+ http,
]
);
@@ -182,6 +194,8 @@ const getColumns = ({
isAllRulesSelectedThisPage,
isCurrentPageRulesASubset,
onRuleClick,
+ notifications,
+ http,
}: GetColumnProps): Array> => [
{
field: 'action',
@@ -296,11 +310,22 @@ const getColumns = ({
};
const isRuleMuted = rule?.state === 'muted';
const nextRuleState = isRuleMuted ? 'unmute' : 'mute';
-
- const useChangeCspRuleStateFn = async () => {
+ const changeCspRuleStateFn = async () => {
if (rule?.metadata.benchmark.rule_number) {
+ // Calling this function this way to make sure it didn't get called on every single row render, its only being called when user click on the switch button
+ const detectionRuleCount = (
+ await fetchDetectionRulesByTags(
+ getFindingsDetectionRuleSearchTags(rule.metadata),
+ { match: 'all' },
+ http
+ )
+ ).total;
await postRequestChangeRulesStates(nextRuleState, [rulesObjectRequest]);
await refetchRulesStates();
+ await showChangeBenchmarkRuleStatesSuccessToast(notifications, isRuleMuted, {
+ numberOfRules: 1,
+ numberOfDetectionRules: detectionRuleCount || 0,
+ });
}
};
return (
@@ -309,7 +334,7 @@ const getColumns = ({
!e);
};
+ const { data: rulesData } = useFetchDetectionRulesByTags(
+ getFindingsDetectionRuleSearchTagsFromArrayOfRules(selectedRules.map((rule) => rule.metadata)),
+ { match: 'any' }
+ );
+
+ const { notifications } = useKibana().services;
+
const postRequestChangeRulesState = useChangeCspRuleState();
const changeRulesState = async (state: 'mute' | 'unmute') => {
const bulkSelectedRules: RuleStateAttributesWithoutStates[] = selectedRules.map(
@@ -283,6 +294,10 @@ const CurrentPageOfTotal = ({
await postRequestChangeRulesState(state, bulkSelectedRules);
await refetchRulesStates();
await setIsPopoverOpen(false);
+ await showChangeBenchmarkRuleStatesSuccessToast(notifications, state !== 'mute', {
+ numberOfRules: bulkSelectedRules.length,
+ numberOfDetectionRules: rulesData?.total || 0,
+ });
}
};
const changeCspRuleStateMute = async () => {
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts
index 9efb4267a385d..5b592b6b9926c 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts
@@ -15,7 +15,7 @@ import type {
CspSettings,
} from '../../../../common/types/rules/v4';
import {
- convertRuleTagsToKQL,
+ convertRuleTagsToMatchAllKQL,
generateBenchmarkRuleTags,
} from '../../../../common/utils/detection_rules';
@@ -55,7 +55,7 @@ export const getDetectionRules = async (
return detectionRulesClient.find({
excludeFromPublicApi: false,
options: {
- filter: convertRuleTagsToKQL(ruleTags),
+ filter: convertRuleTagsToMatchAllKQL(ruleTags),
searchFields: ['tags'],
page: 1,
per_page: 1,
From 028b99332aae2a12f09975236bb19a25322a17be Mon Sep 17 00:00:00 2001
From: Nicolas Chaulet
Date: Mon, 12 Feb 2024 17:21:41 -0500
Subject: [PATCH 29/83] [Fleet] Allow to skip package verification with force
flag when creating a policy (#176738)
---
.../plugins/fleet/common/openapi/bundled.json | 22 +-
.../plugins/fleet/common/openapi/bundled.yaml | 203 +++++++++---------
.../schemas/agent_policy_create_request.yaml | 3 +
.../schemas/agent_policy_update_request.yaml | 3 +
.../server/routes/agent_policy/handlers.ts | 2 +
.../fleet/server/services/agent_policy.ts | 1 +
.../server/services/agent_policy_create.ts | 3 +
.../fleet/server/types/models/agent_policy.ts | 1 +
.../apis/agent_policy/agent_policy.ts | 5 +-
9 files changed, 134 insertions(+), 109 deletions(-)
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json
index 127ed8a877354..9418cca89888f 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.json
+++ b/x-pack/plugins/fleet/common/openapi/bundled.json
@@ -1,6 +1,5 @@
{
"openapi": "3.0.0",
- "tags": [],
"info": {
"title": "Fleet",
"description": "OpenAPI schema for Fleet API endpoints",
@@ -19,6 +18,12 @@
"description": "local"
}
],
+ "security": [
+ {
+ "basicAuth": []
+ }
+ ],
+ "tags": [],
"paths": {
"/health_check": {
"post": {
@@ -7385,6 +7390,10 @@
},
"is_protected": {
"type": "boolean"
+ },
+ "force": {
+ "type": "boolean",
+ "description": "Force agent policy creation even if packages are not verified."
}
},
"required": [
@@ -7457,6 +7466,10 @@
},
"is_protected": {
"type": "boolean"
+ },
+ "force": {
+ "type": "boolean",
+ "description": "Force agent policy creation even if packages are not verified."
}
},
"required": [
@@ -9066,10 +9079,5 @@
]
}
}
- },
- "security": [
- {
- "basicAuth": []
- }
- ]
+ }
}
\ No newline at end of file
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml
index 849c22b47069a..04f7dc77e184f 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.yaml
+++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml
@@ -1,5 +1,4 @@
openapi: 3.0.0
-tags: []
info:
title: Fleet
description: OpenAPI schema for Fleet API endpoints
@@ -12,6 +11,9 @@ info:
servers:
- url: http://localhost:5601/api/fleet
description: local
+security:
+ - basicAuth: []
+tags: []
paths:
/health_check:
post:
@@ -180,9 +182,7 @@ paths:
id:
type: string
nullable: true
- description: >-
- the key ID of the GPG key used to verify package
- signatures
+ description: the key ID of the GPG key used to verify package signatures
statusCode:
type: number
headers:
@@ -245,9 +245,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- Whether to include prerelease packages in categories count (e.g. beta,
- rc, preview)
+ description: Whether to include prerelease packages in categories count (e.g. beta, rc, preview)
- in: query
name: experimental
deprecated: true
@@ -301,20 +299,13 @@ paths:
schema:
type: boolean
default: false
- description: >-
- Whether to exclude the install status of each package. Enabling this
- option will opt in to caching for the response via `cache-control`
- headers. If you don't need up-to-date installation info for a
- package, and are querying for a list of available packages,
- providing this flag can improve performance substantially.
+ description: Whether to exclude the install status of each package. Enabling this option will opt in to caching for the response via `cache-control` headers. If you don't need up-to-date installation info for a package, and are querying for a list of available packages, providing this flag can improve performance substantially.
- in: query
name: prerelease
schema:
type: boolean
default: false
- description: >-
- Whether to return prerelease versions of packages (e.g. beta, rc,
- preview)
+ description: Whether to return prerelease versions of packages (e.g. beta, rc, preview)
- in: query
name: experimental
deprecated: true
@@ -379,9 +370,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- skip data stream rollover during index template mapping or settings
- update
+ description: skip data stream rollover during index template mapping or settings update
requestBody:
content:
application/zip:
@@ -413,9 +402,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- Whether to return prerelease versions of packages (e.g. beta, rc,
- preview)
+ description: Whether to return prerelease versions of packages (e.g. beta, rc, preview)
requestBody:
content:
application/json:
@@ -487,9 +474,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- Whether to return prerelease versions of packages (e.g. beta, rc,
- preview)
+ description: Whether to return prerelease versions of packages (e.g. beta, rc, preview)
deprecated: true
post:
summary: Install package
@@ -541,9 +526,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- skip data stream rollover during index template mapping or settings
- update
+ description: skip data stream rollover during index template mapping or settings update
requestBody:
content:
application/json:
@@ -662,18 +645,14 @@ paths:
- schema:
type: boolean
name: full
- description: >-
- Return all fields from the package manifest, not just those supported
- by the Elastic Package Registry
+ description: Return all fields from the package manifest, not just those supported by the Elastic Package Registry
in: query
- in: query
name: prerelease
schema:
type: boolean
default: false
- description: >-
- Whether to return prerelease versions of packages (e.g. beta, rc,
- preview)
+ description: Whether to return prerelease versions of packages (e.g. beta, rc, preview)
post:
summary: Install package
tags:
@@ -728,9 +707,7 @@ paths:
schema:
type: boolean
default: false
- description: >-
- skip data stream rollover during index template mapping or settings
- update
+ description: skip data stream rollover during index template mapping or settings update
requestBody:
content:
application/json:
@@ -828,6 +805,70 @@ paths:
properties:
force:
type: boolean
+ /epm/packages/{pkgName}/{pkgVersion}/transforms/authorize:
+ post:
+ summary: Authorize transforms
+ tags:
+ - Elastic Package Manager (EPM)
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ type: object
+ properties:
+ transformId:
+ type: string
+ success:
+ type: boolean
+ error:
+ type: string
+ required:
+ - transformId
+ - error
+ required:
+ - items
+ '400':
+ $ref: '#/components/responses/error'
+ operationId: reauthorize-transforms
+ description: ''
+ parameters:
+ - $ref: '#/components/parameters/kbn_xsrf'
+ - schema:
+ type: string
+ name: pkgName
+ in: path
+ required: true
+ - schema:
+ type: string
+ name: pkgVersion
+ in: path
+ required: true
+ - in: query
+ name: prerelease
+ schema:
+ type: boolean
+ default: false
+ description: Whether to include prerelease packages in categories count (e.g. beta, rc, preview)
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ transforms:
+ type: array
+ items:
+ type: object
+ properties:
+ transformId:
+ type: string
/epm/packages/{pkgName}/{pkgVersion}/{filePath}:
get:
summary: Get package file
@@ -1301,9 +1342,7 @@ paths:
description: creation time of action
latestErrors:
type: array
- description: >-
- latest errors that happened when the agents executed
- the action
+ description: latest errors that happened when the agents executed the action
items:
type: object
properties:
@@ -1771,9 +1810,7 @@ paths:
description: Unenrolls hosted agents too
includeInactive:
type: boolean
- description: >-
- When passing agents by KQL query, unenrolls inactive agents
- too
+ description: When passing agents by KQL query, unenrolls inactive agents too
required:
- agents
example:
@@ -1956,18 +1993,12 @@ paths:
type: boolean
in: query
name: full
- description: >-
- When set to true, retrieve the related package policies for each
- agent policy.
+ description: When set to true, retrieve the related package policies for each agent policy.
- schema:
type: boolean
in: query
name: noAgentCount
- description: >-
- When set to true, do not count how many agents are in the agent
- policy, this can improve performance if you are searching over a
- large number of agent policies. The "agents" property will always be
- 0 if set to true.
+ description: When set to true, do not count how many agents are in the agent policy, this can improve performance if you are searching over a large number of agent policies. The "agents" property will always be 0 if set to true.
description: ''
post:
summary: Create agent policy
@@ -2547,9 +2578,7 @@ paths:
'409':
$ref: '#/components/responses/error'
requestBody:
- description: >-
- You should use inputs as an object and not use the deprecated inputs
- array.
+ description: You should use inputs as an object and not use the deprecated inputs array.
content:
application/json:
schema:
@@ -4013,9 +4042,7 @@ components:
release:
type: string
deprecated: true
- description: >-
- release label is deprecated, derive from the version instead
- (packages follow semver)
+ description: release label is deprecated, derive from the version instead (packages follow semver)
enum:
- experimental
- beta
@@ -4294,9 +4321,7 @@ components:
properties:
cpu_avg:
type: number
- description: >-
- Average agent CPU usage during the last 5 minutes, number
- between 0-1
+ description: Average agent CPU usage during the last 5 minutes, number between 0-1
memory_size_byte_avg:
type: number
description: Average agent memory consumption during the last 5 minutes
@@ -4557,9 +4582,7 @@ components:
- metrics
- logs
keep_monitoring_alive:
- description: >-
- When set to true, monitoring will be enabled but logs/metrics
- collection will be disabled
+ description: When set to true, monitoring will be enabled but logs/metrics collection will be disabled
type: boolean
nullable: true
data_output_id:
@@ -4579,10 +4602,7 @@ components:
inactivity_timeout:
type: integer
package_policies:
- description: >-
- This field is present only when retrieving a single agent policy, or
- when retrieving a list of agent policies with the ?full=true
- parameter
+ description: This field is present only when retrieving a single agent policy, or when retrieving a list of agent policies with the ?full=true parameter
type: array
items:
$ref: '#/components/schemas/package_policy'
@@ -4608,16 +4628,11 @@ components:
- name
- enabled
is_protected:
- description: >-
- Indicates whether the agent policy has tamper protection enabled.
- Default false.
+ description: Indicates whether the agent policy has tamper protection enabled. Default false.
type: boolean
overrides:
type: object
- description: >-
- Override settings that are defined in the agent policy. Input
- settings cannot be overridden. The override option should be used
- only in unusual circumstances and not as a routine procedure.
+ description: Override settings that are defined in the agent policy. Input settings cannot be overridden. The override option should be used only in unusual circumstances and not as a routine procedure.
nullable: true
required:
- id
@@ -4673,6 +4688,9 @@ components:
- enabled
is_protected:
type: boolean
+ force:
+ type: boolean
+ description: Force agent policy creation even if packages are not verified.
required:
- name
- namespace
@@ -4723,6 +4741,9 @@ components:
- enabled
is_protected:
type: boolean
+ force:
+ type: boolean
+ description: Force agent policy creation even if packages are not verified.
required:
- name
- namespace
@@ -4936,9 +4957,7 @@ components:
example: my description
namespace:
type: string
- description: >-
- The package policy namespace. Leave blank to inherit the agent
- policy's namespace.
+ description: The package policy namespace. Leave blank to inherit the agent policy's namespace.
example: customnamespace
policy_id:
type: string
@@ -4960,14 +4979,10 @@ components:
- version
vars:
type: object
- description: >-
- Package root level variable (see integration documentation for more
- information)
+ description: Package root level variable (see integration documentation for more information)
inputs:
type: object
- description: >-
- Package policy inputs (see integration documentation to know what
- inputs are available)
+ description: Package policy inputs (see integration documentation to know what inputs are available)
example:
nginx-logfile:
enabled: true
@@ -4989,14 +5004,10 @@ components:
description: enable or disable that input, (default to true)
vars:
type: object
- description: >-
- Input level variable (see integration documentation for more
- information)
+ description: Input level variable (see integration documentation for more information)
streams:
type: object
- description: >-
- Input streams (see integration documentation to know what
- streams are available)
+ description: Input streams (see integration documentation to know what streams are available)
additionalProperties:
type: object
properties:
@@ -5005,14 +5016,10 @@ components:
description: enable or disable that stream, (default to true)
vars:
type: object
- description: >-
- Stream level variable (see integration documentation for
- more information)
+ description: Stream level variable (see integration documentation for more information)
force:
type: boolean
- description: >-
- Force package policy creation even if package is not verified, or if
- the agent policy is managed.
+ description: Force package policy creation even if package is not verified, or if the agent policy is managed.
required:
- name
- policy_id
@@ -5747,9 +5754,7 @@ components:
host:
type: string
proxy_id:
- description: >-
- The ID of the proxy to use for this download source. See the proxies
- API for more information.
+ description: The ID of the proxy to use for this download source. See the proxies API for more information.
type: string
nullable: true
required:
@@ -5801,5 +5806,3 @@ components:
required:
- name
- url
-security:
- - basicAuth: []
diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_create_request.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_create_request.yaml
index e1d94c69a0d24..d2b69e37672e8 100644
--- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_create_request.yaml
+++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_create_request.yaml
@@ -46,6 +46,9 @@ properties:
- enabled
is_protected:
type: boolean
+ force:
+ type: boolean
+ description: Force agent policy creation even if packages are not verified.
required:
- name
- namespace
diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_update_request.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_update_request.yaml
index 0500c94871192..7fb5581aa79e4 100644
--- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_update_request.yaml
+++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy_update_request.yaml
@@ -44,6 +44,9 @@ properties:
- enabled
is_protected:
type: boolean
+ force:
+ type: boolean
+ description: Force agent policy creation even if packages are not verified.
required:
- name
- namespace
diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
index 68237bb4e0ac9..259314c0a8c9e 100644
--- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
+++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
@@ -172,6 +172,7 @@ export const createAgentPolicyHandler: FleetRequestHandler<
const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined;
const withSysMonitoring = request.query.sys_monitoring ?? false;
const monitoringEnabled = request.body.monitoring_enabled;
+ const force = request.body.force;
const { has_fleet_server: hasFleetServer, ...newPolicy } = request.body;
const spaceId = fleetContext.spaceId;
const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request, user?.username);
@@ -188,6 +189,7 @@ export const createAgentPolicyHandler: FleetRequestHandler<
spaceId,
user,
authorizationHeader,
+ force,
}),
};
diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts
index a532aab68b228..5907d07b4da38 100644
--- a/x-pack/plugins/fleet/server/services/agent_policy.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policy.ts
@@ -603,6 +603,7 @@ class AgentPolicyService {
packagesToInstall,
spaceId: options?.spaceId || DEFAULT_SPACE_ID,
authorizationHeader: options?.authorizationHeader,
+ force: options?.force,
});
}
diff --git a/x-pack/plugins/fleet/server/services/agent_policy_create.ts b/x-pack/plugins/fleet/server/services/agent_policy_create.ts
index 9d1b3a9f01a5f..a55541c621f83 100644
--- a/x-pack/plugins/fleet/server/services/agent_policy_create.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policy_create.ts
@@ -91,6 +91,7 @@ interface CreateAgentPolicyParams {
spaceId: string;
user?: AuthenticatedUser;
authorizationHeader?: HTTPAuthorizationHeader | null;
+ force?: boolean;
}
export async function createAgentPolicyWithPackages({
@@ -103,6 +104,7 @@ export async function createAgentPolicyWithPackages({
spaceId,
user,
authorizationHeader,
+ force,
}: CreateAgentPolicyParams) {
let agentPolicyId = newPolicy.id;
const packagesToInstall = [];
@@ -128,6 +130,7 @@ export async function createAgentPolicyWithPackages({
packagesToInstall,
spaceId,
authorizationHeader,
+ force,
});
}
diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts
index 8fbad6d90fdaa..518510f0b8454 100644
--- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts
+++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts
@@ -85,6 +85,7 @@ export const AgentPolicyBaseSchema = {
export const NewAgentPolicySchema = schema.object({
...AgentPolicyBaseSchema,
+ force: schema.maybe(schema.boolean()),
});
export const AgentPolicySchema = schema.object({
diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
index e27dff4e9d081..a8a22bd66ed0c 100644
--- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
+++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
@@ -972,8 +972,7 @@ export default function (providerContext: FtrProviderContext) {
);
});
- // Skipped as cannot force install the system and agent integrations as part of policy creation https://github.com/elastic/kibana/issues/137450
- it.skip('should return a 200 if updating monitoring_enabled on a policy', async () => {
+ it('should return a 200 if updating monitoring_enabled on a policy', async () => {
const fetchPackageList = async () => {
const response = await supertest
.get('/api/fleet/epm/packages')
@@ -1017,6 +1016,7 @@ export default function (providerContext: FtrProviderContext) {
description: 'Updated description',
namespace: 'default',
monitoring_enabled: ['logs', 'metrics'],
+ force: true,
})
.expect(200);
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -1029,6 +1029,7 @@ export default function (providerContext: FtrProviderContext) {
description: 'Updated description',
namespace: 'default',
is_managed: false,
+ is_protected: false,
revision: 2,
schema_version: FLEET_AGENT_POLICIES_SCHEMA_VERSION,
updated_by: 'elastic',
From ed2b987b3ca9cc68d3c8953aab44e25295c40aa9 Mon Sep 17 00:00:00 2001
From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Date: Mon, 12 Feb 2024 17:22:00 -0500
Subject: [PATCH 30/83] skip failing test suite (#176757)
---
.../cypress/e2e/explore/navigation/navigation.cy.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
index 1920bd01668f6..3be9029bcb2d4 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
@@ -88,7 +88,8 @@ import {
THREAT_INTELLIGENCE_PAGE,
} from '../../../screens/kibana_navigation';
-describe('top-level navigation common to all pages in the Security app', { tags: '@ess' }, () => {
+// Failing: See https://github.com/elastic/kibana/issues/176757
+describe.skip('top-level navigation common to all pages in the Security app', { tags: '@ess' }, () => {
beforeEach(() => {
login();
visitWithTimeRange(TIMELINES_URL);
From c4936521eeb9242a0230adc84969a7c5f2862488 Mon Sep 17 00:00:00 2001
From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Date: Mon, 12 Feb 2024 17:22:24 -0500
Subject: [PATCH 31/83] skip failing test suite (#176759)
---
.../cypress/e2e/explore/navigation/navigation.cy.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
index 3be9029bcb2d4..13cb9a3dcae32 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
@@ -89,6 +89,7 @@ import {
} from '../../../screens/kibana_navigation';
// Failing: See https://github.com/elastic/kibana/issues/176757
+// Failing: See https://github.com/elastic/kibana/issues/176759
describe.skip('top-level navigation common to all pages in the Security app', { tags: '@ess' }, () => {
beforeEach(() => {
login();
From 105138c60178eb60bf79d8a6dd876c06ddd01a28 Mon Sep 17 00:00:00 2001
From: Jonathan Budzenski
Date: Mon, 12 Feb 2024 16:23:21 -0600
Subject: [PATCH 32/83] skip failing test suite (#176600)
---
.../public/components/custom_fields/text/configure.test.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx
index 455163f225a2b..29d3f4268443a 100644
--- a/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx
+++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx
@@ -12,7 +12,8 @@ import userEvent from '@testing-library/user-event';
import { FormTestComponent } from '../../../common/test_utils';
import { Configure } from './configure';
-describe('Configure ', () => {
+// Failing: See https://github.com/elastic/kibana/issues/176600
+describe.skip('Configure ', () => {
const onSubmit = jest.fn();
beforeEach(() => {
From 5562cf4d8f4bb6abd3093a3734de57edf405b0d7 Mon Sep 17 00:00:00 2001
From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Date: Mon, 12 Feb 2024 17:24:44 -0500
Subject: [PATCH 33/83] skip failing test suite (#176758)
---
.../cypress/e2e/explore/navigation/navigation.cy.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
index 13cb9a3dcae32..60b48e02e6774 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
@@ -90,6 +90,7 @@ import {
// Failing: See https://github.com/elastic/kibana/issues/176757
// Failing: See https://github.com/elastic/kibana/issues/176759
+// Failing: See https://github.com/elastic/kibana/issues/176758
describe.skip('top-level navigation common to all pages in the Security app', { tags: '@ess' }, () => {
beforeEach(() => {
login();
From 3b08e74e58d9fa6b65a5fcf9c26d5de6b0278af4 Mon Sep 17 00:00:00 2001
From: Jonathan Budzenski
Date: Mon, 12 Feb 2024 16:31:26 -0600
Subject: [PATCH 34/83] fix lint error
---
.../e2e/explore/navigation/navigation.cy.ts | 246 +++++++++---------
1 file changed, 125 insertions(+), 121 deletions(-)
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
index 60b48e02e6774..70ecbc771eb3a 100644
--- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/navigation/navigation.cy.ts
@@ -91,127 +91,131 @@ import {
// Failing: See https://github.com/elastic/kibana/issues/176757
// Failing: See https://github.com/elastic/kibana/issues/176759
// Failing: See https://github.com/elastic/kibana/issues/176758
-describe.skip('top-level navigation common to all pages in the Security app', { tags: '@ess' }, () => {
- beforeEach(() => {
- login();
- visitWithTimeRange(TIMELINES_URL);
- });
-
- it('navigates to the Dashboards landing page', () => {
- navigateFromHeaderTo(DASHBOARDS);
- cy.url().should('include', DASHBOARDS_URL);
- });
-
- it('navigates to the Overview page', () => {
- navigateFromHeaderTo(OVERVIEW);
- cy.url().should('include', OVERVIEW_URL);
- });
-
- it('navigates to the Detection & Response page', () => {
- navigateFromHeaderTo(DETECTION_RESPONSE);
- cy.url().should('include', DETECTION_AND_RESPONSE_URL);
- });
-
- it('navigates to the Entity Analytics page', () => {
- navigateFromHeaderTo(ENTITY_ANALYTICS);
- cy.url().should('include', ENTITY_ANALYTICS_URL);
- });
-
- it('navigates to the Kubernetes page', () => {
- navigateFromHeaderTo(KUBERNETES);
- cy.url().should('include', KUBERNETES_URL);
- });
-
- it('navigates to the CSP dashboard page', () => {
- navigateFromHeaderTo(CSP_DASHBOARD);
- cy.url().should('include', CSP_DASHBOARD_URL);
- });
-
- it('navigates to the Alerts page', () => {
- navigateFromHeaderTo(ALERTS);
- cy.url().should('include', ALERTS_URL);
- });
-
- it('navigates to the Findings page', () => {
- navigateFromHeaderTo(CSP_FINDINGS);
- cy.url().should('include', CSP_FINDINGS_URL);
- });
-
- it('navigates to the Timelines page', () => {
- navigateFromHeaderTo(TIMELINES);
- cy.url().should('include', TIMELINES_URL);
- });
-
- it('navigates to the Explore landing page', () => {
- navigateFromHeaderTo(EXPLORE);
- cy.url().should('include', EXPLORE_URL);
- });
-
- it('navigates to the Hosts page', () => {
- navigateFromHeaderTo(HOSTS);
- cy.url().should('include', HOSTS_URL);
- });
-
- it('navigates to the Network page', () => {
- navigateFromHeaderTo(NETWORK);
- cy.url().should('include', NETWORK_URL);
- });
-
- it('navigates to the Users page', () => {
- navigateFromHeaderTo(USERS);
- cy.url().should('include', USERS_URL);
- });
-
- it('navigates to the Indicators page', () => {
- navigateFromHeaderTo(INDICATORS);
- cy.url().should('include', INDICATORS_URL);
- });
-
- it('navigates to the Rules page', () => {
- navigateFromHeaderTo(RULES);
- cy.url().should('include', RULES_MANAGEMENT_URL);
- });
-
- it('navigates to the Exceptions page', () => {
- navigateFromHeaderTo(EXCEPTIONS);
- cy.url().should('include', EXCEPTIONS_URL);
- });
-
- it('navigates to the Cases page', () => {
- navigateFromHeaderTo(CASES);
- cy.url().should('include', CASES_URL);
- });
-
- it('navigates to the Manage landing page', () => {
- navigateFromHeaderTo(SETTINGS);
- cy.url().should('include', MANAGE_URL);
- });
-
- it('navigates to the Endpoints page', () => {
- navigateFromHeaderTo(ENDPOINTS);
- cy.url().should('include', ENDPOINTS_URL);
- });
- it('navigates to the Policies page', () => {
- navigateFromHeaderTo(POLICIES);
- cy.url().should('include', POLICIES_URL);
- });
- it('navigates to the Trusted Apps page', () => {
- navigateFromHeaderTo(TRUSTED_APPS);
- cy.url().should('include', TRUSTED_APPS_URL);
- });
- it('navigates to the Event Filters page', () => {
- navigateFromHeaderTo(EVENT_FILTERS);
- cy.url().should('include', EVENT_FILTERS_URL);
- });
- it('navigates to the Blocklist page', () => {
- navigateFromHeaderTo(BLOCKLIST);
- cy.url().should('include', BLOCKLIST_URL);
- });
- it('navigates to the CSP Benchmarks page', () => {
- navigateFromHeaderTo(CSP_BENCHMARKS);
- cy.url().should('include', CSP_BENCHMARKS_URL);
- });
-});
+describe.skip(
+ 'top-level navigation common to all pages in the Security app',
+ { tags: '@ess' },
+ () => {
+ beforeEach(() => {
+ login();
+ visitWithTimeRange(TIMELINES_URL);
+ });
+
+ it('navigates to the Dashboards landing page', () => {
+ navigateFromHeaderTo(DASHBOARDS);
+ cy.url().should('include', DASHBOARDS_URL);
+ });
+
+ it('navigates to the Overview page', () => {
+ navigateFromHeaderTo(OVERVIEW);
+ cy.url().should('include', OVERVIEW_URL);
+ });
+
+ it('navigates to the Detection & Response page', () => {
+ navigateFromHeaderTo(DETECTION_RESPONSE);
+ cy.url().should('include', DETECTION_AND_RESPONSE_URL);
+ });
+
+ it('navigates to the Entity Analytics page', () => {
+ navigateFromHeaderTo(ENTITY_ANALYTICS);
+ cy.url().should('include', ENTITY_ANALYTICS_URL);
+ });
+
+ it('navigates to the Kubernetes page', () => {
+ navigateFromHeaderTo(KUBERNETES);
+ cy.url().should('include', KUBERNETES_URL);
+ });
+
+ it('navigates to the CSP dashboard page', () => {
+ navigateFromHeaderTo(CSP_DASHBOARD);
+ cy.url().should('include', CSP_DASHBOARD_URL);
+ });
+
+ it('navigates to the Alerts page', () => {
+ navigateFromHeaderTo(ALERTS);
+ cy.url().should('include', ALERTS_URL);
+ });
+
+ it('navigates to the Findings page', () => {
+ navigateFromHeaderTo(CSP_FINDINGS);
+ cy.url().should('include', CSP_FINDINGS_URL);
+ });
+
+ it('navigates to the Timelines page', () => {
+ navigateFromHeaderTo(TIMELINES);
+ cy.url().should('include', TIMELINES_URL);
+ });
+
+ it('navigates to the Explore landing page', () => {
+ navigateFromHeaderTo(EXPLORE);
+ cy.url().should('include', EXPLORE_URL);
+ });
+
+ it('navigates to the Hosts page', () => {
+ navigateFromHeaderTo(HOSTS);
+ cy.url().should('include', HOSTS_URL);
+ });
+
+ it('navigates to the Network page', () => {
+ navigateFromHeaderTo(NETWORK);
+ cy.url().should('include', NETWORK_URL);
+ });
+
+ it('navigates to the Users page', () => {
+ navigateFromHeaderTo(USERS);
+ cy.url().should('include', USERS_URL);
+ });
+
+ it('navigates to the Indicators page', () => {
+ navigateFromHeaderTo(INDICATORS);
+ cy.url().should('include', INDICATORS_URL);
+ });
+
+ it('navigates to the Rules page', () => {
+ navigateFromHeaderTo(RULES);
+ cy.url().should('include', RULES_MANAGEMENT_URL);
+ });
+
+ it('navigates to the Exceptions page', () => {
+ navigateFromHeaderTo(EXCEPTIONS);
+ cy.url().should('include', EXCEPTIONS_URL);
+ });
+
+ it('navigates to the Cases page', () => {
+ navigateFromHeaderTo(CASES);
+ cy.url().should('include', CASES_URL);
+ });
+
+ it('navigates to the Manage landing page', () => {
+ navigateFromHeaderTo(SETTINGS);
+ cy.url().should('include', MANAGE_URL);
+ });
+
+ it('navigates to the Endpoints page', () => {
+ navigateFromHeaderTo(ENDPOINTS);
+ cy.url().should('include', ENDPOINTS_URL);
+ });
+ it('navigates to the Policies page', () => {
+ navigateFromHeaderTo(POLICIES);
+ cy.url().should('include', POLICIES_URL);
+ });
+ it('navigates to the Trusted Apps page', () => {
+ navigateFromHeaderTo(TRUSTED_APPS);
+ cy.url().should('include', TRUSTED_APPS_URL);
+ });
+ it('navigates to the Event Filters page', () => {
+ navigateFromHeaderTo(EVENT_FILTERS);
+ cy.url().should('include', EVENT_FILTERS_URL);
+ });
+ it('navigates to the Blocklist page', () => {
+ navigateFromHeaderTo(BLOCKLIST);
+ cy.url().should('include', BLOCKLIST_URL);
+ });
+ it('navigates to the CSP Benchmarks page', () => {
+ navigateFromHeaderTo(CSP_BENCHMARKS);
+ cy.url().should('include', CSP_BENCHMARKS_URL);
+ });
+ }
+);
describe('Kibana navigation to all pages in the Security app ', { tags: '@ess' }, () => {
beforeEach(() => {
From 6010b64204e19dae73766d150279a8b998027143 Mon Sep 17 00:00:00 2001
From: "Quynh Nguyen (Quinn)" <43350163+qn895@users.noreply.github.com>
Date: Mon, 12 Feb 2024 17:04:46 -0600
Subject: [PATCH 35/83] [ML] Enhance support for ES|QL Data visualizer
(#176515)
## Summary
This PR enhances support for ES|QL data visualizer. Changes include:
- Add an Update button that when clicked, will update and run the query.
This is to complement the current cmd + Enter keyboard short cut.
https://github.com/elastic/kibana/assets/43350163/5ca3ac0b-782e-404c-a04b-330c8eea6ab7
- Improve logic to no longer fetch total count & document count if only
the limit size is updated (so changing the limit size, but not the query
or time, will not refresh the count chart again)
- Remove dependency from data view's field format
- Refactor into a data fetching & processing into common hook to be used
for embeddable
- Support ES|QL in Field stats embeddable
- Fix count % of documents where field exists is > 100% when there are
multi-field values. (E.g. when row is an array of values like ["a", "b",
"c"], the count is much higher than the total number of rows)
- Add support for `geo_point` and `geo_shape` field types
### Checklist
Delete any items that are not applicable to this PR.
- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### Risk Matrix
Delete this section if it is not applicable to this PR.
Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.
When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:
| Risk | Probability | Severity | Mitigation/Notes |
|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.../src/components/date_picker_wrapper.tsx | 40 +-
.../geo_point_content_with_map.tsx | 63 +-
.../expanded_row/index_based_expanded_row.tsx | 11 +-
.../field_data_row/action_menu/actions.ts | 10 +-
.../field_data_row/document_stats.tsx | 2 +-
.../field_data_row/number_content_preview.tsx | 2 +-
.../components/top_values/top_values.tsx | 2 +-
.../index_data_visualizer_esql.tsx | 702 +++---------------
.../index_data_visualizer_view.tsx | 39 +-
.../search_panel/esql/limit_size.tsx | 5 +-
.../constants/index_data_visualizer_viewer.ts | 21 +
.../embeddable_esql_field_stats_table.tsx | 74 ++
.../embeddable_field_stats_no_results.tsx | 31 +
.../embeddable_field_stats_table.tsx | 96 +++
.../grid_embeddable/grid_embeddable.tsx | 167 +----
.../grid_embeddable_factory.tsx | 6 +-
.../embeddables/grid_embeddable/types.ts | 59 ++
.../esql/use_data_visualizer_esql_data.tsx | 628 ++++++++++++++++
.../hooks/esql/use_esql_field_stats_data.ts | 15 +-
.../hooks/esql/use_esql_overall_stats_data.ts | 68 +-
.../hooks/use_data_visualizer_grid_data.ts | 8 +-
.../hooks/use_overall_stats.ts | 6 +-
.../esql_requests/get_boolean_field_stats.ts | 2 +-
.../get_count_and_cardinality.ts | 102 ++-
.../esql_requests/get_text_field_stats.ts | 2 +-
.../types/index_data_visualizer_state.ts | 13 +
.../index_data_visualizer/types/storage.ts | 2 +-
27 files changed, 1314 insertions(+), 862 deletions(-)
create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_esql_field_stats_table.tsx
create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_no_results.tsx
create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx
create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/types.ts
create mode 100644 x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/esql/use_data_visualizer_esql_data.tsx
diff --git a/x-pack/packages/ml/date_picker/src/components/date_picker_wrapper.tsx b/x-pack/packages/ml/date_picker/src/components/date_picker_wrapper.tsx
index d16f74561984c..bc1f025309233 100644
--- a/x-pack/packages/ml/date_picker/src/components/date_picker_wrapper.tsx
+++ b/x-pack/packages/ml/date_picker/src/components/date_picker_wrapper.tsx
@@ -94,6 +94,15 @@ interface DatePickerWrapperProps {
* Boolean flag to disable the date picker
*/
isDisabled?: boolean;
+ /**
+ * Boolean flag to force change from 'Refresh' to 'Update' state
+ */
+ needsUpdate?: boolean;
+ /**
+ * Callback function that gets called
+ * when EuiSuperDatePicker's 'Refresh'|'Update' button is clicked
+ */
+ onRefresh?: () => void;
}
/**
@@ -111,6 +120,8 @@ export const DatePickerWrapper: FC = (props) => {
width,
flexGroup = true,
isDisabled = false,
+ needsUpdate,
+ onRefresh,
} = props;
const {
data,
@@ -285,6 +296,12 @@ export const DatePickerWrapper: FC = (props) => {
setRefreshInterval({ pause, value });
}
+ const handleRefresh = useCallback(() => {
+ updateLastRefresh();
+ if (onRefresh) {
+ onRefresh();
+ }
+ }, [onRefresh]);
const flexItems = (
<>
@@ -296,12 +313,16 @@ export const DatePickerWrapper: FC = (props) => {
isAutoRefreshOnly={!isTimeRangeSelectorEnabled || isAutoRefreshOnly}
refreshInterval={refreshInterval.value || DEFAULT_REFRESH_INTERVAL_MS}
onTimeChange={updateTimeFilter}
- onRefresh={updateLastRefresh}
+ onRefresh={handleRefresh}
onRefreshChange={updateInterval}
recentlyUsedRanges={recentlyUsedRanges}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
- updateButtonProps={{ iconOnly: isWithinLBreakpoint, fill: false }}
+ updateButtonProps={{
+ iconOnly: isWithinLBreakpoint,
+ fill: false,
+ ...(needsUpdate ? { needsUpdate } : {}),
+ }}
width={width}
isDisabled={isDisabled}
/>
@@ -310,13 +331,20 @@ export const DatePickerWrapper: FC = (props) => {
updateLastRefresh()}
+ color={needsUpdate ? 'success' : 'primary'}
+ iconType={needsUpdate ? 'kqlFunction' : 'refresh'}
+ onClick={handleRefresh}
data-test-subj={`mlDatePickerRefreshPageButton${isLoading ? ' loading' : ' loaded'}`}
isLoading={isLoading}
>
-
+ {needsUpdate ? (
+
+ ) : (
+
+ )}
) : null}
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx
index f12b65569be1c..768afda241792 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/geo_point_content_with_map/geo_point_content_with_map.tsx
@@ -5,13 +5,13 @@
* 2.0.
*/
import React, { FC, useEffect, useState } from 'react';
-import { DataView } from '@kbn/data-views-plugin/public';
+import type { DataView } from '@kbn/data-views-plugin/public';
import { ES_GEO_FIELD_TYPE, LayerDescriptor } from '@kbn/maps-plugin/common';
-import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
+import type { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content';
import { DocumentStatsTable } from '../../stats_table/components/field_data_expanded_row/document_stats';
import { ExamplesList } from '../../examples_list';
-import { FieldVisConfig } from '../../stats_table/types';
+import type { FieldVisConfig } from '../../stats_table/types';
import { useDataVisualizerKibana } from '../../../../kibana_context';
import { SUPPORTED_FIELD_TYPES } from '../../../../../../common/constants';
import { EmbeddedMapComponent } from '../../embedded_map';
@@ -20,8 +20,9 @@ import { ExpandedRowPanel } from '../../stats_table/components/field_data_expand
export const GeoPointContentWithMap: FC<{
config: FieldVisConfig;
dataView: DataView | undefined;
- combinedQuery: CombinedQuery;
-}> = ({ config, dataView, combinedQuery }) => {
+ combinedQuery?: CombinedQuery;
+ esql?: string;
+}> = ({ config, dataView, combinedQuery, esql }) => {
const { stats } = config;
const [layerList, setLayerList] = useState([]);
const {
@@ -43,22 +44,60 @@ export const GeoPointContentWithMap: FC<{
geoFieldName: config.fieldName,
geoFieldType: config.type as ES_GEO_FIELD_TYPE,
filters: data.query.filterManager.getFilters() ?? [],
- query: {
- query: combinedQuery.searchString,
- language: combinedQuery.searchQueryLanguage,
- },
+
+ ...(typeof esql === 'string' ? { esql, type: 'ESQL' } : {}),
+ ...(combinedQuery
+ ? {
+ query: {
+ query: combinedQuery.searchString,
+ language: combinedQuery.searchQueryLanguage,
+ },
+ }
+ : {}),
};
const searchLayerDescriptor = mapsPlugin
? await mapsPlugin.createLayerDescriptors.createESSearchSourceLayerDescriptor(params)
: null;
- if (searchLayerDescriptor) {
- setLayerList([...layerList, searchLayerDescriptor]);
+
+ if (searchLayerDescriptor?.sourceDescriptor) {
+ if (esql !== undefined) {
+ // Currently, createESSearchSourceLayerDescriptor doesn't support ES|QL yet
+ // but we can manually override the source descriptor with the ES|QL ESQLSourceDescriptor
+ const esqlSourceDescriptor = {
+ columns: [
+ {
+ name: config.fieldName,
+ type: config.type,
+ },
+ ],
+ dataViewId: dataView.id,
+ dateField: dataView.timeFieldName,
+ geoField: config.fieldName,
+ esql,
+ narrowByGlobalSearch: true,
+ narrowByGlobalTime: true,
+ narrowByMapBounds: true,
+ id: searchLayerDescriptor.sourceDescriptor.id,
+ type: 'ESQL',
+ applyForceRefresh: true,
+ };
+
+ setLayerList([
+ ...layerList,
+ {
+ ...searchLayerDescriptor,
+ sourceDescriptor: esqlSourceDescriptor,
+ },
+ ]);
+ } else {
+ setLayerList([...layerList, searchLayerDescriptor]);
+ }
}
}
}
updateIndexPatternSearchLayer();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dataView, combinedQuery, config, mapsPlugin, data.query]);
+ }, [dataView, combinedQuery, esql, config, mapsPlugin, data.query]);
if (stats?.examples === undefined) return null;
return (
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx
index f921d66e2cd1e..da8db22fb93df 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/expanded_row/index_based_expanded_row.tsx
@@ -6,7 +6,7 @@
*/
import React from 'react';
-import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
+import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { useExpandedRowCss } from './use_expanded_row_css';
import { GeoPointContentWithMap } from './geo_point_content_with_map';
import { SUPPORTED_FIELD_TYPES } from '../../../../../common/constants';
@@ -20,8 +20,8 @@ import {
TextContent,
} from '../stats_table/components/field_data_expanded_row';
import { NotInDocsContent } from '../not_in_docs_content';
-import { FieldVisConfig } from '../stats_table/types';
-import { CombinedQuery } from '../../../index_data_visualizer/types/combined_query';
+import type { FieldVisConfig } from '../stats_table/types';
+import type { CombinedQuery } from '../../../index_data_visualizer/types/combined_query';
import { LoadingIndicator } from '../loading_indicator';
import { ErrorMessageContent } from '../stats_table/components/field_data_expanded_row/error_message';
@@ -30,12 +30,14 @@ export const IndexBasedDataVisualizerExpandedRow = ({
dataView,
combinedQuery,
onAddFilter,
+ esql,
totalDocuments,
typeAccessor = 'type',
}: {
item: FieldVisConfig;
dataView: DataView | undefined;
- combinedQuery: CombinedQuery;
+ combinedQuery?: CombinedQuery;
+ esql?: string;
totalDocuments?: number;
typeAccessor?: 'type' | 'secondaryType';
/**
@@ -74,6 +76,7 @@ export const IndexBasedDataVisualizerExpandedRow = ({
config={config}
dataView={dataView}
combinedQuery={combinedQuery}
+ esql={esql}
/>
);
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts
index 09a3e1b8ffb45..ef1bb63246ac8 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/actions.ts
@@ -7,14 +7,14 @@
import { i18n } from '@kbn/i18n';
import { Action } from '@elastic/eui/src/components/basic_table/action_types';
-import { MutableRefObject } from 'react';
-import { DataView } from '@kbn/data-views-plugin/public';
+import type { MutableRefObject } from 'react';
+import type { DataView } from '@kbn/data-views-plugin/public';
import { VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { mlTimefilterRefresh$, Refresh } from '@kbn/ml-date-picker';
import { getCompatibleLensDataType, getLensAttributes } from './lens_utils';
-import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
-import { FieldVisConfig } from '../../stats_table/types';
-import { DataVisualizerKibanaReactContextValue } from '../../../../kibana_context';
+import type { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
+import type { FieldVisConfig } from '../../stats_table/types';
+import type { DataVisualizerKibanaReactContextValue } from '../../../../kibana_context';
import { SUPPORTED_FIELD_TYPES } from '../../../../../../common/constants';
import { APP_ID } from '../../../../../../common/constants';
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx
index 2a7ca2fedc0bd..4d2a61435b025 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/document_stats.tsx
@@ -32,7 +32,7 @@ export const DocumentStat = ({ config, showIcon, totalCount }: Props) => {
const { count, sampleCount } = stats;
- const total = sampleCount ?? totalCount;
+ const total = Math.min(sampleCount ?? Infinity, totalCount ?? Infinity);
// If field exists is docs but we don't have count stats then don't show
// Otherwise if field doesn't appear in docs at all, show 0%
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx
index 54633d4c6c74c..4becf188f1639 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/number_content_preview.tsx
@@ -8,7 +8,7 @@
import React, { FC, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { MetricDistributionChart, buildChartDataFromStats } from '../metric_distribution_chart';
-import { FieldVisConfig } from '../../types';
+import type { FieldVisConfig } from '../../types';
import { kibanaFieldFormat, formatSingleValue } from '../../../utils';
const METRIC_DISTRIBUTION_CHART_WIDTH = 100;
diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx
index 9ea517d45eea1..a9a49f5038b1b 100644
--- a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx
@@ -126,7 +126,7 @@ export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed,
max={1}
color={barColor}
size="xs"
- label={kibanaFieldFormat(value.key, fieldFormat)}
+ label={value.key ? kibanaFieldFormat(value.key, fieldFormat) : fieldValue}
className={classNames('eui-textTruncate', 'topValuesValueLabelContainer')}
valueText={`${value.doc_count}${
totalDocuments !== undefined
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx
index 2f91dce01b456..7af011586d77c 100644
--- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx
@@ -6,20 +6,13 @@
*/
/* eslint-disable react-hooks/exhaustive-deps */
-
import { css } from '@emotion/react';
-import React, { FC, useEffect, useMemo, useState, useCallback, useRef } from 'react';
-import type { Required } from 'utility-types';
-import {
- FullTimeRangeSelector,
- mlTimefilterRefresh$,
- useTimefilter,
- DatePickerWrapper,
-} from '@kbn/ml-date-picker';
+import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
+import { usePageUrlState } from '@kbn/ml-url-state';
+
+import { FullTimeRangeSelector, DatePickerWrapper } from '@kbn/ml-date-picker';
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
import type { AggregateQuery } from '@kbn/es-query';
-import { merge } from 'rxjs';
-import { Comparators } from '@elastic/eui';
import {
useEuiBreakpoint,
@@ -31,112 +24,28 @@ import {
EuiProgress,
EuiSpacer,
} from '@elastic/eui';
-import { usePageUrlState, useUrlState } from '@kbn/ml-url-state';
-import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils';
-import { getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import type { DataView } from '@kbn/data-views-plugin/common';
-import { KBN_FIELD_TYPES } from '@kbn/field-types';
-import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import { getFieldType } from '@kbn/field-utils';
-import { UI_SETTINGS } from '@kbn/data-service';
-import type { SupportedFieldType } from '../../../../../common/types';
+import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
+import { getOrCreateDataViewByIndexPattern } from '../../search_strategy/requests/get_data_view_by_index_pattern';
import { useCurrentEuiTheme } from '../../../common/hooks/use_current_eui_theme';
import type { FieldVisConfig } from '../../../common/components/stats_table/types';
import { DATA_VISUALIZER_INDEX_VIEWER } from '../../constants/index_data_visualizer_viewer';
-import type { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { GetAdditionalLinks } from '../../../common/components/results_links';
import { DocumentCountContent } from '../../../common/components/document_count_content';
-import { useTimeBuckets } from '../../../common/hooks/use_time_buckets';
-import {
- DataVisualizerTable,
- ItemIdToExpandedRowMap,
-} from '../../../common/components/stats_table';
-import type {
- MetricFieldsStats,
- TotalFieldsStats,
-} from '../../../common/components/stats_table/components/field_count_stats';
-import { filterFields } from '../../../common/components/fields_stats_grid/filter_fields';
-import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
-import { getOrCreateDataViewByIndexPattern } from '../../search_strategy/requests/get_data_view_by_index_pattern';
+import { DataVisualizerTable } from '../../../common/components/stats_table';
import { FieldCountPanel } from '../../../common/components/field_count_panel';
-import { useESQLFieldStatsData } from '../../hooks/esql/use_esql_field_stats_data';
-import type { NonAggregatableField, OverallStats } from '../../types/overall_stats';
-import { isESQLQuery } from '../../search_strategy/requests/esql_utils';
-import { DEFAULT_BAR_TARGET } from '../../../common/constants';
+import { ESQLDefaultLimitSizeSelect } from '../search_panel/esql/limit_size';
import {
- type ESQLDefaultLimitSizeOption,
- ESQLDefaultLimitSizeSelect,
-} from '../search_panel/esql/limit_size';
-import { type Column, useESQLOverallStatsData } from '../../hooks/esql/use_esql_overall_stats_data';
-import { type AggregatableField } from '../../types/esql_data_visualizer';
-
-const defaults = getDefaultPageState();
-
-interface DataVisualizerPageState {
- overallStats: OverallStats;
- metricConfigs: FieldVisConfig[];
- totalMetricFieldCount: number;
- populatedMetricFieldCount: number;
- metricsLoaded: boolean;
- nonMetricConfigs: FieldVisConfig[];
- nonMetricsLoaded: boolean;
- documentCountStats?: FieldVisConfig;
-}
-
-const defaultSearchQuery = {
- match_all: {},
-};
-
-export function getDefaultPageState(): DataVisualizerPageState {
- return {
- overallStats: {
- totalCount: 0,
- aggregatableExistsFields: [],
- aggregatableNotExistsFields: [],
- nonAggregatableExistsFields: [],
- nonAggregatableNotExistsFields: [],
- },
- metricConfigs: [],
- totalMetricFieldCount: 0,
- populatedMetricFieldCount: 0,
- metricsLoaded: false,
- nonMetricConfigs: [],
- nonMetricsLoaded: false,
- documentCountStats: undefined,
- };
-}
-
-interface ESQLDataVisualizerIndexBasedAppState extends DataVisualizerIndexBasedAppState {
- limitSize: ESQLDefaultLimitSizeOption;
-}
-
-export interface ESQLDataVisualizerIndexBasedPageUrlState {
- pageKey: typeof DATA_VISUALIZER_INDEX_VIEWER;
- pageUrlState: Required;
-}
-
-export const getDefaultDataVisualizerListState = (
- overrides?: Partial
-): Required => ({
- pageIndex: 0,
- pageSize: 25,
- sortField: 'fieldName',
- sortDirection: 'asc',
- visibleFieldTypes: [],
- visibleFieldNames: [],
- limitSize: '10000',
- searchString: '',
- searchQuery: defaultSearchQuery,
- searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY,
- filters: [],
- showDistributions: true,
- showAllFields: false,
- showEmptyFields: false,
- probability: null,
- rndSamplerPref: 'off',
- ...overrides,
-});
+ getDefaultESQLDataVisualizerListState,
+ useESQLDataVisualizerData,
+} from '../../hooks/esql/use_data_visualizer_esql_data';
+import type {
+ DataVisualizerGridInput,
+ ESQLDataVisualizerIndexBasedPageUrlState,
+ ESQLDefaultLimitSizeOption,
+} from '../../embeddables/grid_embeddable/types';
+import { ESQLQuery, isESQLQuery } from '../../search_strategy/requests/esql_utils';
export interface IndexDataVisualizerESQLProps {
getAdditionalLinks?: GetAdditionalLinks;
@@ -144,31 +53,49 @@ export interface IndexDataVisualizerESQLProps {
export const IndexDataVisualizerESQL: FC = (dataVisualizerProps) => {
const { services } = useDataVisualizerKibana();
- const { data, fieldFormats, uiSettings } = services;
+ const { data } = services;
const euiTheme = useCurrentEuiTheme();
- const [query, setQuery] = useState({ esql: '' });
+ const [query, setQuery] = useState({ esql: '' });
const [currentDataView, setCurrentDataView] = useState();
+ const toggleShowEmptyFields = () => {
+ setDataVisualizerListState({
+ ...dataVisualizerListState,
+ showEmptyFields: !dataVisualizerListState.showEmptyFields,
+ });
+ };
+ const updateLimitSize = (newLimitSize: ESQLDefaultLimitSizeOption) => {
+ setDataVisualizerListState({
+ ...dataVisualizerListState,
+ limitSize: newLimitSize,
+ });
+ };
+
+ const restorableDefaults = useMemo(
+ () => getDefaultESQLDataVisualizerListState({}),
+ // We just need to load the saved preference when the page is first loaded
+
+ []
+ );
+
+ const [dataVisualizerListState, setDataVisualizerListState] =
+ usePageUrlState(
+ DATA_VISUALIZER_INDEX_VIEWER,
+ restorableDefaults
+ );
const updateDataView = (dv: DataView) => {
if (dv.id !== currentDataView?.id) {
setCurrentDataView(dv);
}
};
- const [lastRefresh, setLastRefresh] = useState(0);
- const _timeBuckets = useTimeBuckets();
- const timefilter = useTimefilter({
- timeRangeSelector: true,
- autoRefreshSelector: true,
- });
+ // Query that has been typed, but has not submitted with cmd + enter
+ const [localQuery, setLocalQuery] = useState({ esql: '' });
const indexPattern = useMemo(() => {
let indexPatternFromQuery = '';
- if ('sql' in query) {
- indexPatternFromQuery = getIndexPatternFromSQLQuery(query.sql);
- }
- if ('esql' in query) {
+ if (isESQLQuery(query)) {
indexPatternFromQuery = getIndexPatternFromESQLQuery(query.esql);
}
// we should find a better way to work with ESQL queries which dont need a dataview
@@ -178,38 +105,6 @@ export const IndexDataVisualizerESQL: FC = (dataVi
return indexPatternFromQuery;
}, [query]);
- const restorableDefaults = useMemo(
- () => getDefaultDataVisualizerListState({}),
- // We just need to load the saved preference when the page is first loaded
-
- []
- );
-
- const [dataVisualizerListState, setDataVisualizerListState] =
- usePageUrlState(
- DATA_VISUALIZER_INDEX_VIEWER,
- restorableDefaults
- );
- const [globalState, setGlobalState] = useUrlState('_g');
-
- const showEmptyFields =
- dataVisualizerListState.showEmptyFields ?? restorableDefaults.showEmptyFields;
- const toggleShowEmptyFields = () => {
- setDataVisualizerListState({
- ...dataVisualizerListState,
- showEmptyFields: !dataVisualizerListState.showEmptyFields,
- });
- };
-
- const limitSize = dataVisualizerListState.limitSize ?? restorableDefaults.limitSize;
-
- const updateLimitSize = (newLimitSize: ESQLDefaultLimitSizeOption) => {
- setDataVisualizerListState({
- ...dataVisualizerListState,
- limitSize: newLimitSize,
- });
- };
-
useEffect(
function updateAdhocDataViewFromQuery() {
let unmounted = false;
@@ -239,471 +134,67 @@ export const IndexDataVisualizerESQL: FC = (dataVi
[indexPattern, data.dataViews, currentDataView]
);
- /** Search strategy **/
- const fieldStatsRequest = useMemo(() => {
- // Obtain the interval to use for date histogram aggregations
- // (such as the document count chart). Aim for 75 bars.
- const buckets = _timeBuckets;
-
- const tf = timefilter;
-
- if (!buckets || !tf || (isESQLQuery(query) && query.esql === '')) return;
- const activeBounds = tf.getActiveBounds();
-
- let earliest: number | undefined;
- let latest: number | undefined;
- if (activeBounds !== undefined && currentDataView?.timeFieldName !== undefined) {
- earliest = activeBounds.min?.valueOf();
- latest = activeBounds.max?.valueOf();
- }
-
- const bounds = tf.getActiveBounds();
- const barTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ?? DEFAULT_BAR_TARGET;
- buckets.setInterval('auto');
-
- if (bounds) {
- buckets.setBounds(bounds);
- buckets.setBarTarget(barTarget);
- }
-
- const aggInterval = buckets.getInterval();
-
- const filter = currentDataView?.timeFieldName
- ? ({
- bool: {
- must: [],
- filter: [
- {
- range: {
- [currentDataView.timeFieldName]: {
- format: 'strict_date_optional_time',
- gte: timefilter.getTime().from,
- lte: timefilter.getTime().to,
- },
- },
- },
- ],
- should: [],
- must_not: [],
- },
- } as QueryDslQueryContainer)
- : undefined;
+ const input: DataVisualizerGridInput = useMemo(() => {
return {
- earliest,
- latest,
- aggInterval,
- intervalMs: aggInterval?.asMilliseconds(),
- searchQuery: query,
- limitSize,
+ dataView: currentDataView,
+ query,
+ savedSearch: undefined,
sessionId: undefined,
+ visibleFieldNames: undefined,
+ allowEditDataView: true,
+ id: 'esql_data_visualizer',
indexPattern,
- timeFieldName: currentDataView?.timeFieldName,
- runtimeFieldMap: currentDataView?.getRuntimeMappings(),
- lastRefresh,
- filter,
};
- }, [
- _timeBuckets,
- timefilter,
- currentDataView?.id,
- JSON.stringify(query),
- indexPattern,
- lastRefresh,
- limitSize,
- ]);
-
- useEffect(() => {
- // Force refresh on index pattern change
- setLastRefresh(Date.now());
- }, [setLastRefresh]);
+ }, [currentDataView, query?.esql]);
- useEffect(() => {
- if (globalState?.time !== undefined) {
- timefilter.setTime({
- from: globalState.time.from,
- to: globalState.time.to,
- });
- }
- }, [JSON.stringify(globalState?.time), timefilter]);
-
- useEffect(() => {
- const timeUpdateSubscription = merge(
- timefilter.getTimeUpdate$(),
- timefilter.getAutoRefreshFetch$(),
- mlTimefilterRefresh$
- ).subscribe(() => {
- setGlobalState({
- time: timefilter.getTime(),
- refreshInterval: timefilter.getRefreshInterval(),
- });
- setLastRefresh(Date.now());
- });
- return () => {
- timeUpdateSubscription.unsubscribe();
- };
- }, []);
+ const dvPageHeader = css({
+ [useEuiBreakpoint(['xs', 's', 'm', 'l'])]: {
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+ });
- useEffect(() => {
- if (globalState?.refreshInterval !== undefined) {
- timefilter.setRefreshInterval(globalState.refreshInterval);
- }
- }, [JSON.stringify(globalState?.refreshInterval), timefilter]);
+ const isWithinLargeBreakpoint = useIsWithinMaxBreakpoint('l');
const {
- documentCountStats,
totalCount,
- overallStats,
+ progress: combinedProgress,
overallStatsProgress,
- columns,
- cancelOverallStatsRequest,
- } = useESQLOverallStatsData(fieldStatsRequest);
-
- const [metricConfigs, setMetricConfigs] = useState(defaults.metricConfigs);
- const [metricsLoaded] = useState(defaults.metricsLoaded);
- const [metricsStats, setMetricsStats] = useState();
-
- const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs);
- const [nonMetricsLoaded] = useState(defaults.nonMetricsLoaded);
-
- const [fieldStatFieldsToFetch, setFieldStatFieldsToFetch] = useState();
-
- const visibleFieldTypes =
- dataVisualizerListState.visibleFieldTypes ?? restorableDefaults.visibleFieldTypes;
-
- const visibleFieldNames =
- dataVisualizerListState.visibleFieldNames ?? restorableDefaults.visibleFieldNames;
-
- useEffect(
- function updateFieldStatFieldsToFetch() {
- const { sortField, sortDirection } = dataVisualizerListState;
-
- // Otherwise, sort the list of fields by the initial sort field and sort direction
- // Then divide into chunks by the initial page size
-
- const itemsSorter = Comparators.property(
- sortField as string,
- Comparators.default(sortDirection as 'asc' | 'desc' | undefined)
- );
-
- const preslicedSortedConfigs = [...nonMetricConfigs, ...metricConfigs]
- .map((c) => ({
- ...c,
- name: c.fieldName,
- docCount: c.stats?.count,
- cardinality: c.stats?.cardinality,
- }))
- .sort(itemsSorter);
-
- const filteredItems = filterFields(
- preslicedSortedConfigs,
- dataVisualizerListState.visibleFieldNames,
- dataVisualizerListState.visibleFieldTypes
- );
-
- const { pageIndex, pageSize } = dataVisualizerListState;
-
- const pageOfConfigs = filteredItems.filteredFields
- ?.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
- .filter((d) => d.existsInDocs === true);
-
- setFieldStatFieldsToFetch(pageOfConfigs);
- },
- [
- dataVisualizerListState.pageIndex,
- dataVisualizerListState.pageSize,
- dataVisualizerListState.sortField,
- dataVisualizerListState.sortDirection,
- nonMetricConfigs,
- metricConfigs,
- ]
- );
-
- const { fieldStats, fieldStatsProgress, cancelFieldStatsRequest } = useESQLFieldStatsData({
- searchQuery: fieldStatsRequest?.searchQuery,
- columns: fieldStatFieldsToFetch,
- filter: fieldStatsRequest?.filter,
- limitSize: fieldStatsRequest?.limitSize,
- });
-
- const createMetricCards = useCallback(() => {
- if (!columns || !overallStats) return;
- const configs: FieldVisConfig[] = [];
- const aggregatableExistsFields: AggregatableField[] =
- overallStats.aggregatableExistsFields || [];
-
- const allMetricFields = columns.filter((f) => {
- return f.secondaryType === KBN_FIELD_TYPES.NUMBER;
- });
-
- const metricExistsFields = allMetricFields.filter((f) => {
- return aggregatableExistsFields.find((existsF) => {
- return existsF.fieldName === f.name;
- });
- });
-
- let _aggregatableFields: AggregatableField[] = overallStats.aggregatableExistsFields;
- if (allMetricFields.length !== metricExistsFields.length && metricsLoaded === true) {
- _aggregatableFields = _aggregatableFields.concat(overallStats.aggregatableNotExistsFields);
- }
-
- const metricFieldsToShow =
- metricsLoaded === true && showEmptyFields === true ? allMetricFields : metricExistsFields;
-
- metricFieldsToShow.forEach((field) => {
- const fieldData = _aggregatableFields.find((f) => {
- return f.fieldName === field.name;
- });
- if (!fieldData) return;
-
- const metricConfig: FieldVisConfig = {
- ...field,
- ...fieldData,
- loading: fieldData?.existsInDocs ?? true,
- fieldFormat:
- currentDataView?.getFormatterForFieldNoDefault(field.name) ??
- fieldFormats.deserialize({ id: field.secondaryType }),
- aggregatable: true,
- deletable: false,
- type: getFieldType(field) as SupportedFieldType,
- };
-
- configs.push(metricConfig);
- });
-
- setMetricsStats({
- totalMetricFieldsCount: allMetricFields.length,
- visibleMetricsCount: metricFieldsToShow.length,
- });
- setMetricConfigs(configs);
- }, [metricsLoaded, overallStats, showEmptyFields, columns, currentDataView?.id]);
-
- const createNonMetricCards = useCallback(() => {
- if (!columns || !overallStats) return;
-
- const allNonMetricFields = columns.filter((f) => {
- return f.secondaryType !== KBN_FIELD_TYPES.NUMBER;
- });
- // Obtain the list of all non-metric fields which appear in documents
- // (aggregatable or not aggregatable).
- const populatedNonMetricFields: Column[] = []; // Kibana index pattern non metric fields.
- let nonMetricFieldData: Array = []; // Basic non metric field data loaded from requesting overall stats.
- const aggregatableExistsFields: AggregatableField[] =
- overallStats.aggregatableExistsFields || [];
- const nonAggregatableExistsFields: NonAggregatableField[] =
- overallStats.nonAggregatableExistsFields || [];
-
- allNonMetricFields.forEach((f) => {
- const checkAggregatableField = aggregatableExistsFields.find(
- (existsField) => existsField.fieldName === f.name
- );
-
- if (checkAggregatableField !== undefined) {
- populatedNonMetricFields.push(f);
- nonMetricFieldData.push(checkAggregatableField);
- } else {
- const checkNonAggregatableField = nonAggregatableExistsFields.find(
- (existsField) => existsField.fieldName === f.name
- );
-
- if (checkNonAggregatableField !== undefined) {
- populatedNonMetricFields.push(f);
- nonMetricFieldData.push(checkNonAggregatableField);
- }
- }
- });
-
- if (allNonMetricFields.length !== nonMetricFieldData.length && showEmptyFields === true) {
- // Combine the field data obtained from Elasticsearch into a single array.
- nonMetricFieldData = nonMetricFieldData.concat(
- overallStats.aggregatableNotExistsFields,
- overallStats.nonAggregatableNotExistsFields
- );
- }
-
- const nonMetricFieldsToShow = showEmptyFields ? allNonMetricFields : populatedNonMetricFields;
-
- const configs: FieldVisConfig[] = [];
-
- nonMetricFieldsToShow.forEach((field) => {
- const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.name);
- const nonMetricConfig: Partial = {
- ...(fieldData ? fieldData : {}),
- secondaryType: getFieldType(field) as SupportedFieldType,
- loading: fieldData?.existsInDocs ?? true,
- deletable: false,
- fieldFormat:
- currentDataView?.getFormatterForFieldNoDefault(field.name) ??
- fieldFormats.deserialize({ id: field.secondaryType }),
- };
-
- // Map the field type from the Kibana index pattern to the field type
- // used in the data visualizer.
- const dataVisualizerType = getFieldType(field) as SupportedFieldType;
- if (dataVisualizerType !== undefined) {
- nonMetricConfig.type = dataVisualizerType;
- } else {
- // Add a flag to indicate that this is one of the 'other' Kibana
- // field types that do not yet have a specific card type.
- nonMetricConfig.type = field.type as SupportedFieldType;
- nonMetricConfig.isUnsupportedType = true;
- }
-
- if (field.name !== nonMetricConfig.fieldName) {
- nonMetricConfig.displayName = field.name;
- }
-
- configs.push(nonMetricConfig as FieldVisConfig);
- });
-
- setNonMetricConfigs(configs);
- }, [columns, nonMetricsLoaded, overallStats, showEmptyFields, currentDataView?.id]);
-
- const fieldsCountStats: TotalFieldsStats | undefined = useMemo(() => {
- if (!overallStats) return;
-
- let _visibleFieldsCount = 0;
- let _totalFieldsCount = 0;
- Object.keys(overallStats).forEach((key) => {
- const fieldsGroup = overallStats[key as keyof typeof overallStats];
- if (Array.isArray(fieldsGroup) && fieldsGroup.length > 0) {
- _totalFieldsCount += fieldsGroup.length;
- }
- });
-
- if (showEmptyFields === true) {
- _visibleFieldsCount = _totalFieldsCount;
- } else {
- _visibleFieldsCount =
- overallStats.aggregatableExistsFields.length +
- overallStats.nonAggregatableExistsFields.length;
- }
- return { visibleFieldsCount: _visibleFieldsCount, totalFieldsCount: _totalFieldsCount };
- }, [overallStats, showEmptyFields]);
-
- useEffect(() => {
- createMetricCards();
- createNonMetricCards();
- }, [overallStats, showEmptyFields]);
-
- const configs = useMemo(() => {
- let combinedConfigs = [...nonMetricConfigs, ...metricConfigs];
-
- combinedConfigs = filterFields(
- combinedConfigs,
- visibleFieldNames,
- visibleFieldTypes
- ).filteredFields;
-
- if (fieldStatsProgress.loaded === 100 && fieldStats) {
- combinedConfigs = combinedConfigs.map((c) => {
- const loadedFullStats = fieldStats.get(c.fieldName) ?? {};
- return loadedFullStats
- ? {
- ...c,
- loading: false,
- stats: { ...c.stats, ...loadedFullStats },
- }
- : c;
- });
- }
- return combinedConfigs;
- }, [
- nonMetricConfigs,
- metricConfigs,
- visibleFieldTypes,
- visibleFieldNames,
- fieldStatsProgress.loaded,
- dataVisualizerListState.pageIndex,
- dataVisualizerListState.pageSize,
- ]);
-
- // Some actions open up fly-out or popup
- // This variable is used to keep track of them and clean up when unmounting
- const actionFlyoutRef = useRef<() => void | undefined>();
- useEffect(() => {
- const ref = actionFlyoutRef;
- return () => {
- // Clean up any of the flyout/editor opened from the actions
- if (ref.current) {
- ref.current();
- }
- };
- }, []);
-
- const getItemIdToExpandedRowMap = useCallback(
- function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap {
- return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => {
- const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName);
- if (item !== undefined) {
- m[fieldName] = (
-
- );
- }
- return m;
- }, {} as ItemIdToExpandedRowMap);
- },
- [currentDataView, totalCount]
- );
+ configs,
+ documentCountStats,
+ metricsStats,
+ timefilter,
+ getItemIdToExpandedRowMap,
+ onQueryUpdate,
+ limitSize,
+ showEmptyFields,
+ fieldsCountStats,
+ } = useESQLDataVisualizerData(input, dataVisualizerListState, setQuery);
const hasValidTimeField = useMemo(
- () =>
- currentDataView &&
- currentDataView.timeFieldName !== undefined &&
- currentDataView.timeFieldName !== '',
- [currentDataView]
+ () => currentDataView?.timeFieldName !== undefined,
+ [currentDataView?.timeFieldName]
);
- const isWithinLargeBreakpoint = useIsWithinMaxBreakpoint('l');
- const dvPageHeader = css({
- [useEuiBreakpoint(['xs', 's', 'm', 'l'])]: {
- flexDirection: 'column',
- alignItems: 'flex-start',
- },
- });
-
- const combinedProgress = useMemo(
- () => overallStatsProgress.loaded * 0.3 + fieldStatsProgress.loaded * 0.7,
- [overallStatsProgress.loaded, fieldStatsProgress.loaded]
+ const queryNeedsUpdate = useMemo(
+ () => (localQuery.esql !== query.esql ? true : undefined),
+ [localQuery.esql, query.esql]
);
- // Query that has been typed, but has not submitted with cmd + enter
- const [localQuery, setLocalQuery] = useState({ esql: '' });
-
- const onQueryUpdate = async (q?: AggregateQuery) => {
- // When user submits a new query
- // resets all current requests and other data
- if (cancelOverallStatsRequest) {
- cancelOverallStatsRequest();
- }
- if (cancelFieldStatsRequest) {
- cancelFieldStatsRequest();
- }
- // Reset field stats to fetch state
- setFieldStatFieldsToFetch(undefined);
- setMetricConfigs(defaults.metricConfigs);
- setNonMetricConfigs(defaults.nonMetricConfigs);
- if (q) {
- setQuery(q);
+ const handleRefresh = useCallback(() => {
+ // The page is already autoamtically updating when time range is changed
+ // via the url state
+ // so we just need to force update if the query is outdated
+ if (queryNeedsUpdate) {
+ setQuery(localQuery);
}
- };
-
- useEffect(
- function resetFieldStatsFieldToFetch() {
- // If query returns 0 document, no need to do more work here
- if (totalCount === undefined || totalCount === 0) {
- setFieldStatFieldsToFetch(undefined);
- return;
- }
- },
- [totalCount]
- );
+ }, [queryNeedsUpdate, localQuery.esql]);
+ const onTextLangQueryChange = useCallback((q: AggregateQuery) => {
+ if (isESQLQuery(q)) {
+ setLocalQuery(q);
+ }
+ }, []);
return (
= (dataVi
) : null}
@@ -754,7 +247,7 @@ export const IndexDataVisualizerESQL: FC = (dataVi
false}
isCodeEditorExpanded={true}
@@ -777,9 +270,9 @@ export const IndexDataVisualizerESQL: FC = (dataVi
showSettings={false}
/>
+
>
)}
-
= (dataVi
onChangeLimitSize={updateLimitSize}
/>
-
+
+
items={configs}
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx
index 5d8ebe9e44d57..a00a126d517f6 100644
--- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx
@@ -48,9 +48,9 @@ import {
DataVisualizerTable,
ItemIdToExpandedRowMap,
} from '../../../common/components/stats_table';
-import { FieldVisConfig } from '../../../common/components/stats_table/types';
+import type { FieldVisConfig } from '../../../common/components/stats_table/types';
import type { TotalFieldsStats } from '../../../common/components/stats_table/components/field_count_stats';
-import { OverallStats } from '../../types/overall_stats';
+import type { OverallStats } from '../../types/overall_stats';
import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
import { DATA_VISUALIZER_INDEX_VIEWER } from '../../constants/index_data_visualizer_viewer';
import {
@@ -66,46 +66,17 @@ import { ActionsPanel } from '../actions_panel';
import { DataVisualizerDataViewManagement } from '../data_view_management';
import type { GetAdditionalLinks } from '../../../common/components/results_links';
import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data';
-import type { DataVisualizerGridInput } from '../../embeddables/grid_embeddable/grid_embeddable';
import {
MIN_SAMPLER_PROBABILITY,
RANDOM_SAMPLER_OPTION,
- RandomSamplerOption,
+ type RandomSamplerOption,
} from '../../constants/random_sampler';
-
-interface DataVisualizerPageState {
- overallStats: OverallStats;
- metricConfigs: FieldVisConfig[];
- totalMetricFieldCount: number;
- populatedMetricFieldCount: number;
- metricsLoaded: boolean;
- nonMetricConfigs: FieldVisConfig[];
- nonMetricsLoaded: boolean;
- documentCountStats?: FieldVisConfig;
-}
+import type { DataVisualizerGridInput } from '../../embeddables/grid_embeddable/types';
const defaultSearchQuery = {
match_all: {},
};
-export function getDefaultPageState(): DataVisualizerPageState {
- return {
- overallStats: {
- totalCount: 0,
- aggregatableExistsFields: [],
- aggregatableNotExistsFields: [],
- nonAggregatableExistsFields: [],
- nonAggregatableNotExistsFields: [],
- },
- metricConfigs: [],
- totalMetricFieldCount: 0,
- populatedMetricFieldCount: 0,
- metricsLoaded: false,
- nonMetricConfigs: [],
- nonMetricsLoaded: false,
- documentCountStats: undefined,
- };
-}
export const getDefaultDataVisualizerListState = (
overrides?: Partial
): Required => ({
@@ -244,7 +215,7 @@ export const IndexDataVisualizerView: FC = (dataVi
});
};
- const input: DataVisualizerGridInput = useMemo(() => {
+ const input: Required = useMemo(() => {
return {
dataView: currentDataView,
savedSearch: currentSavedSearch,
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/esql/limit_size.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/esql/limit_size.tsx
index bcdf3241f5ee3..2e84191521bdb 100644
--- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/esql/limit_size.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/esql/limit_size.tsx
@@ -8,6 +8,7 @@ import React, { type ChangeEvent } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSelect, EuiText, useGeneratedHtmlId } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
+import type { ESQLDefaultLimitSizeOption } from '../../../embeddables/grid_embeddable/types';
const options = [
{
@@ -46,8 +47,6 @@ const options = [
},
];
-export type ESQLDefaultLimitSizeOption = '5000' | '10000' | '100000' | '1000000' | 'none';
-
export const ESQLDefaultLimitSizeSelect = ({
limitSize,
onChangeLimitSize,
@@ -71,7 +70,7 @@ export const ESQLDefaultLimitSizeSelect = ({
defaultMessage: 'Limit size',
})}
prepend={
-
+
void;
+}) => {
+ const [dataVisualizerListState, setDataVisualizerListState] =
+ useState>(restorableDefaults);
+
+ const onTableChange = useCallback(
+ (update: DataVisualizerTableState) => {
+ setDataVisualizerListState({ ...dataVisualizerListState, ...update });
+ if (onOutputChange) {
+ onOutputChange(update);
+ }
+ },
+ [dataVisualizerListState, onOutputChange]
+ );
+
+ const {
+ configs,
+ extendedColumns,
+ progress,
+ overallStatsProgress,
+ setLastRefresh,
+ getItemIdToExpandedRowMap,
+ } = useESQLDataVisualizerData(input, dataVisualizerListState);
+
+ useEffect(() => {
+ setLastRefresh(Date.now());
+ }, [input?.lastReloadRequestTime, setLastRefresh]);
+
+ if (progress === 100 && configs.length === 0) {
+ return ;
+ }
+ return (
+
+ items={configs}
+ pageState={dataVisualizerListState}
+ updatePageState={onTableChange}
+ getItemIdToExpandedRowMap={getItemIdToExpandedRowMap}
+ extendedColumns={extendedColumns}
+ showPreviewByDefault={input?.showPreviewByDefault}
+ onChange={onOutputChange}
+ loading={progress < 100}
+ overallStatsRunning={overallStatsProgress.isRunning}
+ />
+ );
+};
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_no_results.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_no_results.tsx
new file mode 100644
index 0000000000000..8424b8ec5d8a0
--- /dev/null
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_no_results.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { css } from '@emotion/react';
+import { EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
+
+export const EmbeddableNoResultsEmptyPrompt = () => (
+
+
+
+
+
+
+
+);
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx
new file mode 100644
index 0000000000000..e2202e44482c5
--- /dev/null
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_field_stats_table.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useCallback, useEffect, useState } from 'react';
+import type { Required } from 'utility-types';
+import type { DataVisualizerGridEmbeddableInput } from './types';
+import {
+ DataVisualizerTable,
+ ItemIdToExpandedRowMap,
+} from '../../../common/components/stats_table';
+import type { FieldVisConfig } from '../../../common/components/stats_table/types';
+import { getDefaultDataVisualizerListState } from '../../components/index_data_visualizer_view/index_data_visualizer_view';
+import type { DataVisualizerTableState } from '../../../../../common/types';
+import type { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
+import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
+import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data';
+import { EmbeddableNoResultsEmptyPrompt } from './embeddable_field_stats_no_results';
+
+const restorableDefaults = getDefaultDataVisualizerListState();
+
+export const EmbeddableFieldStatsTableWrapper = ({
+ input,
+ onOutputChange,
+}: {
+ input: Required;
+ onOutputChange?: (ouput: any) => void;
+}) => {
+ const [dataVisualizerListState, setDataVisualizerListState] =
+ useState>(restorableDefaults);
+
+ const onTableChange = useCallback(
+ (update: DataVisualizerTableState) => {
+ setDataVisualizerListState({ ...dataVisualizerListState, ...update });
+ if (onOutputChange) {
+ onOutputChange(update);
+ }
+ },
+ [dataVisualizerListState, onOutputChange]
+ );
+
+ const {
+ configs,
+ searchQueryLanguage,
+ searchString,
+ extendedColumns,
+ progress,
+ overallStatsProgress,
+ setLastRefresh,
+ } = useDataVisualizerGridData(input, dataVisualizerListState);
+
+ useEffect(() => {
+ setLastRefresh(Date.now());
+ }, [input?.lastReloadRequestTime, setLastRefresh]);
+
+ const getItemIdToExpandedRowMap = useCallback(
+ function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap {
+ return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => {
+ const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName);
+ if (item !== undefined) {
+ m[fieldName] = (
+
+ );
+ }
+ return m;
+ }, {} as ItemIdToExpandedRowMap);
+ },
+ [input, searchQueryLanguage, searchString]
+ );
+
+ if (progress === 100 && configs.length === 0) {
+ return ;
+ }
+ return (
+
+ items={configs}
+ pageState={dataVisualizerListState}
+ updatePageState={onTableChange}
+ getItemIdToExpandedRowMap={getItemIdToExpandedRowMap}
+ extendedColumns={extendedColumns}
+ showPreviewByDefault={input?.showPreviewByDefault}
+ onChange={onOutputChange}
+ loading={progress < 100}
+ overallStatsRunning={overallStatsProgress.isRunning}
+ />
+ );
+};
diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx
index 47dac89aa9c1f..681f3d994aeef 100644
--- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx
+++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx
@@ -9,156 +9,41 @@ import { pick } from 'lodash';
import { Observable, Subject } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import ReactDOM from 'react-dom';
-import React, { Suspense, useCallback, useEffect, useState } from 'react';
+import React, { Suspense } from 'react';
import useObservable from 'react-use/lib/useObservable';
-import { EuiEmptyPrompt, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
-import { Filter } from '@kbn/es-query';
+import { EuiEmptyPrompt } from '@elastic/eui';
import { Required } from 'utility-types';
import { FormattedMessage } from '@kbn/i18n-react';
-import {
- Embeddable,
- EmbeddableInput,
- EmbeddableOutput,
- IContainer,
-} from '@kbn/embeddable-plugin/public';
+import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
-import type { Query } from '@kbn/es-query';
-import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { DatePickerContextProvider } from '@kbn/ml-date-picker';
-import type { SavedSearch } from '@kbn/saved-search-plugin/public';
-import type { SamplingOption } from '../../../../../common/types/field_stats';
+import { isPopulatedObject } from '@kbn/ml-is-populated-object';
+import type { DataVisualizerStartDependencies } from '../../../../plugin';
import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants';
import { EmbeddableLoading } from './embeddable_loading_fallback';
-import { DataVisualizerStartDependencies } from '../../../../plugin';
-import {
- DataVisualizerTable,
- ItemIdToExpandedRowMap,
-} from '../../../common/components/stats_table';
-import { FieldVisConfig } from '../../../common/components/stats_table/types';
-import { getDefaultDataVisualizerListState } from '../../components/index_data_visualizer_view/index_data_visualizer_view';
-import type { DataVisualizerTableState } from '../../../../../common/types';
-import type { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
-import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
-import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data';
+import { EmbeddableESQLFieldStatsTableWrapper } from './embeddable_esql_field_stats_table';
+import { EmbeddableFieldStatsTableWrapper } from './embeddable_field_stats_table';
+import type {
+ DataVisualizerGridEmbeddableInput,
+ ESQLDataVisualizerGridEmbeddableInput,
+ DataVisualizerGridEmbeddableOutput,
+} from './types';
export type DataVisualizerGridEmbeddableServices = [CoreStart, DataVisualizerStartDependencies];
-export interface DataVisualizerGridInput {
- dataView: DataView;
- savedSearch?: SavedSearch | null;
- query?: Query;
- visibleFieldNames?: string[];
- filters?: Filter[];
- showPreviewByDefault?: boolean;
- allowEditDataView?: boolean;
- id?: string;
- /**
- * Callback to add a filter to filter bar
- */
- onAddFilter?: (field: DataViewField | string, value: string, type: '+' | '-') => void;
- sessionId?: string;
- fieldsToFetch?: string[];
- totalDocuments?: number;
- samplingOption?: SamplingOption;
-}
-export type DataVisualizerGridEmbeddableInput = EmbeddableInput & DataVisualizerGridInput;
-export type DataVisualizerGridEmbeddableOutput = EmbeddableOutput;
-
export type IDataVisualizerGridEmbeddable = typeof DataVisualizerGridEmbeddable;
-const restorableDefaults = getDefaultDataVisualizerListState();
-
-export const EmbeddableWrapper = ({
- input,
- onOutputChange,
-}: {
- input: DataVisualizerGridEmbeddableInput;
- onOutputChange?: (ouput: any) => void;
-}) => {
- const [dataVisualizerListState, setDataVisualizerListState] =
- useState