- ? T['type']
- : undefined
->;
-
/**
* Route validator class to define the validation logic for each new route.
*
@@ -92,9 +83,9 @@ export class RouteValidator {
unsafe?: boolean,
data?: unknown,
namespace?: string
- ): RouteValidationResultType {
+ ): T {
if (typeof validationRule === 'undefined') {
- return {};
+ return {} as T;
}
let precheckedData = this.preValidateSchema(data).validate(data, {}, namespace);
@@ -125,12 +116,10 @@ export class RouteValidator {
validationRule: RouteValidationSpec,
data?: unknown,
namespace?: string
- ): RouteValidationResultType {
+ ): T {
if (isConfigSchema(validationRule)) {
- // @ts-expect-error upgrade typescript v4.9.5
return validationRule.validate(data, {}, namespace);
} else if (typeof validationRule === 'function') {
- // @ts-expect-error upgrade typescript v4.9.5
return this.validateFunction(validationRule, data, namespace);
} else {
throw new ValidationError(
diff --git a/packages/core/http/core-http-server-internal/src/cookie_session_storage.ts b/packages/core/http/core-http-server-internal/src/cookie_session_storage.ts
index 94dbd10970cc5..ab3dd92aa6f7b 100644
--- a/packages/core/http/core-http-server-internal/src/cookie_session_storage.ts
+++ b/packages/core/http/core-http-server-internal/src/cookie_session_storage.ts
@@ -18,7 +18,7 @@ import type {
} from '@kbn/core-http-server';
import { ensureRawRequest } from '@kbn/core-http-router-server-internal';
-class ScopedCookieSessionStorage> implements SessionStorage {
+class ScopedCookieSessionStorage implements SessionStorage {
constructor(
private readonly log: Logger,
private readonly server: Server,
@@ -72,7 +72,7 @@ function validateOptions(options: SessionStorageCookieOptions) {
* @param server - hapi server to create SessionStorage for
* @param cookieOptions - cookies configuration
*/
-export async function createCookieSessionStorageFactory(
+export async function createCookieSessionStorageFactory(
log: Logger,
server: Server,
cookieOptions: SessionStorageCookieOptions,
@@ -113,7 +113,6 @@ export async function createCookieSessionStorageFactory(
return {
asScoped(request: KibanaRequest) {
- // @ts-expect-error upgrade typescript v4.9.5
return new ScopedCookieSessionStorage(log, server, ensureRawRequest(request));
},
};
diff --git a/packages/core/http/core-http-server-internal/src/http_server.ts b/packages/core/http/core-http-server-internal/src/http_server.ts
index ae9025d5cd9a7..c023f39206868 100644
--- a/packages/core/http/core-http-server-internal/src/http_server.ts
+++ b/packages/core/http/core-http-server-internal/src/http_server.ts
@@ -292,8 +292,9 @@ export class HttpServer {
registerAuth: this.registerAuth.bind(this),
registerOnPostAuth: this.registerOnPostAuth.bind(this),
registerOnPreResponse: this.registerOnPreResponse.bind(this),
- createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) =>
- this.createCookieSessionStorageFactory(cookieOptions, config.basePath),
+ createCookieSessionStorageFactory: (
+ cookieOptions: SessionStorageCookieOptions
+ ) => this.createCookieSessionStorageFactory(cookieOptions, config.basePath),
basePath: basePathService,
csp: config.csp,
auth: {
@@ -554,7 +555,7 @@ export class HttpServer {
this.server.ext('onPreResponse', adoptToHapiOnPreResponseFormat(fn, this.log));
}
- private async createCookieSessionStorageFactory(
+ private async createCookieSessionStorageFactory(
cookieOptions: SessionStorageCookieOptions,
basePath?: string
) {
diff --git a/packages/core/http/core-http-server-mocks/src/cookie_session_storage.mocks.ts b/packages/core/http/core-http-server-mocks/src/cookie_session_storage.mocks.ts
index 279858a257eb1..5f70893c9114b 100644
--- a/packages/core/http/core-http-server-mocks/src/cookie_session_storage.mocks.ts
+++ b/packages/core/http/core-http-server-mocks/src/cookie_session_storage.mocks.ts
@@ -22,7 +22,7 @@ type ReturnMocked = {
type DeepMocked = jest.Mocked>;
-const creatSessionStorageFactoryMock = () => {
+const creatSessionStorageFactoryMock = () => {
const mocked: DeepMocked> = {
asScoped: jest.fn(),
};
diff --git a/packages/core/http/core-http-server/src/http_contract.ts b/packages/core/http/core-http-server/src/http_contract.ts
index 8ac34b26a386c..09250abf8adae 100644
--- a/packages/core/http/core-http-server/src/http_contract.ts
+++ b/packages/core/http/core-http-server/src/http_contract.ts
@@ -218,7 +218,7 @@ export interface HttpServiceSetup<
* Creates cookie based session storage factory {@link SessionStorageFactory}
* @param cookieOptions {@link SessionStorageCookieOptions} - options to configure created cookie session storage.
*/
- createCookieSessionStorageFactory: (
+ createCookieSessionStorageFactory: (
cookieOptions: SessionStorageCookieOptions
) => Promise>;
From b6670ae48a565dfd18faaefd506ee20e762f9761 Mon Sep 17 00:00:00 2001
From: Jeramy Soucy
Date: Tue, 2 Apr 2024 11:28:24 +0200
Subject: [PATCH 09/63] =?UTF-8?q?Upgrade=20webpack-dev-middleware@5.3.3?=
=?UTF-8?q?=E2=86=925.3.4=20(#179738)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Upgrades development dependency `webpack-dev-middleware` from v5.3.3 to
v5.3.4.
---
yarn.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 5fdede7d8e075..e518c0672b74e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -31180,9 +31180,9 @@ webpack-dev-middleware@^3.7.3:
webpack-log "^2.0.0"
webpack-dev-middleware@^5.3.1:
- version "5.3.3"
- resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f"
- integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==
+ version "5.3.4"
+ resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517"
+ integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==
dependencies:
colorette "^2.0.10"
memfs "^3.4.3"
From 0f2599c55df846fa48dc5e766ee2fa03d88b565f Mon Sep 17 00:00:00 2001
From: Coen Warmer
Date: Tue, 2 Apr 2024 11:29:54 +0200
Subject: [PATCH 10/63] [AI Assistant for Observability] Add aria-label to AI
Assistant button (#179718)
Resolves https://github.com/elastic/kibana/issues/179195
## Summary
Adds an `aria-label` for the AI Assistant for Observability button.
---
.../public/components/nav_control/index.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx
index 074a13815dd0c..b9f44142e946b 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/nav_control/index.tsx
@@ -98,6 +98,10 @@ export function NavControl({}: {}) {
return (
<>
{
From 622380d50bfb6012049e3f3382ba65121427dd42 Mon Sep 17 00:00:00 2001
From: Coen Warmer
Date: Tue, 2 Apr 2024 11:34:40 +0200
Subject: [PATCH 11/63] [AI Assistant for Observability] Add aria-label to AI
Assistant flyout (#179720)
Resolves https://github.com/elastic/kibana/issues/176963
## Summary
Adds an aria-label for the AI Assistant for Observability flyout.
---
.../public/components/chat/chat_flyout.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx
index 46f64e7a9f003..4194f9a2ca0c4 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_flyout.tsx
@@ -147,6 +147,10 @@ export function ChatFlyout({
}}
>
Date: Tue, 2 Apr 2024 12:45:41 +0200
Subject: [PATCH 12/63] [Fleet] fix UI error when entering an invalid semver
(#179631)
## Summary
Closes https://github.com/elastic/kibana/issues/179592
The UI calls the semver version check functions on every keystroke, it
seems the semver functions throw error if the input is not a valid
semver (e.g. `8.14` instead of `8.14.0`). Added try-catch around the
logic which is called from the UI.
To verify:
- run a stack 8.14.0-SNAPSHOT with fleet-server
- enroll an agent with version 8.13.0
- upgrade agent, type in `8.14`
- verify that the UI error is no longer visible
- the submit button should only be enabled if typing a valid semver e.g.
`8.14.0`
### 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
---
.../check_fleet_server_versions.test.ts | 27 +++++++
.../services/check_fleet_server_versions.ts | 73 ++++++++++---------
.../agent_upgrade_modal/index.test.tsx | 40 ++++++++++
.../components/agent_upgrade_modal/index.tsx | 18 ++++-
4 files changed, 124 insertions(+), 34 deletions(-)
diff --git a/x-pack/plugins/fleet/common/services/check_fleet_server_versions.test.ts b/x-pack/plugins/fleet/common/services/check_fleet_server_versions.test.ts
index 1afca26bca254..3ed6f2fb81ae0 100644
--- a/x-pack/plugins/fleet/common/services/check_fleet_server_versions.test.ts
+++ b/x-pack/plugins/fleet/common/services/check_fleet_server_versions.test.ts
@@ -7,6 +7,7 @@
import {
checkFleetServerVersion,
+ getFleetServerVersionMessage,
isAgentVersionLessThanFleetServer,
} from './check_fleet_server_versions';
@@ -66,4 +67,30 @@ describe('isAgentVersionLessThanFleetServer', () => {
] as any;
expect(isAgentVersionLessThanFleetServer('8.5.0', fleetServers)).toBe(false);
});
+
+ it('should not throw if version is not a semver', () => {
+ const fleetServers = [
+ { local_metadata: { elastic: { agent: { version: '8.13.0' } } } },
+ { local_metadata: { elastic: { agent: { version: '8.14.0' } } } },
+ ] as any;
+ const version = '8.14';
+
+ const result = isAgentVersionLessThanFleetServer(version, fleetServers);
+
+ expect(result).toEqual(false);
+ });
+});
+
+describe('getFleetServerVersionMessage', () => {
+ it('should not throw if version is not a semver', () => {
+ const fleetServers = [
+ { local_metadata: { elastic: { agent: { version: '8.13.0' } } } },
+ { local_metadata: { elastic: { agent: { version: '8.14.0' } } } },
+ ] as any;
+ const version = '8.14';
+
+ const result = getFleetServerVersionMessage(version, fleetServers);
+
+ expect(result).toEqual('Invalid Version: 8.14');
+ });
});
diff --git a/x-pack/plugins/fleet/common/services/check_fleet_server_versions.ts b/x-pack/plugins/fleet/common/services/check_fleet_server_versions.ts
index 2f98938861c8e..000332c66aa89 100644
--- a/x-pack/plugins/fleet/common/services/check_fleet_server_versions.ts
+++ b/x-pack/plugins/fleet/common/services/check_fleet_server_versions.ts
@@ -41,24 +41,27 @@ export const getFleetServerVersionMessage = (
if (!maxFleetServerVersion || !versionToUpgradeNumber) {
return;
}
-
- if (
- !force &&
- semverGt(versionToUpgradeNumber, maxFleetServerVersion) &&
- !differsOnlyInPatch(versionToUpgradeNumber, maxFleetServerVersion)
- ) {
- return `Cannot upgrade to version ${versionToUpgradeNumber} because it is higher than the latest fleet server version ${maxFleetServerVersion}.`;
- }
-
- const fleetServerMajorGt =
- semverMajor(maxFleetServerVersion) > semverMajor(versionToUpgradeNumber);
- const fleetServerMajorEqMinorGte =
- semverMajor(maxFleetServerVersion) === semverMajor(versionToUpgradeNumber) &&
- semverMinor(maxFleetServerVersion) >= semverMinor(versionToUpgradeNumber);
-
- // When force is enabled, only the major and minor versions are checked
- if (force && !(fleetServerMajorGt || fleetServerMajorEqMinorGte)) {
- return `Cannot force upgrade to version ${versionToUpgradeNumber} because it does not satisfy the major and minor of the latest fleet server version ${maxFleetServerVersion}.`;
+ try {
+ if (
+ !force &&
+ semverGt(versionToUpgradeNumber, maxFleetServerVersion) &&
+ !differsOnlyInPatch(versionToUpgradeNumber, maxFleetServerVersion)
+ ) {
+ return `Cannot upgrade to version ${versionToUpgradeNumber} because it is higher than the latest fleet server version ${maxFleetServerVersion}.`;
+ }
+
+ const fleetServerMajorGt =
+ semverMajor(maxFleetServerVersion) > semverMajor(versionToUpgradeNumber);
+ const fleetServerMajorEqMinorGte =
+ semverMajor(maxFleetServerVersion) === semverMajor(versionToUpgradeNumber) &&
+ semverMinor(maxFleetServerVersion) >= semverMinor(versionToUpgradeNumber);
+
+ // When force is enabled, only the major and minor versions are checked
+ if (force && !(fleetServerMajorGt || fleetServerMajorEqMinorGte)) {
+ return `Cannot force upgrade to version ${versionToUpgradeNumber} because it does not satisfy the major and minor of the latest fleet server version ${maxFleetServerVersion}.`;
+ }
+ } catch (e) {
+ return e.message;
}
};
@@ -80,21 +83,25 @@ export const isAgentVersionLessThanFleetServer = (
if (!maxFleetServerVersion || !versionToUpgradeNumber) {
return false;
}
- if (
- !force &&
- semverGt(versionToUpgradeNumber, maxFleetServerVersion) &&
- !differsOnlyInPatch(versionToUpgradeNumber, maxFleetServerVersion)
- )
- return false;
-
- const fleetServerMajorGt =
- semverMajor(maxFleetServerVersion) > semverMajor(versionToUpgradeNumber);
- const fleetServerMajorEqMinorGte =
- semverMajor(maxFleetServerVersion) === semverMajor(versionToUpgradeNumber) &&
- semverMinor(maxFleetServerVersion) >= semverMinor(versionToUpgradeNumber);
-
- // When force is enabled, only the major and minor versions are checked
- if (force && !(fleetServerMajorGt || fleetServerMajorEqMinorGte)) {
+ try {
+ if (
+ !force &&
+ semverGt(versionToUpgradeNumber, maxFleetServerVersion) &&
+ !differsOnlyInPatch(versionToUpgradeNumber, maxFleetServerVersion)
+ )
+ return false;
+
+ const fleetServerMajorGt =
+ semverMajor(maxFleetServerVersion) > semverMajor(versionToUpgradeNumber);
+ const fleetServerMajorEqMinorGte =
+ semverMajor(maxFleetServerVersion) === semverMajor(versionToUpgradeNumber) &&
+ semverMinor(maxFleetServerVersion) >= semverMinor(versionToUpgradeNumber);
+
+ // When force is enabled, only the major and minor versions are checked
+ if (force && !(fleetServerMajorGt || fleetServerMajorEqMinorGte)) {
+ return false;
+ }
+ } catch (e) {
return false;
}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx
index fd25be2132584..dc08d052a9152 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.test.tsx
@@ -169,6 +169,46 @@ describe('AgentUpgradeAgentModal', () => {
});
});
+ it('should display invalid input if version is not a valid semver', async () => {
+ const { utils } = renderAgentUpgradeAgentModal({
+ agents: [
+ {
+ id: 'agent1',
+ local_metadata: { host: 'abc', elastic: { agent: { version: '8.12.0' } } },
+ },
+ ] as any,
+ agentCount: 1,
+ });
+
+ await waitFor(() => {
+ const input = utils.getByTestId('agentUpgradeModal.VersionInput');
+ fireEvent.input(input, { target: { value: '8.14' } });
+ expect(
+ utils.getByText('Invalid version, please use a valid semver version, e.g. 8.14.0')
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('should not display invalid input if version is a valid semver', async () => {
+ const { utils } = renderAgentUpgradeAgentModal({
+ agents: [
+ {
+ id: 'agent1',
+ local_metadata: { host: 'abc', elastic: { agent: { version: '8.12.0' } } },
+ },
+ ] as any,
+ agentCount: 1,
+ });
+
+ await waitFor(() => {
+ const input = utils.getByTestId('agentUpgradeModal.VersionInput');
+ fireEvent.input(input, { target: { value: '8.14.0+build123456789' } });
+ expect(
+ utils.queryByText('Invalid version, please use a valid semver version, e.g. 8.14.0')
+ ).toBeNull();
+ });
+ });
+
it('should display available version options', async () => {
mockSendGetAgentsAvailableVersions.mockClear();
mockSendGetAgentsAvailableVersions.mockResolvedValue({
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx
index e86dac0385dc2..89531c2b01181 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx
@@ -28,6 +28,7 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui';
import semverGt from 'semver/functions/gt';
import semverLt from 'semver/functions/lt';
+import semverValid from 'semver/functions/valid';
import {
AGENT_UPGRADE_COOLDOWN_IN_MIN,
@@ -267,6 +268,18 @@ export const AgentUpgradeAgentModal: React.FunctionComponent {
+ if (!selectedVersion[0].value) return undefined;
+ if (!semverValid(selectedVersion[0].value)) {
+ return (
+
+ );
+ }
+ }, [selectedVersion]);
+
const [selectedMaintenanceWindow, setSelectedMaintenanceWindow] = useState([
isSmallBatch ? maintenanceOptions[0] : maintenanceOptions[1],
]);
@@ -501,13 +514,15 @@ export const AgentUpgradeAgentModal: React.FunctionComponent
+ ) : !!semverErrors ? (
+ semverErrors
) : undefined
}
>
@@ -522,6 +537,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent
) : (
Date: Tue, 2 Apr 2024 13:28:50 +0200
Subject: [PATCH 13/63] [Observability AI Assistant] Fix dark mode (#179716)
---
.../get_edit_lens_configuration.tsx | 19 ++++++++------
.../open_lens_config/edit_action_helpers.ts | 1 +
.../components/buttons/new_chat_button.tsx | 25 ++++++-------------
.../public/components/chat/chat_body.tsx | 11 ++++----
4 files changed, 24 insertions(+), 32 deletions(-)
diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx
index c243ac9b84193..d83a8f0aec4cb 100644
--- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx
@@ -6,7 +6,7 @@
*/
import React, { useCallback, useState } from 'react';
-import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui';
+import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask, EuiThemeProvider } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n-react';
import { Provider } from 'react-redux';
@@ -99,6 +99,7 @@ export async function getEditLensConfiguration(
startDependencies,
getLensAttributeService(coreStart, startDependencies)
);
+ const theme = coreStart.theme.getTheme();
return ({
attributes,
@@ -223,13 +224,15 @@ export async function getEditLensConfiguration(
return getWrapper(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
};
diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts
index 9e738dea11ed7..476ac6f3e6505 100644
--- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts
+++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts
@@ -45,6 +45,7 @@ export async function executeEditAction({
const rootEmbeddable = embeddable.getRoot();
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
const ConfigPanel = await embeddable.openConfingPanel(startDependencies, isNewPanel, deletePanel);
+
if (ConfigPanel) {
const handle = overlays.openFlyout(
toMountPoint(
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx
index 6dadba00f2394..75cede6344c59 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/buttons/new_chat_button.tsx
@@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import React, { SVGProps } from 'react';
+import React from 'react';
import { EuiButton, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@@ -13,8 +13,11 @@ export function NewChatButton(
) {
const { collapsed, ...nextProps } = props;
return !props.collapsed ? (
-
-
+
{i18n.translate('xpack.observabilityAiAssistant.newChatButton', {
defaultMessage: 'New chat',
})}
@@ -22,23 +25,9 @@ export function NewChatButton(
) : (
);
}
-
-// To-do: replace with Eui icon once https://github.com/elastic/eui/pull/7524 is merged.
-export function EuiIconNewChat({ ...props }: SVGProps) {
- return (
-
-
-
-
- );
-}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx
index b5cd2cd0683e3..4a5d7d38c3e63 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/components/chat/chat_body.tsx
@@ -90,6 +90,11 @@ const animClassName = css`
${euiThemeVars.euiAnimSlightBounce} ${euiThemeVars.euiAnimSpeedNormal} forwards;
`;
+const containerClassName = css`
+ min-width: 0;
+ max-height: 100%;
+`;
+
const PADDING_AND_BORDER = 32;
export function ChatBody({
@@ -154,12 +159,6 @@ export function ChatBody({
}
}
- const containerClassName = css`
- background: white;
- min-width: 0;
- max-height: 100%;
- `;
-
const headerContainerClassName = css`
padding-right: ${showLinkToConversationsApp ? '32px' : '0'};
`;
From 849f8fb1ac8e0d3ced6ab885cdcfe67ddbe0a241 Mon Sep 17 00:00:00 2001
From: Jean-Louis Leysens
Date: Tue, 2 Apr 2024 13:38:59 +0200
Subject: [PATCH 14/63] [HTTP/CDN] Set `connect-src` to HTTPS only when CDN is
configured (#179609)
## Summary
Close https://github.com/elastic/kibana/issues/179061
---
.github/CODEOWNERS | 1 +
.../src/{ => cdn_config}/cdn_config.test.ts | 2 +-
.../src/{ => cdn_config}/cdn_config.ts | 7 +++++--
.../core-http-server-internal/src/cdn_config/index.ts | 9 +++++++++
4 files changed, 16 insertions(+), 3 deletions(-)
rename packages/core/http/core-http-server-internal/src/{ => cdn_config}/cdn_config.test.ts (98%)
rename packages/core/http/core-http-server-internal/src/{ => cdn_config}/cdn_config.ts (81%)
create mode 100644 packages/core/http/core-http-server-internal/src/cdn_config/index.ts
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 1088b4e8a1176..badb25a143bb4 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1252,6 +1252,7 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib
/x-pack/test/security_functional/ @elastic/kibana-security
/x-pack/test/spaces_api_integration/ @elastic/kibana-security
/x-pack/test/saved_object_api_integration/ @elastic/kibana-security
+/packages/core/http/core-http-server-internal/src/cdn_config/ @elastic/kibana-security @elastic/kibana-core
#CC# /x-pack/plugins/security/ @elastic/kibana-security
# Response Ops team
diff --git a/packages/core/http/core-http-server-internal/src/cdn_config.test.ts b/packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.test.ts
similarity index 98%
rename from packages/core/http/core-http-server-internal/src/cdn_config.test.ts
rename to packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.test.ts
index b6a954782f523..a728966b5e916 100644
--- a/packages/core/http/core-http-server-internal/src/cdn_config.test.ts
+++ b/packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.test.ts
@@ -41,7 +41,7 @@ describe('CdnConfig', () => {
it('generates the expected CSP additions', () => {
const cdnConfig = CdnConfig.from({ url: 'https://foo.bar:9999' });
expect(cdnConfig.getCspConfig()).toEqual({
- connect_src: ['foo.bar:9999'],
+ connect_src: ['https:'],
font_src: ['foo.bar:9999'],
img_src: ['foo.bar:9999'],
script_src: ['foo.bar:9999'],
diff --git a/packages/core/http/core-http-server-internal/src/cdn_config.ts b/packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.ts
similarity index 81%
rename from packages/core/http/core-http-server-internal/src/cdn_config.ts
rename to packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.ts
index e6fa29200ac74..68a255a62f5c2 100644
--- a/packages/core/http/core-http-server-internal/src/cdn_config.ts
+++ b/packages/core/http/core-http-server-internal/src/cdn_config/cdn_config.ts
@@ -7,7 +7,7 @@
*/
import { URL, format } from 'node:url';
-import type { CspAdditionalConfig } from './csp';
+import type { CspAdditionalConfig } from '../csp';
export interface Input {
url?: string;
@@ -34,13 +34,16 @@ export class CdnConfig {
public getCspConfig(): CspAdditionalConfig {
const host = this.host;
if (!host) return {};
+ // Since CDN is only used in specific envs we set `connect_src` to allow any
+ // but require https. This hardens security a bit, but allows apps like
+ // maps to still work as expected.
return {
+ connect_src: ['https:'],
font_src: [host],
img_src: [host],
script_src: [host],
style_src: [host],
worker_src: [host],
- connect_src: [host],
};
}
diff --git a/packages/core/http/core-http-server-internal/src/cdn_config/index.ts b/packages/core/http/core-http-server-internal/src/cdn_config/index.ts
new file mode 100644
index 0000000000000..e2e30ffbea243
--- /dev/null
+++ b/packages/core/http/core-http-server-internal/src/cdn_config/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { CdnConfig, type Input } from './cdn_config';
From da697032c7ba74a2bba8338f64354cb6b0393ea5 Mon Sep 17 00:00:00 2001
From: Luke G <11671118+lgestc@users.noreply.github.com>
Date: Tue, 2 Apr 2024 13:46:07 +0200
Subject: [PATCH 15/63] Fix threat intel edit filters (#179607)
## Summary
The following PR needs to be merged first:
https://github.com/elastic/kibana/pull/178701
This fixes
https://github.com/elastic/kibana/issues/174764#issuecomment-1992363217
and https://github.com/elastic/kibana/issues/179030
**To reproduce:**
Add whatever filter in the Threat Intelligence (via table filter in),
then click it (in the top bar) - filter edit popover is not filled in
with data.
On Alerts page though, it is filled in correctly - and it should look
like that on TI:
![image](https://github.com/elastic/kibana/assets/11671118/0de5f076-83dd-48f3-810b-75d1572536e3)
---
.../app/home/template_wrapper/index.tsx | 5 +++-
.../public/mocks/mock_security_context.tsx | 3 ++
.../query_bar/components/filter_in.test.tsx | 30 +++++++++++++++----
.../query_bar/components/filter_out.test.tsx | 29 ++++++++++++++----
.../query_bar/hooks/use_filter_in_out.test.ts | 27 +++++++++++++++++
.../query_bar/hooks/use_filter_in_out.ts | 12 ++++++--
.../public/modules/query_bar/utils/filter.ts | 18 +++++++++--
7 files changed, 106 insertions(+), 18 deletions(-)
diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
index a7dabdb81f0b4..19e8d55aa2dd5 100644
--- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
@@ -62,13 +62,16 @@ export const SecuritySolutionTemplateWrapper: React.FC
{children}
,
sourcererDataView: {
+ sourcererDataView: {
+ id: 'security-solution-default',
+ },
browserFields: {},
selectedPatterns: [],
indexPattern: { fields: [], title: '' },
diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_in.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_in.test.tsx
index f4cb5f231e2e8..0a44481f5cb25 100644
--- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_in.test.tsx
+++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_in.test.tsx
@@ -18,6 +18,8 @@ import {
FilterInContextMenu,
} from './filter_in';
+import { TestProvidersComponent } from '../../../mocks/test_providers';
+
jest.mock('../../indicators/hooks/use_filters_context');
const mockIndicator: Indicator = generateMockIndicator();
@@ -33,20 +35,27 @@ describe(' '
});
it('should render null (wrong data input)', () => {
- const { container } = render( );
+ const { container } = render( , {
+ wrapper: TestProvidersComponent,
+ });
expect(container).toBeEmptyDOMElement();
});
it('should render null (wrong field input)', () => {
- const { container } = render( );
+ const { container } = render( , {
+ wrapper: TestProvidersComponent,
+ });
expect(container).toBeEmptyDOMElement();
});
it('should render one EuiButtonIcon', () => {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiButtonIcon');
@@ -54,7 +63,10 @@ describe(' '
it('should render one EuiButtonEmpty', () => {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiButtonEmpty');
@@ -62,7 +74,10 @@ describe(' '
it('should render one EuiContextMenuItem (for EuiContextMenu use)', () => {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiContextMenuItem');
@@ -83,7 +98,10 @@ describe(' '
field={mockField}
Component={mockComponent}
data-test-subj={TEST_ID}
- />
+ />,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toBeInTheDocument();
diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_out.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_out.test.tsx
index 3155805c05a2d..1bc4d0e4da158 100644
--- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_out.test.tsx
+++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/components/filter_out.test.tsx
@@ -17,6 +17,7 @@ import {
FilterOutCellAction,
FilterOutContextMenu,
} from './filter_out';
+import { TestProvidersComponent } from '../../../mocks/test_providers';
jest.mock('../../indicators/hooks/use_filters_context');
@@ -33,20 +34,27 @@ describe(' {
- const { container } = render( );
+ const { container } = render( , {
+ wrapper: TestProvidersComponent,
+ });
expect(container).toBeEmptyDOMElement();
});
it('should render an empty component (wrong field input)', () => {
- const { container } = render( );
+ const { container } = render( , {
+ wrapper: TestProvidersComponent,
+ });
expect(container).toBeEmptyDOMElement();
});
it('should render one EuiButtonIcon', () => {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiButtonIcon');
@@ -54,7 +62,10 @@ describe(' {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiButtonEmpty');
@@ -62,7 +73,10 @@ describe(' {
const { getByTestId } = render(
-
+ ,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toHaveClass('euiContextMenuItem');
@@ -83,7 +97,10 @@ describe('
+ />,
+ {
+ wrapper: TestProvidersComponent,
+ }
);
expect(getByTestId(TEST_ID)).toBeInTheDocument();
diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts
index 9e22684bb4c31..7e435d944145b 100644
--- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts
+++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.test.ts
@@ -14,6 +14,9 @@ import {
import { TestProvidersComponent } from '../../../mocks/test_providers';
import { useFilterInOut, UseFilterInValue } from './use_filter_in_out';
import { FilterIn } from '../utils/filter';
+import { updateFiltersArray } from '../utils/filter';
+
+jest.mock('../utils/filter', () => ({ updateFiltersArray: jest.fn() }));
describe('useFilterInOut()', () => {
let hookResult: RenderHookResult<{}, UseFilterInValue, Renderer>;
@@ -53,4 +56,28 @@ describe('useFilterInOut()', () => {
expect(hookResult.result.current).toHaveProperty('filterFn');
});
+
+ describe('calling filterFn', () => {
+ it('should call dependencies ', () => {
+ const indicator: string = '0.0.0.0';
+ const field: string = 'threat.indicator.name';
+ const filterType = FilterIn;
+
+ hookResult = renderHook(() => useFilterInOut({ indicator, field, filterType }), {
+ wrapper: TestProvidersComponent,
+ });
+
+ expect(hookResult.result.current).toHaveProperty('filterFn');
+
+ hookResult.result.current.filterFn?.();
+
+ expect(jest.mocked(updateFiltersArray)).toHaveBeenCalledWith(
+ [],
+ 'threat.indicator.name',
+ '0.0.0.0',
+ undefined,
+ 'security-solution-default'
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.ts b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.ts
index 72388d8a9a8a6..b6526cf76d456 100644
--- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.ts
+++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/hooks/use_filter_in_out.ts
@@ -11,6 +11,7 @@ import { fieldAndValueValid, getIndicatorFieldAndValue } from '../../indicators/
import { useIndicatorsFiltersContext } from '../../indicators/hooks/use_filters_context';
import { Indicator } from '../../../../common/types/indicator';
import { FilterIn, FilterOut, updateFiltersArray } from '../utils/filter';
+import { useSourcererDataView } from '../../indicators/hooks/use_sourcerer_data_view';
export interface UseFilterInParam {
/**
@@ -44,6 +45,7 @@ export const useFilterInOut = ({
filterType,
}: UseFilterInParam): UseFilterInValue => {
const { filterManager } = useIndicatorsFiltersContext();
+ const { sourcererDataView } = useSourcererDataView();
const { key, value } =
typeof indicator === 'string'
@@ -52,9 +54,15 @@ export const useFilterInOut = ({
const filterFn = useCallback((): void => {
const existingFilters = filterManager.getFilters();
- const newFilters: Filter[] = updateFiltersArray(existingFilters, key, value, filterType);
+ const newFilters: Filter[] = updateFiltersArray(
+ existingFilters,
+ key,
+ value,
+ filterType,
+ sourcererDataView?.id
+ );
filterManager.setFilters(newFilters);
- }, [filterManager, filterType, key, value]);
+ }, [filterManager, filterType, key, sourcererDataView?.id, value]);
if (!fieldAndValueValid(key, value)) {
return {} as unknown as UseFilterInValue;
diff --git a/x-pack/plugins/threat_intelligence/public/modules/query_bar/utils/filter.ts b/x-pack/plugins/threat_intelligence/public/modules/query_bar/utils/filter.ts
index 1bb1661a4e750..451132ce234f9 100644
--- a/x-pack/plugins/threat_intelligence/public/modules/query_bar/utils/filter.ts
+++ b/x-pack/plugins/threat_intelligence/public/modules/query_bar/utils/filter.ts
@@ -17,7 +17,17 @@ export const FilterOut = false;
* @param negate Set to true when we create a negated filter (e.g. NOT threat.indicator.type: url)
* @returns The new {@link Filter}
*/
-const createFilter = (key: string, value: string, negate: boolean): Filter => ({
+const createFilter = ({
+ key,
+ value,
+ negate,
+ index,
+}: {
+ key: string;
+ value: string;
+ negate: boolean;
+ index?: string;
+}): Filter => ({
meta: {
alias: null,
negate,
@@ -25,6 +35,7 @@ const createFilter = (key: string, value: string, negate: boolean): Filter => ({
type: 'phrase',
key,
params: { query: value },
+ index,
},
query: { match_phrase: { [key]: value } },
});
@@ -71,9 +82,10 @@ export const updateFiltersArray = (
existingFilters: Filter[],
key: string,
value: string | null,
- filterType: boolean
+ filterType: boolean,
+ index?: string
): Filter[] => {
- const newFilter = createFilter(key, value as string, !filterType);
+ const newFilter = createFilter({ key, value: value as string, negate: !filterType, index });
const filter: Filter | undefined = filterExistsInFiltersArray(
existingFilters,
From f89c3e06cc3e7f1b7d6f1293254393a2ceb8f734 Mon Sep 17 00:00:00 2001
From: Pierre Gayvallet
Date: Tue, 2 Apr 2024 14:13:51 +0200
Subject: [PATCH 16/63] [ZDT migration] support root field addition (#179595)
## Summary
Fix https://github.com/elastic/kibana/issues/179258
Change the version compatibility and mapping generation
(`checkVersionCompatibility` and `generateAdditiveMappingDiff`) of the
ZDT migration algorithm to support the scenario were root fields are
added (adding new fields to our base mappings)
---
.../src/core/build_active_mappings.ts | 4 +
.../src/core/compare_mappings.test.ts | 28 +++
.../src/zdt/model/stages/init.test.ts | 2 +-
.../src/zdt/model/stages/init.ts | 2 +-
.../check_version_compatibility.test.mocks.ts | 10 ++
.../utils/check_version_compatibility.test.ts | 116 +++++++++++--
.../zdt/utils/check_version_compatibility.ts | 45 ++++-
...nerate_additive_mapping_diff.test.mocks.ts | 27 +++
.../generate_additive_mapping_diff.test.ts | 161 +++++++++++++++++-
.../utils/generate_additive_mapping_diff.ts | 22 ++-
.../zdt_2/root_field_addition.test.ts | 124 ++++++++++++++
11 files changed, 514 insertions(+), 27 deletions(-)
create mode 100644 packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.mocks.ts
create mode 100644 src/core/server/integration_tests/saved_objects/migrations/zdt_2/root_field_addition.test.ts
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts
index e3de61646d749..898a1114f8e57 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/build_active_mappings.ts
@@ -40,6 +40,10 @@ export function buildActiveMappings(
* @returns {IndexMapping}
*/
export function getBaseMappings(): IndexMapping {
+ // Important: the ZDT algorithm won't trigger a reindex on documents
+ // when changes on root field mappings are detected, meaning that adding
+ // a non-indexed root field and then later switching it to indexed is
+ // not support atm and would require changes to the ZDT algo.
return {
dynamic: 'strict',
properties: {
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/compare_mappings.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/compare_mappings.test.ts
index eec6e52090cae..29a1d6cfe4849 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/compare_mappings.test.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/compare_mappings.test.ts
@@ -118,4 +118,32 @@ describe('getUpdatedRootFields', () => {
expect(updatedFields).toEqual(['namespace', 'references']);
});
+
+ it('ignores fields not being present on the base mapping for the diff', () => {
+ const updatedFields = getUpdatedRootFields({
+ properties: {
+ ...getBaseMappings().properties,
+ someUnknownField: {
+ type: 'text',
+ },
+ },
+ });
+
+ expect(updatedFields).toEqual([]);
+ });
+
+ it('ignores fields not being present on the base mapping even with nested props', () => {
+ const updatedFields = getUpdatedRootFields({
+ properties: {
+ ...getBaseMappings().properties,
+ someTypeProps: {
+ properties: {
+ foo: { type: 'text' },
+ },
+ },
+ },
+ });
+
+ expect(updatedFields).toEqual([]);
+ });
});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.test.ts
index a68d6c266a652..bdaabfcc863cd 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.test.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.test.ts
@@ -345,7 +345,7 @@ describe('Stage: init', () => {
expect(generateAdditiveMappingDiffMock).toHaveBeenCalledTimes(1);
expect(generateAdditiveMappingDiffMock).toHaveBeenCalledWith({
types: ['foo', 'bar'].map((type) => context.typeRegistry.getType(type)),
- meta: fetchIndexResponse[currentIndex].mappings._meta,
+ mapping: fetchIndexResponse[currentIndex].mappings,
deletedTypes: context.deletedTypes,
});
});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.ts
index ceb97cb045987..7524e7b2d9f09 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/model/stages/init.ts
@@ -164,7 +164,7 @@ export const init: ModelStage<
case 'greater':
const additiveMappingChanges = generateAdditiveMappingDiff({
types,
- meta: currentMappings._meta ?? {},
+ mapping: currentMappings,
deletedTypes: context.deletedTypes,
});
return {
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.mocks.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.mocks.ts
index 8d7cbe657758c..e2ff1de594076 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.mocks.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.mocks.ts
@@ -19,3 +19,13 @@ jest.doMock('@kbn/core-saved-objects-base-server-internal', () => {
getVirtualVersionMap: getVirtualVersionMapMock,
};
});
+
+export const getUpdatedRootFieldsMock = jest.fn();
+
+jest.doMock('../../core/compare_mappings', () => {
+ const actual = jest.requireActual('../../core/compare_mappings');
+ return {
+ ...actual,
+ getUpdatedRootFields: getUpdatedRootFieldsMock,
+ };
+});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.ts
index 528855417e90f..640b60d5aeb3f 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.test.ts
@@ -10,6 +10,7 @@ import {
compareVirtualVersionsMock,
getVirtualVersionMapMock,
getVirtualVersionsFromMappingsMock,
+ getUpdatedRootFieldsMock,
} from './check_version_compatibility.test.mocks';
import type { SavedObjectsType } from '@kbn/core-saved-objects-server';
import type {
@@ -30,6 +31,7 @@ describe('checkVersionCompatibility', () => {
compareVirtualVersionsMock.mockReset().mockReturnValue({});
getVirtualVersionMapMock.mockReset().mockReturnValue({});
getVirtualVersionsFromMappingsMock.mockReset().mockReturnValue({ status: 'equal' });
+ getUpdatedRootFieldsMock.mockReset().mockReturnValue([]);
types = [createType({ name: 'foo' }), createType({ name: 'bar' })];
@@ -88,24 +90,112 @@ describe('checkVersionCompatibility', () => {
});
});
- it('returns the result of the compareModelVersions call', () => {
- const expected: CompareModelVersionResult = {
- status: 'lesser',
- details: {
- greater: [],
- lesser: [],
- equal: [],
- },
- };
- compareVirtualVersionsMock.mockReturnValue(expected);
-
- const result = checkVersionCompatibility({
+ it('calls getUpdatedRootFields with the correct parameters', () => {
+ checkVersionCompatibility({
types,
mappings,
source: 'mappingVersions',
deletedTypes,
});
- expect(result).toEqual(expected);
+ expect(getUpdatedRootFieldsMock).toHaveBeenCalledTimes(1);
+ expect(getUpdatedRootFieldsMock).toHaveBeenCalledWith(mappings);
+ });
+
+ describe('without updated root fields', () => {
+ it('returns the result of the compareModelVersions call', () => {
+ const expected: CompareModelVersionResult = {
+ status: 'lesser',
+ details: { greater: [], lesser: [], equal: [] },
+ };
+ compareVirtualVersionsMock.mockReturnValue(expected);
+
+ const result = checkVersionCompatibility({
+ types,
+ mappings,
+ source: 'mappingVersions',
+ deletedTypes,
+ });
+
+ expect(result).toEqual({
+ status: expected.status,
+ versionDetails: expected.details,
+ updatedRootFields: [],
+ });
+ });
+ });
+
+ describe('with updated root fields', () => {
+ beforeEach(() => {
+ getUpdatedRootFieldsMock.mockReturnValue(['rootA']);
+ });
+
+ it('returns the correct status for `greater` version status check', () => {
+ const expected: CompareModelVersionResult = {
+ status: 'greater',
+ details: { greater: [], lesser: [], equal: [] },
+ };
+ compareVirtualVersionsMock.mockReturnValue(expected);
+
+ const result = checkVersionCompatibility({
+ types,
+ mappings,
+ source: 'mappingVersions',
+ deletedTypes,
+ });
+
+ expect(result.status).toEqual('greater');
+ });
+
+ it('returns the correct status for `lesser` version status check', () => {
+ const expected: CompareModelVersionResult = {
+ status: 'lesser',
+ details: { greater: [], lesser: [], equal: [] },
+ };
+ compareVirtualVersionsMock.mockReturnValue(expected);
+
+ const result = checkVersionCompatibility({
+ types,
+ mappings,
+ source: 'mappingVersions',
+ deletedTypes,
+ });
+
+ expect(result.status).toEqual('conflict');
+ });
+
+ it('returns the correct status for `equal` version status check', () => {
+ const expected: CompareModelVersionResult = {
+ status: 'equal',
+ details: { greater: [], lesser: [], equal: [] },
+ };
+ compareVirtualVersionsMock.mockReturnValue(expected);
+
+ const result = checkVersionCompatibility({
+ types,
+ mappings,
+ source: 'mappingVersions',
+ deletedTypes,
+ });
+
+ expect(result.status).toEqual('greater');
+ });
+
+ it('returns the correct status for `conflict` version status check', () => {
+ const expected: CompareModelVersionResult = {
+ status: 'conflict',
+ details: { greater: [], lesser: [], equal: [] },
+ };
+ compareVirtualVersionsMock.mockReturnValue(expected);
+
+ const result = checkVersionCompatibility({
+ types,
+ mappings,
+ source: 'mappingVersions',
+ deletedTypes,
+ });
+
+ expect(result.status).toEqual('conflict');
+ });
});
});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.ts
index 651875e92c438..8dc60c639a56a 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/check_version_compatibility.ts
@@ -12,8 +12,10 @@ import {
compareVirtualVersions,
getVirtualVersionMap,
type IndexMapping,
- type CompareModelVersionResult,
+ type CompareModelVersionStatus,
+ type CompareModelVersionDetails,
} from '@kbn/core-saved-objects-base-server-internal';
+import { getUpdatedRootFields } from '../../core/compare_mappings';
interface CheckVersionCompatibilityOpts {
mappings: IndexMapping;
@@ -22,12 +24,20 @@ interface CheckVersionCompatibilityOpts {
deletedTypes: string[];
}
+type CheckVersionCompatibilityStatus = 'greater' | 'lesser' | 'equal' | 'conflict';
+
+interface CheckVersionCompatibilityResult {
+ status: CheckVersionCompatibilityStatus;
+ versionDetails: CompareModelVersionDetails;
+ updatedRootFields: string[];
+}
+
export const checkVersionCompatibility = ({
mappings,
types,
source,
deletedTypes,
-}: CheckVersionCompatibilityOpts): CompareModelVersionResult => {
+}: CheckVersionCompatibilityOpts): CheckVersionCompatibilityResult => {
const appVersions = getVirtualVersionMap(types);
const indexVersions = getVirtualVersionsFromMappings({
mappings,
@@ -37,5 +47,34 @@ export const checkVersionCompatibility = ({
if (!indexVersions) {
throw new Error(`Cannot check version: ${source} not present in the mapping meta`);
}
- return compareVirtualVersions({ appVersions, indexVersions, deletedTypes });
+
+ const updatedRootFields = getUpdatedRootFields(mappings);
+ const modelVersionStatus = compareVirtualVersions({ appVersions, indexVersions, deletedTypes });
+ const status = getCompatibilityStatus(modelVersionStatus.status, updatedRootFields.length > 0);
+
+ return {
+ status,
+ updatedRootFields,
+ versionDetails: modelVersionStatus.details,
+ };
+};
+
+const getCompatibilityStatus = (
+ versionStatus: CompareModelVersionStatus,
+ hasUpdatedRootFields: boolean
+): CheckVersionCompatibilityStatus => {
+ if (!hasUpdatedRootFields) {
+ return versionStatus;
+ }
+ switch (versionStatus) {
+ case 'lesser':
+ // lower model versions but additional root mappings => conflict
+ return 'conflict';
+ case 'equal':
+ // no change on model versions but additional root mappings => greater
+ return 'greater';
+ default:
+ // greater and conflict are not impacted
+ return versionStatus;
+ }
};
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.mocks.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.mocks.ts
new file mode 100644
index 0000000000000..b6cd5f063873f
--- /dev/null
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.mocks.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export const getBaseMappingsMock = jest.fn();
+
+jest.doMock('../../core/build_active_mappings', () => {
+ const actual = jest.requireActual('../../core/build_active_mappings');
+ return {
+ ...actual,
+ getBaseMappings: getBaseMappingsMock,
+ };
+});
+
+export const getUpdatedRootFieldsMock = jest.fn();
+
+jest.doMock('../../core/compare_mappings', () => {
+ const actual = jest.requireActual('../../core/compare_mappings');
+ return {
+ ...actual,
+ getUpdatedRootFields: getUpdatedRootFieldsMock,
+ };
+});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.ts
index 9083e4657b4ba..04f8754e9fe48 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.test.ts
@@ -6,9 +6,15 @@
* Side Public License, v 1.
*/
+import {
+ getBaseMappingsMock,
+ getUpdatedRootFieldsMock,
+} from './generate_additive_mapping_diff.test.mocks';
+
import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server';
-import type { IndexMappingMeta } from '@kbn/core-saved-objects-base-server-internal';
+import type { IndexMappingMeta, IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
import { generateAdditiveMappingDiff } from './generate_additive_mapping_diff';
+import { getBaseMappings } from '../../core/build_active_mappings';
import { createType } from '../test_helpers';
describe('generateAdditiveMappingDiff', () => {
@@ -19,6 +25,11 @@ describe('generateAdditiveMappingDiff', () => {
changes: [{ type: 'mappings_addition', addedMappings: {} }],
};
+ beforeEach(() => {
+ getBaseMappingsMock.mockReset().mockReturnValue({ properties: {} });
+ getUpdatedRootFieldsMock.mockReset().mockReturnValue([]);
+ });
+
const getTypes = () => {
const foo = createType({
name: 'foo',
@@ -41,6 +52,13 @@ describe('generateAdditiveMappingDiff', () => {
return { foo, bar };
};
+ const mappingFromMeta = (meta: IndexMappingMeta): IndexMapping => {
+ return {
+ properties: getBaseMappings().properties,
+ _meta: meta,
+ };
+ };
+
it('aggregates the mappings of the types with versions higher than in the index', () => {
const { foo, bar } = getTypes();
const types = [foo, bar];
@@ -53,7 +71,7 @@ describe('generateAdditiveMappingDiff', () => {
const addedMappings = generateAdditiveMappingDiff({
types,
- meta,
+ mapping: mappingFromMeta(meta),
deletedTypes,
});
@@ -75,7 +93,7 @@ describe('generateAdditiveMappingDiff', () => {
const addedMappings = generateAdditiveMappingDiff({
types,
- meta,
+ mapping: mappingFromMeta(meta),
deletedTypes,
});
@@ -97,7 +115,7 @@ describe('generateAdditiveMappingDiff', () => {
const addedMappings = generateAdditiveMappingDiff({
types,
- meta,
+ mapping: mappingFromMeta(meta),
deletedTypes,
});
@@ -120,7 +138,7 @@ describe('generateAdditiveMappingDiff', () => {
expect(() =>
generateAdditiveMappingDiff({
types,
- meta,
+ mapping: mappingFromMeta(meta),
deletedTypes,
})
).toThrowErrorMatchingInlineSnapshot(
@@ -136,11 +154,142 @@ describe('generateAdditiveMappingDiff', () => {
expect(() =>
generateAdditiveMappingDiff({
types,
- meta,
+ mapping: mappingFromMeta(meta),
deletedTypes,
})
).toThrowErrorMatchingInlineSnapshot(
`"Cannot generate additive mapping diff: mappingVersions not present on index meta"`
);
});
+
+ it('throws an error if _meta is not present on the index', () => {
+ expect(() =>
+ generateAdditiveMappingDiff({
+ types: [],
+ mapping: {
+ properties: {},
+ },
+ deletedTypes: [],
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Cannot generate additive mapping diff: meta not present on index"`
+ );
+ });
+
+ it('includes the root fields that were added', () => {
+ const { foo, bar } = getTypes();
+ const types = [foo, bar];
+ const meta: IndexMappingMeta = {
+ mappingVersions: {
+ foo: '10.2.0',
+ bar: '8.5.0',
+ },
+ };
+
+ getBaseMappingsMock.mockReturnValue({
+ properties: {
+ rootA: { type: 'keyword' },
+ rootB: { type: 'keyword' },
+ },
+ });
+ getUpdatedRootFieldsMock.mockReturnValue(['rootA']);
+
+ const addedMappings = generateAdditiveMappingDiff({
+ types,
+ mapping: mappingFromMeta(meta),
+ deletedTypes,
+ });
+
+ expect(addedMappings).toEqual({
+ rootA: { type: 'keyword' },
+ });
+ });
+
+ it('includes the root fields that were modified', () => {
+ const { foo, bar } = getTypes();
+ const types = [foo, bar];
+ const meta: IndexMappingMeta = {
+ mappingVersions: {
+ foo: '10.2.0',
+ bar: '8.5.0',
+ },
+ };
+
+ getBaseMappingsMock.mockReturnValue({
+ properties: {
+ rootA: { type: 'keyword' },
+ rootB: { type: 'keyword' },
+ references: {
+ type: 'nested',
+ properties: {
+ name: {
+ type: 'keyword',
+ },
+ type: {
+ type: 'keyword',
+ },
+ id: {
+ type: 'keyword',
+ },
+ },
+ },
+ },
+ });
+ getUpdatedRootFieldsMock.mockReturnValue(['rootA', 'references']);
+
+ const addedMappings = generateAdditiveMappingDiff({
+ types,
+ mapping: mappingFromMeta(meta),
+ deletedTypes,
+ });
+
+ expect(addedMappings).toEqual({
+ rootA: { type: 'keyword' },
+ references: {
+ type: 'nested',
+ properties: {
+ name: {
+ type: 'keyword',
+ },
+ type: {
+ type: 'keyword',
+ },
+ id: {
+ type: 'keyword',
+ },
+ },
+ },
+ });
+ });
+
+ it('combines the changes from the types and from the root fields', () => {
+ const { foo, bar } = getTypes();
+ const types = [foo, bar];
+ const meta: IndexMappingMeta = {
+ mappingVersions: {
+ foo: '10.1.0',
+ bar: '7.9.0',
+ },
+ };
+
+ getBaseMappingsMock.mockReturnValue({
+ properties: {
+ rootA: { type: 'keyword' },
+ rootB: { type: 'keyword' },
+ },
+ });
+ getUpdatedRootFieldsMock.mockReturnValue(['rootA']);
+
+ const addedMappings = generateAdditiveMappingDiff({
+ types,
+ mapping: mappingFromMeta(meta),
+ deletedTypes,
+ });
+
+ expect(addedMappings).toEqual({
+ foo: foo.mappings,
+ bar: bar.mappings,
+ rootA: { type: 'keyword' },
+ });
+ });
});
diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.ts
index 0ef7437b2d6c3..2c6d51881b844 100644
--- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.ts
+++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/zdt/utils/generate_additive_mapping_diff.ts
@@ -11,15 +11,17 @@ import type {
SavedObjectsMappingProperties,
} from '@kbn/core-saved-objects-server';
import {
- IndexMappingMeta,
+ type IndexMapping,
getVirtualVersionsFromMappingMeta,
getVirtualVersionMap,
getModelVersionDelta,
} from '@kbn/core-saved-objects-base-server-internal';
+import { getUpdatedRootFields } from '../../core/compare_mappings';
+import { getBaseMappings } from '../../core/build_active_mappings';
interface GenerateAdditiveMappingsDiffOpts {
types: SavedObjectsType[];
- meta: IndexMappingMeta;
+ mapping: IndexMapping;
deletedTypes: string[];
}
@@ -32,9 +34,15 @@ interface GenerateAdditiveMappingsDiffOpts {
*/
export const generateAdditiveMappingDiff = ({
types,
- meta,
+ mapping,
deletedTypes,
}: GenerateAdditiveMappingsDiffOpts): SavedObjectsMappingProperties => {
+ const meta = mapping._meta;
+ if (!meta) {
+ // should never occur given we only generate additive mapping diff when we've recognized a zdt index
+ throw new Error('Cannot generate additive mapping diff: meta not present on index');
+ }
+
const typeVersions = getVirtualVersionMap(types);
const mappingVersion = getVirtualVersionsFromMappingMeta({
meta,
@@ -69,5 +77,13 @@ export const generateAdditiveMappingDiff = ({
addedMappings[type] = typeMap[type].mappings;
});
+ const changedRootFields = getUpdatedRootFields(mapping);
+ if (changedRootFields.length) {
+ const baseMappings = getBaseMappings();
+ changedRootFields.forEach((changedRootField) => {
+ addedMappings[changedRootField] = baseMappings.properties[changedRootField];
+ });
+ }
+
return addedMappings;
};
diff --git a/src/core/server/integration_tests/saved_objects/migrations/zdt_2/root_field_addition.test.ts b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/root_field_addition.test.ts
new file mode 100644
index 0000000000000..47f853187b18f
--- /dev/null
+++ b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/root_field_addition.test.ts
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+
+const mockGetBaseMappings = jest.fn();
+
+// No other way to simulate a change in root mappings unfortunately
+jest.mock(
+ '@kbn/core-saved-objects-migration-server-internal/src/core/build_active_mappings',
+ () => {
+ const actual = jest.requireActual(
+ '@kbn/core-saved-objects-migration-server-internal/src/core/build_active_mappings'
+ );
+ return {
+ ...actual,
+ getBaseMappings: () => mockGetBaseMappings(),
+ };
+ }
+);
+
+import Path from 'path';
+import fs from 'fs/promises';
+import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server';
+import '../jest_matchers';
+import { getKibanaMigratorTestKit, startElasticsearch } from '../kibana_migrator_test_kit';
+import { delay, parseLogFile } from '../test_utils';
+import { getBaseMigratorParams, getFooType } from '../fixtures/zdt_base.fixtures';
+
+export const logFilePath = Path.join(__dirname, 'root_field_addition.test.log');
+
+describe('ZDT upgrades - introducing new root fields', () => {
+ let esServer: TestElasticsearchUtils['es'];
+
+ beforeAll(async () => {
+ await fs.unlink(logFilePath).catch(() => {});
+ esServer = await startElasticsearch();
+ });
+
+ afterAll(async () => {
+ await esServer?.stop();
+ await delay(10);
+ });
+
+ const baseMappings = {
+ dynamic: 'strict',
+ properties: {
+ type: {
+ type: 'keyword',
+ },
+ namespace: {
+ type: 'keyword',
+ },
+ coreMigrationVersion: {
+ type: 'keyword',
+ },
+ typeMigrationVersion: {
+ type: 'version',
+ },
+ },
+ };
+
+ const createBaseline = async () => {
+ mockGetBaseMappings.mockReturnValue(baseMappings);
+
+ const fooType = getFooType();
+ const { runMigrations } = await getKibanaMigratorTestKit({
+ ...getBaseMigratorParams(),
+ types: [fooType],
+ });
+ await runMigrations();
+ };
+
+ it('should support adding the new root fields', async () => {
+ await createBaseline();
+
+ const updatedMappings = {
+ ...baseMappings,
+ properties: {
+ ...baseMappings.properties,
+ someNewRootField: {
+ type: 'keyword',
+ },
+ anotherNewRootField: {
+ type: 'text',
+ },
+ },
+ };
+ mockGetBaseMappings.mockReturnValue(updatedMappings);
+
+ const fooType = getFooType();
+
+ const { runMigrations, client } = await getKibanaMigratorTestKit({
+ ...getBaseMigratorParams(),
+ logFilePath,
+ types: [fooType],
+ });
+
+ await runMigrations();
+
+ const records = await parseLogFile(logFilePath);
+
+ expect(records).toContainLogEntries(
+ [
+ 'mapping version check result: greater',
+ 'INIT -> UPDATE_INDEX_MAPPINGS',
+ 'INDEX_STATE_UPDATE_DONE -> DOCUMENTS_UPDATE_INIT',
+ '-> DONE',
+ 'Migration completed',
+ ],
+ { ordered: true }
+ );
+
+ const mappings = await client.indices.getMapping({ index: '.kibana_1' });
+
+ expect(mappings['.kibana_1'].mappings.properties).toEqual({
+ ...updatedMappings.properties,
+ foo: fooType.mappings,
+ });
+ });
+});
From 66af26285c401a33dac4e02ff558858d2af55b80 Mon Sep 17 00:00:00 2001
From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
Date: Tue, 2 Apr 2024 08:27:43 -0400
Subject: [PATCH 17/63] [Security Solution][Endpoint] Change Response Actions
client for SentinelOne to support Sub-Action new `UnsecuredActionsClient`
(#179388)
## Summary
Changes are in support of Bi-Directional response actions and includes:
- `EndpointAppContextService.getInternalResponseActionsClient()` now
supports retrieving clients for non-endpoint EDR systems (ex.
SentinelOne)
- Refactored SentinelOne Response Actions Client and pulled logic around
using the Connector into its own class. This new class allows it to
normalize the use of connectors regardless of the Action plugin service
being used (`ActionClient` or `IUnsecuredActionsClient`)
- `CompleteExternalActionsTaskRunner` was adjust to ignore errors for
`agentType`'s that are not configured in the system (don't log useless
errors)
- The `run_sentinelone_host.js` script was enhanced to **not** create
multiple VMs running SentinelOne agent on them if one is already
running. A new CLI argument - `forceNewS1Host` - was also added to by
pass this behaviour and allow for a VM to always be created
### 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
---
.../scripts/endpoint/common/fleet_services.ts | 4 +-
.../scripts/endpoint/common/vm_services.ts | 61 ++++++-
.../endpoint/sentinelone_host/index.ts | 65 ++++++--
.../endpoint/endpoint_app_context_services.ts | 61 ++++---
.../complete_external_actions_task.ts | 6 +-
...plete_external_actions_task_runner.test.ts | 16 ++
.../complete_external_actions_task_runner.ts | 36 +++-
.../server/endpoint/mocks.ts | 6 +-
.../services/actions/clients/errors.ts | 10 ++
...rmalized_external_connector_client.test.ts | 156 ++++++++++++++++++
.../normalized_external_connector_client.ts | 113 +++++++++++++
.../services/actions/clients/mocks.ts | 18 +-
.../sentinel_one_actions_client.test.ts | 46 +-----
.../sentinel_one_actions_client.ts | 87 +++-------
.../security_solution/server/plugin.ts | 1 +
.../server/plugin_contract.ts | 2 +
.../agent_type_support.ts | 2 +-
17 files changed, 527 insertions(+), 163 deletions(-)
create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts
create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts
index c208e87ab166e..5a99dcc8322db 100644
--- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts
+++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts
@@ -517,7 +517,7 @@ export const getLatestAgentDownloadVersion = async (
log?: ToolingLog
): Promise => {
const artifactsUrl = 'https://artifacts-api.elastic.co/v1/versions';
- const semverMatch = `<=${version}`;
+ const semverMatch = `<=${version.replace(`-SNAPSHOT`, '')}`;
const artifactVersionsResponse: { versions: string[] } = await nodeFetch(artifactsUrl).then(
(response) => {
if (!response.ok) {
@@ -551,6 +551,8 @@ export const getLatestAgentDownloadVersion = async (
semverMatch
);
+ log?.verbose(`Matched [${matchedVersion}] for .maxStatisfying(${semverMatch})`);
+
if (!matchedVersion) {
throw new Error(`Unable to find a semver version that meets ${semverMatch}`);
}
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts
index 4209b554b2732..084e068768e8f 100644
--- a/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts
+++ b/x-pack/plugins/security_solution/scripts/endpoint/common/vm_services.ts
@@ -170,15 +170,12 @@ export const generateVmName = (identifier: string = baseGenerator.randomUser()):
* @param threshold
*/
export const getMultipassVmCountNotice = async (threshold: number = 1): Promise => {
- const response = await execa.command(`multipass list --format=json`);
+ const listOfVMs = await findVm('multipass');
- const output: { list: Array<{ ipv4: string; name: string; release: string; state: string }> } =
- JSON.parse(response.stdout);
-
- if (output.list.length > threshold) {
+ if (listOfVMs.data.length > threshold) {
return `-----------------------------------------------------------------
${chalk.red('NOTE:')} ${chalk.bold(
- chalk.red(`You currently have ${chalk.red(output.list.length)} VMs running.`)
+ chalk.red(`You currently have ${chalk.red(listOfVMs.data.length)} VMs running.`)
)}
Remember to delete those no longer being used.
View running VMs: ${chalk.cyan('multipass list')}
@@ -378,3 +375,55 @@ export const getHostVmClient = (
? createVagrantHostVmClient(hostname, vagrantFile, log)
: createMultipassHostVmClient(hostname, log);
};
+
+/**
+ * Retrieve a list of running VM names
+ * @param type
+ * @param name
+ * @param log
+ */
+export const findVm = async (
+ type: SupportedVmManager,
+ /** Filter results by VM name */
+ name?: string | RegExp,
+ log: ToolingLog = createToolingLogger()
+): Promise<{ data: string[] }> => {
+ log.verbose(`Finding [${type}] VMs with name [${name}]`);
+
+ if (type === 'multipass') {
+ const list = JSON.parse((await execa.command(`multipass list --format json`)).stdout) as {
+ list: Array<{
+ ipv4: string[];
+ name: string;
+ release: string;
+ state: string;
+ }>;
+ };
+
+ log.verbose(`List of VM running:`, list);
+
+ if (!list.list || list.list.length === 0) {
+ return { data: [] };
+ }
+
+ return {
+ data: !name
+ ? list.list.map((vmEntry) => vmEntry.name)
+ : list.list.reduce((acc, vmEntry) => {
+ if (typeof name === 'string') {
+ if (vmEntry.name === name) {
+ acc.push(vmEntry.name);
+ }
+ } else {
+ if (name.test(vmEntry.name)) {
+ acc.push(vmEntry.name);
+ }
+ }
+
+ return acc;
+ }, [] as string[]),
+ };
+ }
+
+ throw new Error(`findVm() does not yet have support for [${type}]`);
+};
diff --git a/x-pack/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts
index 871d5bcf83606..2811fe9e57d09 100644
--- a/x-pack/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts
+++ b/x-pack/plugins/security_solution/scripts/endpoint/sentinelone_host/index.ts
@@ -27,7 +27,13 @@ import {
installSentinelOneAgent,
S1Client,
} from './common';
-import { createVm, generateVmName, getMultipassVmCountNotice } from '../common/vm_services';
+import {
+ createMultipassHostVmClient,
+ createVm,
+ findVm,
+ generateVmName,
+ getMultipassVmCountNotice,
+} from '../common/vm_services';
import { createKbnClient } from '../common/stack_services';
export const cli = async () => {
@@ -51,7 +57,7 @@ console and pushes the data to Elasticsearch.`,
's1ApiToken',
'vmName',
],
- boolean: ['forceFleetServer'],
+ boolean: ['forceFleetServer', 'forceNewS1Host'],
default: {
kibanaUrl: 'http://127.0.0.1:5601',
username: 'elastic',
@@ -69,6 +75,10 @@ console and pushes the data to Elasticsearch.`,
Default: re-uses existing dev policy (if found) or creates a new one
--forceFleetServer Optional. If fleet server should be started/configured even if it seems
like it is already setup.
+ --forceNewS1Host Optional. Force a new VM host to be created and enrolled with SentinelOne.
+ By default, a check is done to see if a host running SentinelOne is
+ already running and if so, a new one will not be created - unless this
+ option is used
--username Optional. User name to be used for auth against elasticsearch and
kibana (Default: elastic).
--password Optional. Password associated with the username (Default: changeme)
@@ -86,6 +96,7 @@ const runCli: RunFn = async ({ log, flags }) => {
const s1ApiToken = flags.s1ApiToken as string;
const policy = flags.policy as string;
const forceFleetServer = flags.forceFleetServer as boolean;
+ const forceNewS1Host = flags.forceNewS1Host as boolean;
const getRequiredArgMessage = (argName: string) => `${argName} argument is required`;
createToolingLogger.setDefaultLogLevelFromCliFlags(flags);
@@ -102,22 +113,40 @@ const runCli: RunFn = async ({ log, flags }) => {
password,
});
- const hostVm = await createVm({
- type: 'multipass',
- name: vmName,
- log,
- memory: '2G',
- disk: '10G',
- });
-
- const s1Info = await installSentinelOneAgent({
- hostVm,
- log,
- s1Client,
- });
-
- log.info(`SentinelOne Agent Status:
-${s1Info.status}`);
+ const runningS1VMs = (
+ await findVm(
+ 'multipass',
+ flags.vmName ? vmName : new RegExp(`^${vmName.substring(0, vmName.lastIndexOf('-'))}`)
+ )
+ ).data;
+
+ // Avoid enrolling another VM with SentinelOne if we already have one running
+ const hostVm =
+ forceNewS1Host || runningS1VMs.length === 0
+ ? await createVm({
+ type: 'multipass',
+ name: vmName,
+ log,
+ memory: '2G',
+ disk: '10G',
+ }).then((vm) => {
+ return installSentinelOneAgent({
+ hostVm: vm,
+ log,
+ s1Client,
+ }).then((s1Info) => {
+ log.info(`SentinelOne Agent Status:\n${s1Info.status}`);
+
+ return vm;
+ });
+ })
+ : await Promise.resolve(createMultipassHostVmClient(runningS1VMs[0], log)).then((vm) => {
+ log.info(
+ `A host VM running SentinelOne Agent is already running - will reuse it.\nTIP: Use 'forceNewS1Host' to force the creation of a new one if desired`
+ );
+
+ return vm;
+ });
const {
id: agentPolicyId,
diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts
index 250ac6f9be599..fa435135681f1 100644
--- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts
@@ -23,9 +23,9 @@ import type {
import type { PluginStartContract as AlertsPluginStartContract } from '@kbn/alerting-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { FleetActionsClientInterface } from '@kbn/fleet-plugin/server/services/actions/types';
-import { EndpointError } from '../../common/endpoint/errors';
+import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
import type { ResponseActionsClient } from './services';
-import { EndpointActionsClient } from './services';
+import { getResponseActionsClient } from './services';
import {
getAgentPolicyCreateCallback,
getAgentPolicyUpdateCallback,
@@ -54,7 +54,6 @@ import type { FeatureUsageService } from './services/feature_usage/service';
import type { ExperimentalFeatures } from '../../common/experimental_features';
import type { ProductFeaturesService } from '../lib/product_features_service/product_features_service';
import type { ResponseActionAgentType } from '../../common/endpoint/service/response_actions/constants';
-
export interface EndpointAppContextServiceSetupContract {
securitySolutionRequestContextFactory: IRequestContextFactory;
cloud: CloudSetup;
@@ -83,6 +82,7 @@ export interface EndpointAppContextServiceStartContract {
esClient: ElasticsearchClient;
productFeaturesService: ProductFeaturesService;
savedObjectsClient: SavedObjectsClientContract;
+ connectorActions: ActionsPluginStartContract;
}
/**
@@ -276,34 +276,57 @@ export class EndpointAppContextService {
public getInternalResponseActionsClient({
agentType = 'endpoint',
username = 'elastic',
+ taskId,
+ taskType,
}: {
agentType?: ResponseActionAgentType;
username?: string;
+ /** Used with background task and needed for `UnsecuredActionsClient` */
+ taskId?: string;
+ /** Used with background task and needed for `UnsecuredActionsClient` */
+ taskType?: string;
}): ResponseActionsClient {
if (!this.startDependencies?.esClient) {
throw new EndpointAppContentServicesNotStartedError();
}
- if (agentType !== `endpoint`) {
- throw new EndpointError(
- `Agent type [${agentType}] does not support usage of response actions via non-HTTP requests!`
- );
+ let connectorActionsClient =
+ this.startDependencies.connectorActions.getUnsecuredActionsClient();
+
+ // If we have a task id and type, then call is coming from a background task and we need to use those
+ // values with the Action's plugin `UnsecuredActionsClient`'s `.execute()` method. To do so in a
+ // transparent way to the existing response action client, we create a Proxy here and trap the
+ // `GET execute` property and wrap it a function that will automatically inject this data into
+ // `execute()` calls
+ if (taskId && taskType) {
+ connectorActionsClient = new Proxy(connectorActionsClient, {
+ get(target, prop, receiver) {
+ if (prop === 'execute') {
+ return function (execArgs: Parameters[0]) {
+ return target.execute({
+ ...execArgs,
+ relatedSavedObjects: [
+ ...(execArgs.relatedSavedObjects ?? []),
+ {
+ id: taskId,
+ type: taskType,
+ },
+ ],
+ });
+ };
+ }
+
+ return Reflect.get(target, prop, receiver);
+ },
+ });
}
- // TODO:PT switch to using `getResponseActionsClient()` instead once we support getting internal versions of connectorsActions
- // return getResponseActionsClient(agentType, {
- // endpointService: this,
- // esClient: this.startDependencies.esClient,
- // username: 'elastic',
- // isAutomated: true,
- // connectorActions: undefined, // FIXME:PT get internal client here
- // });
-
- return new EndpointActionsClient({
- username,
- esClient: this.startDependencies.esClient,
+ return getResponseActionsClient(agentType, {
endpointService: this,
+ esClient: this.startDependencies.esClient,
+ username,
isAutomated: true,
+ connectorActions: connectorActionsClient,
});
}
diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task.ts b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task.ts
index ba60fcc37a045..a9c81fbd69e2c 100644
--- a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task.ts
@@ -98,10 +98,14 @@ export class CompleteExternalResponseActionsTask {
);
}
+ const { id: taskId, taskType } = taskInstance;
+
return new CompleteExternalActionsTaskRunner(
this.options.endpointAppContext.service,
this.esClient,
- this.taskInterval
+ this.taskInterval,
+ taskId,
+ taskType
);
},
},
diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts
index a5f7b3e1d14b5..90c392d450adf 100644
--- a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.test.ts
@@ -13,6 +13,7 @@ import { responseActionsClientMock } from '../../services/actions/clients/mocks'
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import { ENDPOINT_ACTION_RESPONSES_INDEX } from '../../../../common/endpoint/constants';
import { waitFor } from '@testing-library/react';
+import { ResponseActionsConnectorNotConfiguredError } from '../../services/actions/clients/errors';
describe('CompleteExternalTaskRunner class', () => {
let endpointContextServicesMock: ReturnType;
@@ -52,6 +53,21 @@ describe('CompleteExternalTaskRunner class', () => {
);
});
+ it('should NOT log an error if agentType is not configured with a connector', async () => {
+ (endpointContextServicesMock.getInternalResponseActionsClient as jest.Mock).mockImplementation(
+ () => {
+ const clientMock = responseActionsClientMock.create();
+ (clientMock.processPendingActions as jest.Mock).mockImplementation(async () => {
+ throw new ResponseActionsConnectorNotConfiguredError('foo');
+ });
+ return clientMock;
+ }
+ );
+ await runnerInstance.run();
+
+ expect(endpointContextServicesMock.createLogger().error).not.toHaveBeenCalled();
+ });
+
it('should call `processPendingAction` for each external agent type', async () => {
await runnerInstance.run();
const getInternalResponseActionsClientMock = (
diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.ts b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.ts
index c42127ad09cc1..bb6fcc0c897d7 100644
--- a/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/lib/response_actions/complete_external_actions_task_runner.ts
@@ -8,6 +8,7 @@
import type { CancellableTask, RunContext, RunResult } from '@kbn/task-manager-plugin/server/task';
import type { Logger, ElasticsearchClient } from '@kbn/core/server';
import type { BulkRequest } from '@elastic/elasticsearch/lib/api/types';
+import { ResponseActionsConnectorNotConfiguredError } from '../../services/actions/clients/errors';
import { catchAndWrapError } from '../../utils';
import { stringify } from '../../utils/stringify';
import { RESPONSE_ACTION_AGENT_TYPE } from '../../../../common/endpoint/service/response_actions/constants';
@@ -32,7 +33,9 @@ export class CompleteExternalActionsTaskRunner
constructor(
private readonly endpointContextServices: EndpointAppContextService,
private readonly esClient: ElasticsearchClient,
- private readonly nextRunInterval: string = '60s'
+ private readonly nextRunInterval: string = '60s',
+ private readonly taskId?: string,
+ private readonly taskType?: string
) {
this.log = this.endpointContextServices.createLogger(
// Adding a unique identifier to the end of the class name to help identify log entries related to this run
@@ -111,15 +114,30 @@ export class CompleteExternalActionsTaskRunner
return null;
}
- // FIXME:PT need to implement logic that first checks if a given agent type is currently supported - like do we have what we need to "talk" to them?
-
const agentTypeActionsClient =
- this.endpointContextServices.getInternalResponseActionsClient({ agentType });
-
- return agentTypeActionsClient.processPendingActions({
- abortSignal: this.abortController.signal,
- addToQueue: this.updatesQueue.addToQueue.bind(this.updatesQueue),
- });
+ this.endpointContextServices.getInternalResponseActionsClient({
+ agentType,
+ taskType: this.taskType,
+ taskId: this.taskId,
+ });
+
+ return agentTypeActionsClient
+ .processPendingActions({
+ abortSignal: this.abortController.signal,
+ addToQueue: this.updatesQueue.addToQueue.bind(this.updatesQueue),
+ })
+ .catch((err) => {
+ // ignore errors due to connector not being configured - no point in logging errors if a customer
+ // is not using response actions for the given agent type
+ if (err instanceof ResponseActionsConnectorNotConfiguredError) {
+ this.log.debug(
+ `Skipping agentType [${agentType}]: No stack connector configured for this agent type`
+ );
+ return null;
+ }
+
+ this.errors.push(err.message);
+ });
}
)
);
diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts
index 344d3bbd1f2f9..f3d39737a7cf2 100644
--- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts
@@ -46,6 +46,8 @@ import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-m
import { casesPluginMock } from '@kbn/cases-plugin/server/mocks';
import { createCasesClientMock } from '@kbn/cases-plugin/server/client/mocks';
import type { AddVersionOpts, VersionedRouteConfig } from '@kbn/core-http-server';
+import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock';
+import type { PluginStartContract } from '@kbn/actions-plugin/server';
import { responseActionsClientMock } from './services/actions/clients/mocks';
import { getEndpointAuthzInitialStateMock } from '../../common/endpoint/service/authz/mocks';
import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__';
@@ -72,7 +74,6 @@ import { EndpointFleetServicesFactory } from './services/fleet';
import { createLicenseServiceMock } from '../../common/license/mocks';
import { createFeatureUsageServiceMock } from './services/feature_usage/mocks';
import { createProductFeaturesServiceMock } from '../lib/product_features_service/mocks';
-
/**
* Creates a mocked EndpointAppContext.
*/
@@ -229,6 +230,9 @@ export const createMockEndpointAppContextServiceStartContract =
esClient: elasticsearchClientMock.createElasticsearchClient(),
productFeaturesService,
savedObjectsClient: savedObjectsClientMock.create(),
+ connectorActions: {
+ getUnsecuredActionsClient: jest.fn().mockReturnValue(unsecuredActionsClientMock.create()),
+ } as unknown as jest.Mocked,
};
};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
index 2e644f9a3a760..d79986e899fe0 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
@@ -44,3 +44,13 @@ export class UnsupportedResponseActionsAgentTypeError extends ResponseActionsCli
super(message, statusCode, meta);
}
}
+
+export class ResponseActionsConnectorNotConfiguredError extends ResponseActionsClientError {
+ constructor(
+ connectorTypeId: string,
+ public readonly statusCode: number = 400,
+ public readonly meta?: unknown
+ ) {
+ super(`No stack connector instance configured for [${connectorTypeId}]`, statusCode, meta);
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts
new file mode 100644
index 0000000000000..5c9d6154226e8
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.test.ts
@@ -0,0 +1,156 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
+import { responseActionsClientMock } from '../mocks';
+import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
+import type { Logger } from '@kbn/logging';
+import type { NormalizedExternalConnectorClientExecuteOptions } from './normalized_external_connector_client';
+import { NormalizedExternalConnectorClient } from './normalized_external_connector_client';
+import { ResponseActionsConnectorNotConfiguredError } from '../errors';
+import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server';
+import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock';
+
+describe('`NormalizedExternalConnectorClient` class', () => {
+ let logger: Logger;
+ let executeInputOptions: NormalizedExternalConnectorClientExecuteOptions;
+
+ beforeEach(() => {
+ logger = loggingSystemMock.create().get('mock');
+ executeInputOptions = { params: { subAction: 'sub-action-1', subActionParams: {} } };
+ });
+
+ describe('#getConnectorInstance()', () => {
+ let actionPluginConnectorClient: ActionsClientMock;
+
+ beforeEach(() => {
+ actionPluginConnectorClient = responseActionsClientMock.createConnectorActionsClient({
+ getAllResponse: [responseActionsClientMock.createConnector({ actionTypeId: 'foo' })],
+ });
+ });
+
+ it('should search for the connector when first API call is done', async () => {
+ const testInstance = new NormalizedExternalConnectorClient(
+ 'foo',
+ actionPluginConnectorClient,
+ logger
+ );
+
+ expect(actionPluginConnectorClient.getAll).not.toHaveBeenCalled();
+
+ await testInstance.execute({ params: { subAction: 'sub-action-1', subActionParams: {} } });
+
+ expect(actionPluginConnectorClient.getAll).toHaveBeenCalledTimes(1);
+
+ // Subsequent calls to `.execute()` should not trigger logic to find Connector instance again
+ await testInstance.execute(executeInputOptions);
+
+ expect(actionPluginConnectorClient.getAll).toHaveBeenCalledTimes(1);
+ });
+
+ it('should error if unable to retrieve all connector instances (`.getAll()`) ', async () => {
+ (actionPluginConnectorClient.getAll as jest.Mock).mockImplementation(async () => {
+ throw new Error('oh oh');
+ });
+ const testInstance = new NormalizedExternalConnectorClient(
+ 'foo',
+ actionPluginConnectorClient,
+ logger
+ );
+ const executePromise = testInstance.execute(executeInputOptions);
+
+ await expect(executePromise).rejects.toHaveProperty(
+ 'message',
+ 'Unable to retrieve list of stack connectors in order to find one for [foo]: oh oh'
+ );
+ await expect(executePromise).rejects.toHaveProperty('statusCode', 400);
+ });
+
+ it.each([
+ ['is not defined', () => []],
+ [
+ 'is deprecated',
+ () => [
+ responseActionsClientMock.createConnector({ actionTypeId: 'foo', isDeprecated: true }),
+ ],
+ ],
+ [
+ 'is missing secrets',
+ () => [
+ responseActionsClientMock.createConnector({
+ actionTypeId: 'foo',
+ isMissingSecrets: true,
+ }),
+ ],
+ ],
+ ])('should error if a connector instance %s', async (_, getResponse) => {
+ (actionPluginConnectorClient.getAll as jest.Mock).mockResolvedValue(getResponse());
+ const testInstance = new NormalizedExternalConnectorClient(
+ 'foo',
+ actionPluginConnectorClient,
+ logger
+ );
+ const executePromise = testInstance.execute(executeInputOptions);
+
+ await expect(executePromise).rejects.toEqual(
+ new ResponseActionsConnectorNotConfiguredError('foo')
+ );
+ });
+ });
+
+ describe('with ActionClient', () => {
+ let actionPluginConnectorClient: ActionsClientMock;
+
+ beforeEach(() => {
+ actionPluginConnectorClient = responseActionsClientMock.createConnectorActionsClient({
+ getAllResponse: [responseActionsClientMock.createConnector({ actionTypeId: 'foo' })],
+ });
+ });
+
+ it('should call Action Plugin client `.execute()` with expected arguments', async () => {
+ const testInstance = new NormalizedExternalConnectorClient(
+ 'foo',
+ actionPluginConnectorClient,
+ logger
+ );
+ await testInstance.execute(executeInputOptions);
+
+ expect(actionPluginConnectorClient.execute).toHaveBeenCalledWith({
+ actionId: 'connector-mock-id-1',
+ params: executeInputOptions.params,
+ });
+ });
+ });
+
+ describe('with IUnsecuredActionsClient', () => {
+ let actionPluginConnectorClient: IUnsecuredActionsClient;
+
+ beforeEach(() => {
+ actionPluginConnectorClient = unsecuredActionsClientMock.create();
+
+ (actionPluginConnectorClient.getAll as jest.Mock).mockResolvedValue([
+ responseActionsClientMock.createConnector({ actionTypeId: 'foo' }),
+ ]);
+ });
+
+ it('should call Action Plugin client `.execute()` with expected arguments', async () => {
+ const testInstance = new NormalizedExternalConnectorClient(
+ 'foo',
+ actionPluginConnectorClient,
+ logger
+ );
+ await testInstance.execute(executeInputOptions);
+
+ expect(actionPluginConnectorClient.execute).toHaveBeenCalledWith({
+ id: 'connector-mock-id-1',
+ requesterId: 'background_task',
+ spaceId: 'default',
+ params: executeInputOptions.params,
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts
new file mode 100644
index 0000000000000..2c0647b43ad73
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/normalized_external_connector_client.ts
@@ -0,0 +1,113 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import type { IUnsecuredActionsClient, ActionsClient } from '@kbn/actions-plugin/server';
+import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
+import type { Logger } from '@kbn/logging';
+import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
+import { once } from 'lodash';
+import { stringify } from '../../../../utils/stringify';
+import { ResponseActionsClientError, ResponseActionsConnectorNotConfiguredError } from '../errors';
+
+export interface NormalizedExternalConnectorClientExecuteOptions<
+ TParams extends Record = Record,
+ TSubAction = unknown
+> {
+ params: {
+ subAction: TSubAction;
+ subActionParams: TParams;
+ };
+ spaceId?: string;
+}
+
+/**
+ * Handles setting up the usage of Stack Connectors for Response Actions and normalizes usage of
+ * Connector's Sub-Actions plugin between the `ActionsClient` and the `IUnsecuredActionsClient`
+ * client interfaces. It also provides better typing support.
+ */
+export class NormalizedExternalConnectorClient {
+ protected readonly getConnectorInstance: () => Promise;
+
+ constructor(
+ protected readonly connectorTypeId: string,
+ protected readonly connectorsClient: ActionsClient | IUnsecuredActionsClient,
+ protected readonly log: Logger
+ ) {
+ this.getConnectorInstance = once(async () => {
+ let connectorList: ConnectorWithExtraFindData[] = [];
+
+ try {
+ connectorList = await this.getAll();
+ } catch (err) {
+ throw new ResponseActionsClientError(
+ `Unable to retrieve list of stack connectors in order to find one for [${this.connectorTypeId}]: ${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 === this.connectorTypeId && !isDeprecated && !isMissingSecrets;
+ });
+
+ if (!connector) {
+ this.log.debug(stringify(connectorList));
+ throw new ResponseActionsConnectorNotConfiguredError(this.connectorTypeId);
+ }
+
+ this.log.debug(
+ `Using [${this.connectorTypeId}] stack connector: "${connector.name}" (ID: ${connector.id})`
+ );
+
+ return connector;
+ });
+ }
+
+ private isUnsecuredActionsClient(
+ client: ActionsClient | IUnsecuredActionsClient
+ ): client is IUnsecuredActionsClient {
+ // The methods below only exist in the normal `ActionsClient`
+ return !('create' in client) && !('delete' in client) && !('update' in client);
+ }
+
+ public async execute<
+ TResponse = unknown,
+ TParams extends Record = Record
+ >({
+ spaceId = 'default',
+ params,
+ }: NormalizedExternalConnectorClientExecuteOptions): Promise<
+ ActionTypeExecutorResult
+ > {
+ const { id: connectorId } = await this.getConnectorInstance();
+
+ if (this.isUnsecuredActionsClient(this.connectorsClient)) {
+ return this.connectorsClient.execute({
+ requesterId: 'background_task',
+ id: connectorId,
+ spaceId,
+ params,
+ }) as Promise>;
+ }
+
+ return this.connectorsClient.execute({
+ actionId: connectorId,
+ params,
+ }) as Promise>;
+ }
+
+ protected async getAll(spaceId: string = 'default'): ReturnType {
+ if (this.isUnsecuredActionsClient(this.connectorsClient)) {
+ return this.connectorsClient.getAll(spaceId);
+ }
+
+ return this.connectorsClient.getAll();
+ }
+}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
index 62e13de5002b3..39497e18f18e3 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
import type { DeepPartial } from 'utility-types';
@@ -252,6 +253,20 @@ const createConnectorActionExecuteResponseMock = (
return merge(result, overrides);
};
+const createConnectorActionsClientMock = ({
+ getAllResponse,
+}: {
+ getAllResponse?: ConnectorWithExtraFindData[];
+} = {}): ActionsClientMock => {
+ const client = actionsClientMock.create();
+
+ (client.getAll as jest.Mock).mockImplementation(async () => {
+ return getAllResponse ?? [];
+ });
+
+ return client;
+};
+
export const responseActionsClientMock = Object.freeze({
create: createResponseActionClientMock,
createConstructorOptions: createConstructorOptionsMock,
@@ -268,7 +283,8 @@ export const responseActionsClientMock = Object.freeze({
createIndexedResponse: createEsIndexTransportResponseMock,
// Some common mocks when working with connector actions
- createConnectorActionsClient: actionsClientMock.create,
+ createConnectorActionsClient: createConnectorActionsClientMock,
+ /** Create a mock connector instance */
createConnector: createConnectorMock,
createConnectorActionExecuteResponse: createConnectorActionExecuteResponseMock,
});
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 0fdd9612bdaa2..b2e521060edfc 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
@@ -9,7 +9,7 @@ import type { ResponseActionsClient } from '../lib/types';
import { responseActionsClientMock } from '../mocks';
import { SentinelOneActionsClient } from './sentinel_one_actions_client';
import { getActionDetailsById as _getActionDetailsById } from '../../action_details_by_id';
-import { ResponseActionsClientError, ResponseActionsNotSupportedError } from '../errors';
+import { ResponseActionsNotSupportedError } from '../errors';
import type { ActionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
import type { SentinelOneActionsClientOptionsMock } from './mocks';
import { sentinelOneMock } from './mocks';
@@ -64,50 +64,6 @@ describe('SentinelOneActionsClient class', () => {
}
);
- it('should error if unable to retrieve list of connectors', async () => {
- connectorActionsMock.getAll.mockImplementation(async () => {
- throw new Error('oh oh');
- });
- const responsePromise = s1ActionsClient.isolate(createS1IsolationOptions());
-
- await expect(responsePromise).rejects.toBeInstanceOf(ResponseActionsClientError);
- await expect(responsePromise).rejects.toHaveProperty(
- 'message',
- expect.stringContaining('Unable to retrieve list of stack connectors:')
- );
- await expect(responsePromise).rejects.toHaveProperty('statusCode', 400);
- });
-
- it('should error if retrieving connectors fails', async () => {
- (connectorActionsMock.getAll as jest.Mock).mockImplementation(async () => {
- throw new Error('oh oh');
- });
-
- await expect(s1ActionsClient.isolate(createS1IsolationOptions())).rejects.toMatchObject({
- message: `Unable to retrieve list of stack connectors: oh oh`,
- statusCode: 400,
- });
- });
-
- it.each([
- ['no connector defined', async () => []],
- [
- 'deprecated connector',
- async () => [responseActionsClientMock.createConnector({ isDeprecated: true })],
- ],
- [
- 'missing secrets',
- async () => [responseActionsClientMock.createConnector({ isMissingSecrets: true })],
- ],
- ])('should error if: %s', async (_, getAllImplementation) => {
- (connectorActionsMock.getAll as jest.Mock).mockImplementation(getAllImplementation);
-
- await expect(s1ActionsClient.isolate(createS1IsolationOptions())).rejects.toMatchObject({
- message: `No SentinelOne stack connector found`,
- statusCode: 400,
- });
- });
-
it('should error if multiple agent ids are received', async () => {
const payload = createS1IsolationOptions();
payload.endpoint_ids.push('second-host-id');
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
index b0c6dd6c0211f..ac40e5726997c 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
@@ -5,78 +5,49 @@
* 2.0.
*/
-import type { ActionsClient } from '@kbn/actions-plugin/server';
+import type { ActionsClient, IUnsecuredActionsClient } from '@kbn/actions-plugin/server';
import {
SENTINELONE_CONNECTOR_ID,
SUB_ACTION,
} from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
-import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types';
-import { once } from 'lodash';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type {
- SentinelOneGetAgentsResponse,
SentinelOneGetAgentsParams,
+ SentinelOneGetAgentsResponse,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
+import type { NormalizedExternalConnectorClientExecuteOptions } from '../lib/normalized_external_connector_client';
+import { NormalizedExternalConnectorClient } from '../lib/normalized_external_connector_client';
import type {
CommonResponseActionMethodOptions,
ProcessPendingActionsMethodOptions,
} from '../../..';
import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants';
-import type { SentinelOneConnectorExecuteOptions } from './types';
import { stringify } from '../../../../utils/stringify';
import { ResponseActionsClientError } from '../errors';
import type { ActionDetails, LogsEndpointAction } from '../../../../../../common/endpoint/types';
import type { IsolationRouteRequestBody } from '../../../../../../common/api/endpoint';
import type {
ResponseActionsClientOptions,
- ResponseActionsClientWriteActionRequestToEndpointIndexOptions,
ResponseActionsClientValidateRequestResponse,
+ ResponseActionsClientWriteActionRequestToEndpointIndexOptions,
} from '../lib/base_response_actions_client';
import { ResponseActionsClientImpl } from '../lib/base_response_actions_client';
export type SentinelOneActionsClientOptions = ResponseActionsClientOptions & {
- connectorActions: ActionsClient;
+ connectorActions: ActionsClient | IUnsecuredActionsClient;
};
export class SentinelOneActionsClient extends ResponseActionsClientImpl {
protected readonly agentType: ResponseActionAgentType = 'sentinel_one';
- private readonly connectorActionsClient: ActionsClient;
- private readonly getConnector: () => Promise;
+ private readonly connectorActionsClient: NormalizedExternalConnectorClient;
constructor({ connectorActions, ...options }: SentinelOneActionsClientOptions) {
super(options);
- this.connectorActionsClient = connectorActions;
-
- this.getConnector = once(async () => {
- let connectorList: ConnectorWithExtraFindData[] = [];
-
- try {
- connectorList = await this.connectorActionsClient.getAll();
- } catch (err) {
- throw new ResponseActionsClientError(
- `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 ResponseActionsClientError(
- `No SentinelOne stack connector found`,
- 400,
- connectorList
- );
- }
-
- this.log.debug(`Using SentinelOne stack connector: ${connector.name} (${connector.id})`);
-
- return connector;
- });
+ this.connectorActionsClient = new NormalizedExternalConnectorClient(
+ SENTINELONE_CONNECTOR_ID,
+ connectorActions,
+ this.log
+ );
}
protected async writeActionRequestToEndpointIndex(
@@ -101,9 +72,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
actionType: SUB_ACTION,
actionParams: object
): Promise> {
- const { id: connectorId } = await this.getConnector();
const executeOptions: Parameters[0] = {
- actionId: connectorId,
params: {
subAction: actionType,
subActionParams: actionParams,
@@ -133,10 +102,12 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
return actionSendResponse;
}
+ /** Gets agent details directly from SentinelOne */
private async getAgentDetails(id: string): Promise {
- const { id: connectorId } = await this.getConnector();
- const executeOptions: SentinelOneConnectorExecuteOptions = {
- actionId: connectorId,
+ const executeOptions: NormalizedExternalConnectorClientExecuteOptions<
+ SentinelOneGetAgentsParams,
+ SUB_ACTION
+ > = {
params: {
subAction: SUB_ACTION.GET_AGENTS,
subActionParams: {
@@ -145,23 +116,15 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
},
};
- let s1ApiResponse: SentinelOneGetAgentsResponse | undefined;
-
- try {
- const response = (await this.connectorActionsClient.execute(
+ const s1ApiResponse: SentinelOneGetAgentsResponse | undefined = (
+ (await this.connectorActionsClient.execute(
executeOptions
- )) as ActionTypeExecutorResult;
+ )) as ActionTypeExecutorResult
+ ).data;
- this.log.debug(`Response for SentinelOne agent id [${id}] returned:\n${stringify(response)}`);
-
- s1ApiResponse = response.data;
- } catch (err) {
- throw new ResponseActionsClientError(
- `Error while attempting to retrieve SentinelOne host with agent id [${id}]`,
- 500,
- err
- );
- }
+ this.log.debug(
+ `Response for SentinelOne agent id [${id}] returned:\n${stringify(s1ApiResponse)}`
+ );
if (!s1ApiResponse || !s1ApiResponse.data[0]) {
throw new ResponseActionsClientError(`SentinelOne agent id [${id}] not found`, 404);
@@ -309,5 +272,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
// if (abortSignal.aborted) {
// return;
// }
+ // Dev test entry below
+ // await this.getAgentDetails('123').catch(() => {});
}
}
diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts
index ec0e3cb16160c..4fd430038bc4e 100644
--- a/x-pack/plugins/security_solution/server/plugin.ts
+++ b/x-pack/plugins/security_solution/server/plugin.ts
@@ -645,6 +645,7 @@ export class Plugin implements ISecuritySolutionPlugin {
esClient: core.elasticsearch.client.asInternalUser,
productFeaturesService,
savedObjectsClient,
+ connectorActions: plugins.actions,
});
if (plugins.taskManager) {
diff --git a/x-pack/plugins/security_solution/server/plugin_contract.ts b/x-pack/plugins/security_solution/server/plugin_contract.ts
index 1a0dd6bccc15c..e441b0d3f5e03 100644
--- a/x-pack/plugins/security_solution/server/plugin_contract.ts
+++ b/x-pack/plugins/security_solution/server/plugin_contract.ts
@@ -42,6 +42,7 @@ import type { SharePluginStart } from '@kbn/share-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server';
import type { ElasticAssistantPluginStart } from '@kbn/elastic-assistant-plugin/server';
+import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
import type { ProductFeaturesService } from './lib/product_features_service/product_features_service';
import type { ExperimentalFeatures } from '../common';
@@ -84,6 +85,7 @@ export interface SecuritySolutionPluginStartDependencies {
taskManager?: TaskManagerPluginStart;
telemetry?: TelemetryPluginStart;
share: SharePluginStart;
+ actions: ActionsPluginStartContract;
}
export interface SecuritySolutionPluginSetup {
diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/agent_type_support.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/agent_type_support.ts
index 2be0752f62475..87167a8926030 100644
--- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/agent_type_support.ts
+++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_response_actions/agent_type_support.ts
@@ -25,7 +25,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(400, {
statusCode: 400,
error: 'Bad Request',
- message: 'No SentinelOne stack connector found',
+ message: 'No stack connector instance configured for [.sentinelone]',
});
});
});
From 61b80ca4e14d0713f22c574d7bc8c0894260cf45 Mon Sep 17 00:00:00 2001
From: Tre
Date: Tue, 2 Apr 2024 13:34:32 +0100
Subject: [PATCH 18/63] [Serverless] validate response (#179794)
## Summary
Assertions were failing on
[serverless](https://buildkite.com/elastic/appex-qa-serverless-kibana-ftr-tests/builds/1339).
So use helper methods to print out more info
in case of failure.
---
.../common/management/data_views/serverless.ts | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/x-pack/test_serverless/functional/test_suites/common/management/data_views/serverless.ts b/x-pack/test_serverless/functional/test_suites/common/management/data_views/serverless.ts
index bf7b6b4115d6c..83f70a465a49e 100644
--- a/x-pack/test_serverless/functional/test_suites/common/management/data_views/serverless.ts
+++ b/x-pack/test_serverless/functional/test_suites/common/management/data_views/serverless.ts
@@ -5,7 +5,6 @@
* 2.0.
*/
-import expect from 'expect';
import { DATA_VIEW_PATH } from '@kbn/data-views-plugin/server';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { INITIAL_REST_VERSION } from '@kbn/data-views-plugin/server/constants';
@@ -19,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const testSubjects = getService('testSubjects');
+ const svlCommonApi = getService('svlCommonApi');
describe('Serverless tests', function () {
describe('disables scripted fields', function () {
@@ -27,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
before(async () => {
await esArchiver.load(archivePath);
- const response = await supertest
+ const { body, status } = await supertest
.post(DATA_VIEW_PATH)
.set('kbn-xsrf', 'some-xsrf-token')
.send({
@@ -37,8 +37,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
override: true,
});
- expect(response.status).toBe(200);
- dataViewId = response.body.data_view.id;
+ svlCommonApi.assertResponseStatusCode(200, status, body);
+ dataViewId = body.data_view.id;
});
after(async () => {
@@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'test/api_integration/fixtures/es_archiver/index_patterns/basic_index'
);
- const response = await supertest
+ const { body, status } = await supertest
.post(DATA_VIEW_PATH)
.set('kbn-xsrf', 'some-xsrf-token')
.send({
@@ -76,7 +76,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
override: true,
})
.set(ELASTIC_HTTP_VERSION_HEADER, INITIAL_REST_VERSION);
- dataViewId = response.body.data_view.id;
+ svlCommonApi.assertResponseStatusCode(200, status, body);
+ dataViewId = body.data_view.id;
});
after(async () => {
From 883beee4dffd757fa8f1f7ac541b0457d92ba1ba Mon Sep 17 00:00:00 2001
From: Michael Olorunnisola
Date: Tue, 2 Apr 2024 08:34:51 -0400
Subject: [PATCH 19/63] [Security Solution] Integrate Vanilla Unified Data
Table in Timeline (#176064)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Fixes below issues
- https://github.com/elastic/security-team/issues/8726
- https://github.com/elastic/security-team/issues/7234
## Details
### Objective
The objective of this PR is to implement basic unified data table
replacing the current timeline table. It is intended to behave exactly
like discover table. Below are the functionalities of the timeline table
that are out of scope of this PR and will be included in the follow up
PR.
|Before|After|
|---|---|
| | |
This feature can be enabled with below feature flag:
```yaml
xpack.securitySolution.enableExperimental:
- unifiedComponentsInTimelineEnabled
```
### Out of scope functionalities
- Row Renderers - https://github.com/elastic/security-team/issues/8728
- Notes / Pinned Events / Analyzer / Session View Link -
https://github.com/elastic/security-team/issues/8727
- Drag / Drop from table --> data providers.
- Telemetry
### Desk Testing Guide
Below are some areas which have changes and would warrant some desk
testing.
One pre-requisite is to enable feature flag
`unifiedComponentsInTimelineEnabled` which will replace traditional
timeline table with the new unified timeline table.
1. **Pagination**
- If total number of events are > sample size ( default 500 ), table
footer on last page should display the notification so that user can
`Load More` on-demand.
- Page changes should be instant, because it is just client-side
pagination where we download multiple pages ( 500 events ) at once.
3. **Flyouts**
- Event Detail ( Open / Close ) - should be new Expandable flyout
- Host / User / IP details flyout ( Open / Close)
4. Unified List
- Add column by dragging & by clicking ⨁ control.
- Remove columns by clicking on column header & by clicking on ❌ control
3. Full screen mode
6. Last updated date
7. Columns Order
- Change columns order from table controls
8. Sort Order
- timestamp
- Any number column
- Any string column
9. Column Actions
- Move Right/Left
- Sort Asc/Desc
- Copy Column and Column Name
- Edit Data View Field with custom label
8. Table Control - Display Options
-
- Try different row heights and Sample sizes.
## Current Observations of Unified table vis-a-vis discover UI + Issues
- [ ] Discover has custom UI on toolbar visibility when compared to
vanilla unified data table
- [x] Full screen behaviour of unified data table
- [x] column width calculation is not automatic.
- [x] Unsaved Timeline
- [x] Saved Timeline
- [x] Host/Network Flyout is not opening when timeline is not saved
- [x] Flyout is not closing after it has been opened
- [ ] Row highlighting for building block -> Will be covered with
Actions Column
- [x] Additional Controls
- [x] Row Renderer Selection
- [x] Last updated Date
- [x] Full Screen
- [ ] Table controls tab order
- [ ] Refactor singleton ActiveTimeline class ( Inform @PhilippeOberti )
- [ ] Total Count not visible
### 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: Jatin Kathuria
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
...use_data_grid_column_cell_actions.test.tsx | 64 +-
.../src/components/data_table.scss | 8 +
.../src/components/data_table.test.tsx | 43 +-
.../src/components/data_table.tsx | 17 +-
.../data_table_document_selection.tsx | 5 +-
.../components/data_table_expand_button.tsx | 7 +-
.../src/utils/get_render_cell_value.tsx | 31 +-
.../field_list_sidebar.tsx | 1 +
.../field_list_sidebar_container.tsx | 483 ++++++------
.../application/context/context_app.scss | 2 +-
test/functional/services/data_grid.ts | 4 +-
.../common/experimental_features.ts | 25 +-
.../drag_drop_context_wrapper.test.tsx.snap | 15 +
.../markdown_editor/plugins/insight/index.tsx | 16 +-
.../plugins/insight/use_insight_query.ts | 9 +-
.../use_get_sourcerer_data_view.test.ts | 59 ++
.../sourcerer/use_get_sourcerer_data_view.tsx | 41 +
.../with_data_view/data_view_error.tsx | 22 +
.../components/with_data_view/index.test.tsx | 57 ++
.../components/with_data_view/index.tsx | 45 ++
.../components/with_data_view/translations.ts | 23 +
.../public/common/containers/source/mock.ts | 53 +-
.../common/containers/sourcerer/mocks.ts | 22 +-
.../__mocks__/use_experimental_features.ts | 19 +
.../timeline/use_init_timeline_url_param.ts | 7 +-
...query_timeline_by_id_on_url_change.test.ts | 2 +
.../use_query_timeline_by_id_on_url_change.ts | 16 +-
.../common/lib/kibana/kibana_react.mock.ts | 43 +-
.../public/common/mock/global_state.ts | 5 +-
.../public/common/mock/timeline_results.ts | 3 +
.../utils/timeline/use_timeline_click.tsx | 8 +-
.../alerts_histogram_panel/index.test.tsx | 15 +-
.../components/alerts_table/actions.test.tsx | 2 +
.../components/alerts_table/actions.tsx | 2 +-
.../use_investigate_in_timeline.tsx | 14 +-
.../rules/use_rule_from_timeline.test.ts | 1 +
.../rules/use_rule_from_timeline.tsx | 8 +-
.../components/recent_timelines/index.tsx | 8 +-
.../edit_data_provider/helpers.test.tsx | 1 +
.../actions/new_timeline_button.test.tsx | 2 +
.../components/new_timeline/index.test.tsx | 2 +
.../components/open_timeline/helpers.test.ts | 169 +++++
.../components/open_timeline/helpers.ts | 45 +-
.../components/open_timeline/index.tsx | 18 +-
.../timelines/components/side_panel/index.tsx | 12 +-
.../__snapshots__/index.test.tsx.snap | 15 +
.../body/column_headers/helpers.test.ts | 12 +
.../components/timeline/body/constants.ts | 2 +
.../body/renderers/formatted_field.tsx | 10 +-
.../renderers/formatted_field_udt.test.tsx | 41 +
.../body/renderers/formatted_field_udt.tsx | 50 ++
.../body/unified_timeline_body.test.tsx | 119 +++
.../timeline/body/unified_timeline_body.tsx | 91 +++
.../timeline/eql_tab_content/index.tsx | 10 +-
.../components/timeline/footer/index.tsx | 32 +-
.../timeline/footer/last_updated.test.tsx | 61 ++
.../timeline/footer/last_updated.tsx | 43 ++
.../timelines/components/timeline/index.tsx | 9 +-
.../timeline/pinned_tab_content/index.tsx | 8 +-
.../timeline/query_tab_content/index.test.tsx | 3 +-
.../timeline/query_tab_content/index.tsx | 175 +++--
.../query_tab_unified_components.test.tsx | 701 ++++++++++++++++++
.../data_table/index.test.tsx | 397 ++++++++++
.../unified_components/data_table/index.tsx | 360 +++++++++
.../toolbar_additional_controls.tsx | 99 +++
.../data_table/translations.ts | 66 ++
.../unified_components/default_headers.tsx | 53 ++
.../get_fields_list_creation_options.ts | 59 ++
.../unified_components/index.test.tsx | 688 +++++++++++++++++
.../timeline/unified_components/index.tsx | 411 ++++++++++
.../resizable_layout.test.tsx | 47 ++
.../unified_components/resizable_layout.tsx | 88 +++
.../timeline/unified_components/styles.tsx | 148 ++++
.../timeline/unified_components/utils.test.ts | 60 ++
.../timeline/unified_components/utils.ts | 44 ++
.../containers/active_timeline_context.ts | 39 +
.../timelines/containers/index.test.tsx | 15 +-
.../public/timelines/containers/index.tsx | 35 +-
.../hooks/use_create_timeline.test.tsx | 2 +
.../timelines/hooks/use_create_timeline.tsx | 8 +-
.../public/timelines/store/actions.ts | 16 +
.../public/timelines/store/defaults.ts | 4 +-
.../public/timelines/store/helpers.test.ts | 33 +
.../public/timelines/store/helpers.ts | 27 +
.../store/middlewares/timeline_save.test.ts | 1 +
.../public/timelines/store/model.ts | 4 +
.../public/timelines/store/reducer.ts | 35 +
.../plugins/security_solution/public/types.ts | 3 +
.../plugins/security_solution/tsconfig.json | 9 +-
.../unified_components/query_tab.cy.ts | 74 ++
.../cypress/screens/unified_timeline.ts | 51 ++
.../cypress/tasks/discover.ts | 4 +
.../cypress/tasks/unified_timeline.ts | 41 +
93 files changed, 5239 insertions(+), 518 deletions(-)
create mode 100644 x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.test.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/with_data_view/data_view_error.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/with_data_view/index.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/with_data_view/index.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/with_data_view/translations.ts
create mode 100644 x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/query_tab_unified_components.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/translations.ts
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/get_fields_list_creation_options.ts
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.test.ts
create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.ts
create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unified_components/query_tab.cy.ts
create mode 100644 x-pack/test/security_solution_cypress/cypress/screens/unified_timeline.ts
create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/unified_timeline.ts
diff --git a/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx
index 014cb526c81fe..a2a2a1e15ade5 100644
--- a/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx
+++ b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx
@@ -161,22 +161,24 @@ describe('useDataGridColumnsCellActions', () => {
cellAction1.getByTestId(`dataGridColumnCellAction-${action1.id}`).click();
await waitFor(() => {
- expect(action1.execute).toHaveBeenCalledWith(
- expect.objectContaining({
- data: [
- {
- value: fieldValues[field1.name][1],
- field: {
- name: field1.name,
- type: field1.type,
- aggregatable: true,
- searchable: true,
- },
+ expect(action1.execute).toHaveBeenCalledWith({
+ data: [
+ {
+ value: fieldValues[field1.name][1],
+ field: {
+ name: field1.name,
+ type: field1.type,
+ aggregatable: true,
+ searchable: true,
},
- ],
- trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
- })
- );
+ },
+ ],
+ metadata: {
+ some: 'value',
+ },
+ nodeRef: expect.any(Object),
+ trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
+ });
});
const cellAction2 = renderCellAction(result.current[1][1], { rowIndex: 2 });
@@ -184,22 +186,24 @@ describe('useDataGridColumnsCellActions', () => {
cellAction2.getByTestId(`dataGridColumnCellAction-${action2.id}`).click();
await waitFor(() => {
- expect(action2.execute).toHaveBeenCalledWith(
- expect.objectContaining({
- data: [
- {
- value: fieldValues[field2.name][2],
- field: {
- name: field2.name,
- type: field2.type,
- aggregatable: true,
- searchable: true,
- },
+ expect(action2.execute).toHaveBeenCalledWith({
+ data: [
+ {
+ value: fieldValues[field2.name][2],
+ field: {
+ name: field2.name,
+ type: field2.type,
+ aggregatable: true,
+ searchable: true,
},
- ],
- trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
- })
- );
+ },
+ ],
+ metadata: {
+ some: 'value',
+ },
+ nodeRef: expect.any(Object),
+ trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
+ });
});
});
diff --git a/packages/kbn-unified-data-table/src/components/data_table.scss b/packages/kbn-unified-data-table/src/components/data_table.scss
index 6e710e87e9aab..e9a95d87d8a30 100644
--- a/packages/kbn-unified-data-table/src/components/data_table.scss
+++ b/packages/kbn-unified-data-table/src/components/data_table.scss
@@ -9,6 +9,14 @@
font-family: $euiCodeFontFamily;
}
+.unifiedDataTable__cell--expanded {
+ background-color: $euiColorHighlight;
+}
+
+.unifiedDataTable__cell--selected {
+ background-color: $euiColorHighlight;
+}
+
.unifiedDataTable__cellPopover {
// Fixes https://github.com/elastic/kibana/issues/145216 in Chrome
.lines-content.monaco-editor-background {
diff --git a/packages/kbn-unified-data-table/src/components/data_table.test.tsx b/packages/kbn-unified-data-table/src/components/data_table.test.tsx
index 8a46322cc97d3..f0c3c797c6dd4 100644
--- a/packages/kbn-unified-data-table/src/components/data_table.test.tsx
+++ b/packages/kbn-unified-data-table/src/components/data_table.test.tsx
@@ -74,6 +74,9 @@ function getProps(): UnifiedDataTableProps {
data: services.data,
theme: services.theme,
},
+ cellActionsMetadata: {
+ someKey: 'someValue',
+ },
};
}
@@ -254,13 +257,16 @@ describe('UnifiedDataTable', () => {
columns: ['message'],
onFieldEdited: jest.fn(),
});
- expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
- expect.objectContaining({
- triggerId: undefined,
- getCellValue: expect.any(Function),
- fields: undefined,
- })
- );
+ expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith({
+ triggerId: undefined,
+ getCellValue: expect.any(Function),
+ fields: undefined,
+ dataGridRef: expect.any(Object),
+ metadata: {
+ dataViewId: 'the-data-view-id',
+ someKey: 'someValue',
+ },
+ });
});
it('should call useDataGridColumnsCellActions properly when cellActionsTriggerId defined', async () => {
@@ -270,16 +276,19 @@ describe('UnifiedDataTable', () => {
onFieldEdited: jest.fn(),
cellActionsTriggerId: 'test',
});
- expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
- expect.objectContaining({
- triggerId: 'test',
- getCellValue: expect.any(Function),
- fields: [
- dataViewMock.getFieldByName('@timestamp')?.toSpec(),
- dataViewMock.getFieldByName('message')?.toSpec(),
- ],
- })
- );
+ expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith({
+ triggerId: 'test',
+ getCellValue: expect.any(Function),
+ fields: [
+ dataViewMock.getFieldByName('@timestamp')?.toSpec(),
+ dataViewMock.getFieldByName('message')?.toSpec(),
+ ],
+ dataGridRef: expect.any(Object),
+ metadata: {
+ dataViewId: 'the-data-view-id',
+ someKey: 'someValue',
+ },
+ });
});
});
diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx
index f935f0ff7daac..09a2f2f8e065b 100644
--- a/packages/kbn-unified-data-table/src/components/data_table.tsx
+++ b/packages/kbn-unified-data-table/src/components/data_table.tsx
@@ -367,6 +367,12 @@ export interface UnifiedDataTableProps {
* Optional row line height override. Default is 1.6em.
*/
rowLineHeightOverride?: string;
+ /**
+ * Custom set of properties used by some actions.
+ * An action might require a specific set of metadata properties to render.
+ * This data is sent directly to actions.
+ */
+ cellActionsMetadata?: Record;
}
export const EuiDataGridMemoized = React.memo(EuiDataGrid);
@@ -430,11 +436,13 @@ export const UnifiedDataTable = ({
componentsTourSteps,
gridStyleOverride,
rowLineHeightOverride,
+ cellActionsMetadata,
customGridColumnsConfiguration,
customControlColumnsConfiguration,
}: UnifiedDataTableProps) => {
const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } =
services;
+
const { darkMode } = useObservable(services.theme?.theme$ ?? of(themeDefault), themeDefault);
const dataGridRef = useRef(null);
const [selectedDocs, setSelectedDocs] = useState([]);
@@ -671,14 +679,17 @@ export const UnifiedDataTable = ({
: undefined,
[cellActionsTriggerId, isPlainRecord, visibleColumns, dataView]
);
- const cellActionsMetadata = useMemo(() => ({ dataViewId: dataView.id }), [dataView]);
+ const allCellActionsMetadata = useMemo(
+ () => ({ dataViewId: dataView.id, ...(cellActionsMetadata ?? {}) }),
+ [dataView, cellActionsMetadata]
+ );
const columnsCellActions = useDataGridColumnsCellActions({
fields: cellActionsFields,
getCellValue,
triggerId: cellActionsTriggerId,
dataGridRef,
- metadata: cellActionsMetadata,
+ metadata: allCellActionsMetadata,
});
const {
@@ -730,6 +741,7 @@ export const UnifiedDataTable = ({
customGridColumnsConfiguration,
}),
[
+ showColumnTokens,
columnsMeta,
columnsCellActions,
customGridColumnsConfiguration,
@@ -743,7 +755,6 @@ export const UnifiedDataTable = ({
isSortEnabled,
onFilter,
settings,
- showColumnTokens,
toastNotifications,
uiSettings,
valueToStringConverter,
diff --git a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx
index db6ed7680b0be..771c2e5fdfd5b 100644
--- a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx
+++ b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx
@@ -19,7 +19,6 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
-import { euiDarkVars as themeDark, euiLightVars as themeLight } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { DataTableRecord } from '@kbn/discover-utils/types';
@@ -40,9 +39,7 @@ export const SelectButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
useEffect(() => {
if (expanded && doc && expanded.id === doc.id) {
setCellProps({
- style: {
- backgroundColor: isDarkMode ? themeDark.euiColorHighlight : themeLight.euiColorHighlight,
- },
+ className: 'unifiedDataTable__cell--selected',
});
} else {
setCellProps({ style: undefined });
diff --git a/packages/kbn-unified-data-table/src/components/data_table_expand_button.tsx b/packages/kbn-unified-data-table/src/components/data_table_expand_button.tsx
index 9538df4d3da03..76210b6aeee23 100644
--- a/packages/kbn-unified-data-table/src/components/data_table_expand_button.tsx
+++ b/packages/kbn-unified-data-table/src/components/data_table_expand_button.tsx
@@ -8,7 +8,6 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import { EuiButtonIcon, EuiDataGridCellValueElementProps, EuiToolTip } from '@elastic/eui';
-import { euiLightVars as themeLight, euiDarkVars as themeDark } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { UnifiedDataTableContext } from '../table_context';
import { DataTableRowControl } from './data_table_row_control';
@@ -27,13 +26,11 @@ export const ExpandButton = ({ rowIndex, setCellProps }: EuiDataGridCellValueEle
useEffect(() => {
if (current.isAnchor) {
setCellProps({
- className: 'dscDocsGrid__cell--highlight',
+ className: 'unifiedDataTable__cell--highlight',
});
} else if (expanded && current && expanded.id === current.id) {
setCellProps({
- style: {
- backgroundColor: isDarkMode ? themeDark.euiColorHighlight : themeLight.euiColorHighlight,
- },
+ className: 'unifiedDataTable__cell--expanded',
});
} else {
setCellProps({ style: undefined });
diff --git a/packages/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/packages/kbn-unified-data-table/src/utils/get_render_cell_value.tsx
index d1806b2c27326..8abd06c7c391f 100644
--- a/packages/kbn-unified-data-table/src/utils/get_render_cell_value.tsx
+++ b/packages/kbn-unified-data-table/src/utils/get_render_cell_value.tsx
@@ -8,7 +8,6 @@
import React, { useContext, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
-import { euiLightVars as themeLight, euiDarkVars as themeDark } from '@kbn/ui-theme';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import {
EuiDataGridCellValueElementProps,
@@ -62,22 +61,16 @@ export const getRenderCellValueFn = ({
const ctx = useContext(UnifiedDataTableContext);
useEffect(() => {
- if (!externalCustomRenderers) {
- if (row?.isAnchor) {
- setCellProps({
- className: 'dscDocsGrid__cell--highlight',
- });
- } else if (ctx.expanded && row && ctx.expanded.id === row.id) {
- setCellProps({
- style: {
- backgroundColor: ctx.isDarkMode
- ? themeDark.euiColorHighlight
- : themeLight.euiColorHighlight,
- },
- });
- } else {
- setCellProps({ style: undefined });
- }
+ if (row?.isAnchor) {
+ setCellProps({
+ className: 'unifiedDataTable__cell--highlight',
+ });
+ } else if (ctx.expanded && row && ctx.expanded.id === row.id) {
+ setCellProps({
+ className: 'unifiedDataTable__cell--expanded',
+ });
+ } else {
+ setCellProps({ style: undefined });
}
}, [ctx, row, setCellProps]);
@@ -87,7 +80,7 @@ export const getRenderCellValueFn = ({
if (!!externalCustomRenderers && !!externalCustomRenderers[columnId]) {
return (
- <>
+
{externalCustomRenderers[columnId]({
rowIndex,
columnId,
@@ -101,7 +94,7 @@ export const getRenderCellValueFn = ({
fieldFormats,
closePopover,
})}
- >
+
);
}
diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx
index b39be6f72715e..fe2f0c0345314 100644
--- a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx
+++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx
@@ -354,6 +354,7 @@ export const UnifiedFieldListSidebarComponent: React.FC(function UnifiedFieldListSidebarContainer(props, componentRef) {
- const {
- getCreationOptions,
- services,
- dataView,
- workspaceSelectedFieldNames,
- prependInFlyout,
- variant = 'responsive',
- onFieldEdited,
- } = props;
- const [stateService] = useState(
- createStateService({ options: getCreationOptions() })
- );
- const { data, dataViewFieldEditor } = services;
- const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState(false);
- const [sidebarVisibility] = useState(() =>
- getSidebarVisibility({
- localStorageKey: stateService.creationOptions.localStorageKeyPrefix
- ? `${stateService.creationOptions.localStorageKeyPrefix}:sidebarClosed`
- : undefined,
- })
- );
- const isSidebarCollapsed = useObservable(sidebarVisibility.isCollapsed$, false);
+const UnifiedFieldListSidebarContainer = memo(
+ forwardRef(
+ function UnifiedFieldListSidebarContainer(props, componentRef) {
+ const {
+ getCreationOptions,
+ services,
+ dataView,
+ workspaceSelectedFieldNames,
+ prependInFlyout,
+ variant = 'responsive',
+ onFieldEdited,
+ } = props;
+ const [stateService] = useState(
+ createStateService({ options: getCreationOptions() })
+ );
+ const { data, dataViewFieldEditor } = services;
+ const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState(false);
+ const [sidebarVisibility] = useState(() =>
+ getSidebarVisibility({
+ localStorageKey: stateService.creationOptions.localStorageKeyPrefix
+ ? `${stateService.creationOptions.localStorageKeyPrefix}:sidebarClosed`
+ : undefined,
+ })
+ );
+ const isSidebarCollapsed = useObservable(sidebarVisibility.isCollapsed$, false);
- const canEditDataView =
- Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
- Boolean(dataView && !dataView.isPersisted());
- const closeFieldEditor = useRef<() => void | undefined>();
- const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
- closeFieldEditor.current = ref;
- }, []);
+ const canEditDataView =
+ Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
+ Boolean(dataView && !dataView.isPersisted());
+ const closeFieldEditor = useRef<() => void | undefined>();
+ const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
+ closeFieldEditor.current = ref;
+ }, []);
- const closeFieldListFlyout = useCallback(() => {
- setIsFieldListFlyoutVisible(false);
- }, []);
+ const closeFieldListFlyout = useCallback(() => {
+ setIsFieldListFlyoutVisible(false);
+ }, []);
- const querySubscriberResult = useQuerySubscriber({
- data,
- timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
- });
- const searchMode: SearchMode | undefined = querySubscriberResult.searchMode;
- const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
+ const querySubscriberResult = useQuerySubscriber({
+ data,
+ timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
+ });
+ const searchMode: SearchMode | undefined = querySubscriberResult.searchMode;
+ const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
- const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
- disableAutoFetching: stateService.creationOptions.disableFieldsExistenceAutoFetching,
- dataViews: searchMode === 'documents' && dataView ? [dataView] : [],
- query: querySubscriberResult.query,
- filters: querySubscriberResult.filters,
- fromDate: querySubscriberResult.fromDate,
- toDate: querySubscriberResult.toDate,
- services,
- });
+ const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
+ disableAutoFetching: stateService.creationOptions.disableFieldsExistenceAutoFetching,
+ dataViews: searchMode === 'documents' && dataView ? [dataView] : [],
+ query: querySubscriberResult.query,
+ filters: querySubscriberResult.filters,
+ fromDate: querySubscriberResult.fromDate,
+ toDate: querySubscriberResult.toDate,
+ services,
+ });
- const editField = useMemo(
- () =>
- dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
- ? (fieldName?: string) => {
- const ref = dataViewFieldEditor.openEditor({
- ctx: {
- dataView,
- },
- fieldName,
- onSave: async () => {
- if (onFieldEdited) {
- await onFieldEdited({ editedFieldName: fieldName });
- }
- },
- });
- setFieldEditorRef(ref);
- closeFieldListFlyout();
- }
- : undefined,
- [
- searchMode,
- canEditDataView,
- dataViewFieldEditor,
- dataView,
- setFieldEditorRef,
- closeFieldListFlyout,
- onFieldEdited,
- ]
- );
+ const editField = useMemo(
+ () =>
+ dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
+ ? (fieldName?: string) => {
+ const ref = dataViewFieldEditor.openEditor({
+ ctx: {
+ dataView,
+ },
+ fieldName,
+ onSave: async () => {
+ if (onFieldEdited) {
+ await onFieldEdited({ editedFieldName: fieldName });
+ }
+ },
+ });
+ setFieldEditorRef(ref);
+ closeFieldListFlyout();
+ }
+ : undefined,
+ [
+ searchMode,
+ canEditDataView,
+ dataViewFieldEditor,
+ dataView,
+ setFieldEditorRef,
+ closeFieldListFlyout,
+ onFieldEdited,
+ ]
+ );
- const deleteField = useMemo(
- () =>
- dataView && dataViewFieldEditor && editField
- ? (fieldName: string) => {
- const ref = dataViewFieldEditor.openDeleteModal({
- ctx: {
- dataView,
- },
- fieldName,
- onDelete: async () => {
- if (onFieldEdited) {
- await onFieldEdited({ removedFieldName: fieldName });
- }
- },
- });
- setFieldEditorRef(ref);
- closeFieldListFlyout();
- }
- : undefined,
- [
- dataView,
- setFieldEditorRef,
- editField,
- closeFieldListFlyout,
- dataViewFieldEditor,
- onFieldEdited,
- ]
- );
+ const deleteField = useMemo(
+ () =>
+ dataView && dataViewFieldEditor && editField
+ ? (fieldName: string) => {
+ const ref = dataViewFieldEditor.openDeleteModal({
+ ctx: {
+ dataView,
+ },
+ fieldName,
+ onDelete: async () => {
+ if (onFieldEdited) {
+ await onFieldEdited({ removedFieldName: fieldName });
+ }
+ },
+ });
+ setFieldEditorRef(ref);
+ closeFieldListFlyout();
+ }
+ : undefined,
+ [
+ dataView,
+ setFieldEditorRef,
+ editField,
+ closeFieldListFlyout,
+ dataViewFieldEditor,
+ onFieldEdited,
+ ]
+ );
- useEffect(() => {
- const cleanup = () => {
- if (closeFieldEditor?.current) {
- closeFieldEditor?.current();
- }
- };
- return () => {
- // Make sure to close the editor when unmounting
- cleanup();
- };
- }, []);
+ useEffect(() => {
+ const cleanup = () => {
+ if (closeFieldEditor?.current) {
+ closeFieldEditor?.current();
+ }
+ };
+ return () => {
+ // Make sure to close the editor when unmounting
+ cleanup();
+ };
+ }, []);
- useImperativeHandle(
- componentRef,
- () => ({
- sidebarVisibility,
- refetchFieldsExistenceInfo,
- closeFieldListFlyout,
- createField: editField,
- editField,
- deleteField,
- }),
- [sidebarVisibility, refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField]
- );
+ useImperativeHandle(
+ componentRef,
+ () => ({
+ sidebarVisibility,
+ refetchFieldsExistenceInfo,
+ closeFieldListFlyout,
+ createField: editField,
+ editField,
+ deleteField,
+ }),
+ [
+ sidebarVisibility,
+ refetchFieldsExistenceInfo,
+ closeFieldListFlyout,
+ editField,
+ deleteField,
+ ]
+ );
- if (!dataView) {
- return null;
- }
+ if (!dataView) {
+ return null;
+ }
- const commonSidebarProps: UnifiedFieldListSidebarProps = {
- ...props,
- searchMode,
- stateService,
- isProcessing,
- isAffectedByGlobalFilter,
- onEditField: editField,
- onDeleteField: deleteField,
- compressed: stateService.creationOptions.compressed ?? false,
- buttonAddFieldVariant: stateService.creationOptions.buttonAddFieldVariant ?? 'primary',
- };
+ const commonSidebarProps: UnifiedFieldListSidebarProps = {
+ ...props,
+ searchMode,
+ stateService,
+ isProcessing,
+ isAffectedByGlobalFilter,
+ onEditField: editField,
+ onDeleteField: deleteField,
+ compressed: stateService.creationOptions.compressed ?? false,
+ buttonAddFieldVariant: stateService.creationOptions.buttonAddFieldVariant ?? 'primary',
+ };
- if (stateService.creationOptions.showSidebarToggleButton) {
- commonSidebarProps.isSidebarCollapsed = isSidebarCollapsed;
- commonSidebarProps.onToggleSidebar = sidebarVisibility.toggle;
- }
+ if (stateService.creationOptions.showSidebarToggleButton) {
+ commonSidebarProps.isSidebarCollapsed = isSidebarCollapsed;
+ commonSidebarProps.onToggleSidebar = sidebarVisibility.toggle;
+ }
- const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;
+ const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;
- const renderListVariant = () => {
- return ;
- };
+ const renderListVariant = () => {
+ return ;
+ };
- const renderButtonVariant = () => {
- return (
- <>
-
- setIsFieldListFlyoutVisible(true)}
- >
-
-
- {!workspaceSelectedFieldNames?.length || workspaceSelectedFieldNames[0] === '_source'
- ? 0
- : workspaceSelectedFieldNames.length}
-
-
-
- {isFieldListFlyoutVisible && (
-
- setIsFieldListFlyoutVisible(false)}
- aria-labelledby="flyoutTitle"
- ownFocus
- >
-
-
-
- setIsFieldListFlyoutVisible(false)}>
- {' '}
-
- {i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
- defaultMessage: 'Field list',
- })}
-
-
-
-
-
-
-
-
- )}
- >
- );
- };
+ const renderButtonVariant = () => {
+ return (
+ <>
+
+ setIsFieldListFlyoutVisible(true)}
+ >
+
+
+ {!workspaceSelectedFieldNames?.length ||
+ workspaceSelectedFieldNames[0] === '_source'
+ ? 0
+ : workspaceSelectedFieldNames.length}
+
+
+
+ {isFieldListFlyoutVisible && (
+
+ setIsFieldListFlyoutVisible(false)}
+ aria-labelledby="flyoutTitle"
+ ownFocus
+ >
+
+
+
+ setIsFieldListFlyoutVisible(false)}>
+ {' '}
+
+ {i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
+ defaultMessage: 'Field list',
+ })}
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+ };
- if (variant === 'button-and-flyout-always') {
- return renderButtonVariant();
- }
+ if (variant === 'button-and-flyout-always') {
+ return renderButtonVariant();
+ }
- if (variant === 'list-always') {
- return renderListVariant();
- }
+ if (variant === 'list-always') {
+ return renderListVariant();
+ }
- return (
- <>
- {renderListVariant()}
- {renderButtonVariant()}
- >
- );
-});
+ return (
+ <>
+ {renderListVariant()}
+ {renderButtonVariant()}
+ >
+ );
+ }
+ )
+);
// Necessary for React.lazy
// eslint-disable-next-line import/no-default-export
diff --git a/src/plugins/discover/public/application/context/context_app.scss b/src/plugins/discover/public/application/context/context_app.scss
index 19ae9a7471302..150fc7e70e52e 100644
--- a/src/plugins/discover/public/application/context/context_app.scss
+++ b/src/plugins/discover/public/application/context/context_app.scss
@@ -14,7 +14,7 @@
flex: 1 1 100%;
overflow: auto;
- &__cell--highlight {
+ .unifiedDataTable__cell--highlight {
background-color: tintOrShade($euiColorPrimary, 90%, 70%);
}
}
diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts
index ee735f6ed17d6..348864a716e7e 100644
--- a/test/functional/services/data_grid.ts
+++ b/test/functional/services/data_grid.ts
@@ -151,7 +151,7 @@ export class DataGridService extends FtrService {
public async getFields(options?: SelectOptions) {
const selector = options?.isAnchorRow
- ? '.euiDataGridRowCell.dscDocsGrid__cell--highlight'
+ ? '.euiDataGridRowCell.unifiedDataTable__cell--highlight'
: '.euiDataGridRowCell';
const cells = await this.find.allByCssSelector(selector);
@@ -215,7 +215,7 @@ export class DataGridService extends FtrService {
}
const selector = options?.isAnchorRow
- ? '.euiDataGridRowCell.dscDocsGrid__cell--highlight'
+ ? '.euiDataGridRowCell.unifiedDataTable__cell--highlight'
: '.euiDataGridRowCell';
const cells = await table.findAllByCssSelector(selector);
diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts
index a09d49723d75a..a6eba0197b490 100644
--- a/x-pack/plugins/security_solution/common/experimental_features.ts
+++ b/x-pack/plugins/security_solution/common/experimental_features.ts
@@ -202,6 +202,21 @@ export const allowedExperimentalValues = Object.freeze({
* Expires: on Feb 20, 2024
*/
jsonPrebuiltRulesDiffingEnabled: true,
+ /*
+ * Disables discover esql tab within timeline
+ *
+ */
+ timelineEsqlTabDisabled: false,
+ /*
+ * Enables Discover components, UnifiedFieldList and UnifiedDataTable in Timeline.
+ */
+ unifiedComponentsInTimelineEnabled: false,
+
+ /*
+ * Disables date pickers and sourcerer in analyzer if needed.
+ *
+ */
+ analyzerDatePickersAndSourcererDisabled: false,
/**
* Enables per-field rule diffs tab in the prebuilt rule upgrade flyout
@@ -213,16 +228,6 @@ export const allowedExperimentalValues = Object.freeze({
* Expires: on Apr 23, 2024
*/
perFieldPrebuiltRulesDiffingEnabled: true,
-
- /**
- * Disables discover esql tab within timeline
- */
- timelineEsqlTabDisabled: false,
-
- /**
- * Disables date pickers and sourcerer in analyzer if needed.
- */
- analyzerDatePickersAndSourcererDisabled: false,
});
type ExperimentalConfigKeys = Array;
diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap
index 5ec3b5e158138..c4941d1ff08b9 100644
--- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/__snapshots__/drag_drop_context_wrapper.test.tsx.snap
@@ -647,6 +647,21 @@ exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] =
},
},
},
+ "process": Object {
+ "fields": Object {
+ "process.args": Object {
+ "aggregatable": true,
+ "esTypes": Array [
+ "keyword",
+ ],
+ "format": "",
+ "name": "process.args",
+ "readFromDocValues": true,
+ "searchable": true,
+ "type": "string",
+ },
+ },
+ },
"source": Object {
"fields": Object {
"source.ip": Object {
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx
index 7efbebb776cd7..2faca21677863 100644
--- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx
@@ -32,7 +32,6 @@ import {
import numeral from '@elastic/numeral';
import { css } from '@emotion/react';
import type { EuiMarkdownEditorUiPluginEditorProps } from '@elastic/eui/src/components/markdown_editor/markdown_types';
-import { DataView } from '@kbn/data-views-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
@@ -57,6 +56,7 @@ import { filtersToInsightProviders } from './provider';
import { useLicense } from '../../../../hooks/use_license';
import { isProviderValid } from './helpers';
import * as i18n from './translations';
+import { useGetScopedSourcererDataView } from '../../../sourcerer/use_get_sourcerer_data_view';
interface InsightComponentProps {
label?: string;
@@ -277,21 +277,17 @@ const InsightEditorComponent = ({
onCancel,
}: EuiMarkdownEditorUiPluginEditorProps) => {
const isEditMode = node != null;
- const { sourcererDataView, indexPattern } = useSourcererDataView(SourcererScopeName.default);
+ const { indexPattern } = useSourcererDataView(SourcererScopeName.default);
const {
unifiedSearch: {
ui: { FiltersBuilderLazy },
},
uiSettings,
- fieldFormats,
} = useKibana().services;
- const dataView = useMemo(() => {
- if (sourcererDataView != null) {
- return new DataView({ spec: sourcererDataView, fieldFormats });
- } else {
- return null;
- }
- }, [sourcererDataView, fieldFormats]);
+
+ const dataView = useGetScopedSourcererDataView({
+ sourcererScope: SourcererScopeName.default,
+ });
const [providers, setProviders] = useState([[]]);
const dateRangeChoices = useMemo(() => {
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.ts
index ee859c70289b3..ffb5abd39d4a5 100644
--- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.ts
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/use_insight_query.ts
@@ -9,6 +9,7 @@ import { useMemo, useState } from 'react';
import type { Filter } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DataProvider } from '@kbn/timelines-plugin/common';
+import { DataLoadingState } from '@kbn/unified-data-table';
import { TimelineId } from '../../../../../../common/types/timeline';
import { useKibana } from '../../../../lib/kibana';
import { combineQueries } from '../../../../lib/kuery';
@@ -64,7 +65,7 @@ export const useInsightQuery = ({
}
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern, filters]);
- const [isQueryLoading, { events, totalCount }] = useTimelineEvents({
+ const [dataLoadingState, { events, totalCount }] = useTimelineEvents({
dataViewId,
fields: ['*'],
filterQuery: combinedQueries?.filterQuery,
@@ -77,6 +78,12 @@ export const useInsightQuery = ({
? { startDate: relativeTimerange?.from, endDate: relativeTimerange?.to }
: {}),
});
+
+ const isQueryLoading = useMemo(
+ () => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(dataLoadingState),
+ [dataLoadingState]
+ );
+
const [oldestEvent] = events;
const timestamp =
oldestEvent && oldestEvent.data && oldestEvent.data.find((d) => d.field === '@timestamp');
diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.test.ts b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.test.ts
new file mode 100644
index 0000000000000..eb7a6c34ce80d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.test.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { DataView } from '@kbn/data-views-plugin/common';
+import { renderHook } from '@testing-library/react-hooks';
+import { useSourcererDataView } from '../../containers/sourcerer';
+import { mockSourcererScope } from '../../containers/sourcerer/mocks';
+import { SourcererScopeName } from '../../store/sourcerer/model';
+import type { UseGetScopedSourcererDataViewArgs } from './use_get_sourcerer_data_view';
+import { useGetScopedSourcererDataView } from './use_get_sourcerer_data_view';
+
+const renderHookCustom = (args: UseGetScopedSourcererDataViewArgs) => {
+ return renderHook(
+ ({ sourcererScope }) => useGetScopedSourcererDataView({ sourcererScope }),
+ {
+ initialProps: {
+ ...args,
+ },
+ }
+ );
+};
+
+jest.mock('../../containers/sourcerer');
+
+const mockGetSourcererDataView = jest.fn(() => mockSourcererScope);
+
+describe('useGetScopedSourcererDataView', () => {
+ beforeEach(() => {
+ (useSourcererDataView as jest.Mock).mockImplementation(mockGetSourcererDataView);
+ });
+ it('should return DataView when correct spec is provided', () => {
+ const { result } = renderHookCustom({ sourcererScope: SourcererScopeName.timeline });
+
+ expect(result.current).toBeInstanceOf(DataView);
+ });
+ it('should return undefined when no spec is provided', () => {
+ mockGetSourcererDataView.mockReturnValueOnce({
+ ...mockSourcererScope,
+ sourcererDataView: undefined,
+ });
+ const { result } = renderHookCustom({ sourcererScope: SourcererScopeName.timeline });
+ expect(result.current).toBeUndefined();
+ });
+ it('should return undefined when no spec is provided and should update the return when spec is updated to correct value', () => {
+ mockGetSourcererDataView.mockReturnValueOnce({
+ ...mockSourcererScope,
+ sourcererDataView: undefined,
+ });
+ const { rerender, result } = renderHookCustom({ sourcererScope: SourcererScopeName.timeline });
+ expect(result.current).toBeUndefined();
+
+ rerender({ sourcererScope: SourcererScopeName.timeline });
+ expect(result.current).toBeInstanceOf(DataView);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.tsx
new file mode 100644
index 0000000000000..af49a784164cf
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_get_sourcerer_data_view.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { DataView } from '@kbn/data-views-plugin/public';
+import { useSourcererDataView } from '../../containers/sourcerer';
+import { useKibana } from '../../lib/kibana';
+import type { SourcererScopeName } from '../../store/sourcerer/model';
+
+export interface UseGetScopedSourcererDataViewArgs {
+ sourcererScope: SourcererScopeName;
+}
+
+/*
+ *
+ * returns the created dataView based on sourcererDataView spec
+ * returned from useSourcererDataView
+ *
+ * */
+export const useGetScopedSourcererDataView = ({
+ sourcererScope,
+}: UseGetScopedSourcererDataViewArgs): DataView | undefined => {
+ const {
+ services: { fieldFormats },
+ } = useKibana();
+ const { sourcererDataView } = useSourcererDataView(sourcererScope);
+
+ const dataView = useMemo(() => {
+ if (sourcererDataView) {
+ return new DataView({ spec: sourcererDataView, fieldFormats });
+ } else {
+ return undefined;
+ }
+ }, [sourcererDataView, fieldFormats]);
+
+ return dataView;
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/with_data_view/data_view_error.tsx b/x-pack/plugins/security_solution/public/common/components/with_data_view/data_view_error.tsx
new file mode 100644
index 0000000000000..b2d8af69d9ba7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/with_data_view/data_view_error.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { EuiEmptyPrompt } from '@elastic/eui';
+import * as i18n from './translations';
+
+export const DataViewErrorComponent = () => {
+ return (
+ {i18n.DATA_VIEW_MANDATORY}}
+ body={{i18n.DATA_VIEW_MANDATORY_MSG}
}
+ />
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/with_data_view/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/with_data_view/index.test.tsx
new file mode 100644
index 0000000000000..9d7c88041abe7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/with_data_view/index.test.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React, { useEffect } from 'react';
+import type { DataView } from '@kbn/data-views-plugin/common';
+import { render, screen } from '@testing-library/react';
+import { withDataView } from '.';
+import { useGetScopedSourcererDataView } from '../sourcerer/use_get_sourcerer_data_view';
+
+interface TestComponentProps {
+ dataView: DataView;
+}
+
+jest.mock('../sourcerer/use_get_sourcerer_data_view');
+
+const TEST_ID = {
+ DATA_VIEW_ERROR_COMPONENT: 'dataViewErrorComponent',
+ TEST_COMPONENT: 'test_component',
+ FALLBACK_COMPONENT: 'fallback_component',
+};
+
+const FallbackComponent: React.FC = () =>
;
+
+const dataViewMockFn = jest.fn();
+
+const TestComponent = (props: TestComponentProps) => {
+ useEffect(() => {
+ dataViewMockFn(props.dataView);
+ }, [props.dataView]);
+ return
;
+};
+
+describe('withDataViewId', () => {
+ beforeEach(() => {
+ (useGetScopedSourcererDataView as jest.Mock).mockReturnValue(undefined);
+ });
+ it('should render default error components when there is not fallback provided and dataViewId is null', async () => {
+ const RenderedComponent = withDataView(TestComponent);
+ render( );
+ expect(screen.getByTestId(TEST_ID.DATA_VIEW_ERROR_COMPONENT)).toBeVisible();
+ });
+ it('should render provided fallback and dataViewId is null', async () => {
+ const RenderedComponent = withDataView(TestComponent, );
+ render( );
+ expect(screen.getByTestId(TEST_ID.FALLBACK_COMPONENT)).toBeVisible();
+ });
+ it('should render provided component when dataViewId is not null', async () => {
+ (useGetScopedSourcererDataView as jest.Mock).mockReturnValue({ id: 'test' });
+ const RenderedComponent = withDataView(TestComponent);
+ render( );
+ expect(screen.getByTestId(TEST_ID.TEST_COMPONENT)).toBeVisible();
+ expect(dataViewMockFn).toHaveBeenCalledWith({ id: 'test' });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/with_data_view/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_data_view/index.tsx
new file mode 100644
index 0000000000000..3f157332482fc
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/with_data_view/index.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import type { ComponentType } from 'react';
+import type { ReactElement } from 'react-markdown';
+import type { DataView } from '@kbn/data-views-plugin/common';
+import { DataViewErrorComponent } from './data_view_error';
+import { useGetScopedSourcererDataView } from '../sourcerer/use_get_sourcerer_data_view';
+import { SourcererScopeName } from '../../store/sourcerer/model';
+
+type OmitDataView = T extends { dataView: DataView } ? Omit : T;
+
+interface WithDataViewArg {
+ dataView: DataView;
+}
+
+/**
+ *
+ * This HOC makes sure the dataView is populated
+ * otherwise it will render the provided/default Error component
+ *
+ * */
+export const withDataView = (
+ Component: ComponentType
,
+ fallback?: ReactElement
+) => {
+ const ComponentWithDataView = (props: OmitDataView
) => {
+ const dataView = useGetScopedSourcererDataView({
+ sourcererScope: SourcererScopeName.timeline,
+ });
+
+ if (!dataView) {
+ return fallback ?? ;
+ }
+
+ return ;
+ };
+
+ return ComponentWithDataView;
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/with_data_view/translations.ts b/x-pack/plugins/security_solution/public/common/components/with_data_view/translations.ts
new file mode 100644
index 0000000000000..a0104b594a9e3
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/with_data_view/translations.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const DATA_VIEW_MANDATORY = i18n.translate(
+ 'xpack.securitySolution.components.dataViewMandatory',
+ {
+ defaultMessage: 'The DataView is required, but was not provided',
+ }
+);
+
+export const DATA_VIEW_MANDATORY_MSG = i18n.translate(
+ 'xpack.securitySolution.components.dataViewMandatoryDetailMessage',
+ {
+ defaultMessage:
+ 'The DataView is not selected properly. Please select the appropriate DataView and index patterns',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts
index f253e694aa81c..74c494b239318 100644
--- a/x-pack/plugins/security_solution/public/common/containers/source/mock.ts
+++ b/x-pack/plugins/security_solution/public/common/containers/source/mock.ts
@@ -6,6 +6,7 @@
*/
import type { MappingRuntimeFieldType } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+import { flatten } from 'lodash';
import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
@@ -47,40 +48,6 @@ export const mocksSource = {
],
};
-export const mockIndexFields = [
- {
- aggregatable: true,
- name: '@timestamp',
- searchable: true,
- type: 'date',
- readFromDocValues: true,
- },
- { aggregatable: true, name: 'agent.ephemeral_id', searchable: true, type: 'string' },
- { aggregatable: true, name: 'agent.hostname', searchable: true, type: 'string' },
- { aggregatable: true, name: 'agent.id', searchable: true, type: 'string' },
- { aggregatable: true, name: 'agent.name', searchable: true, type: 'string' },
- { aggregatable: true, name: 'auditd.data.a0', searchable: true, type: 'string' },
- { aggregatable: true, name: 'auditd.data.a1', searchable: true, type: 'string' },
- { aggregatable: true, name: 'auditd.data.a2', searchable: true, type: 'string' },
- { aggregatable: true, name: 'client.address', searchable: true, type: 'string' },
- { aggregatable: true, name: 'client.bytes', searchable: true, type: 'number' },
- { aggregatable: true, name: 'client.domain', searchable: true, type: 'string' },
- { aggregatable: true, name: 'client.geo.country_iso_code', searchable: true, type: 'string' },
- { aggregatable: true, name: 'cloud.account.id', searchable: true, type: 'string' },
- { aggregatable: true, name: 'cloud.availability_zone', searchable: true, type: 'string' },
- { aggregatable: true, name: 'container.id', searchable: true, type: 'string' },
- { aggregatable: true, name: 'container.image.name', searchable: true, type: 'string' },
- { aggregatable: true, name: 'container.image.tag', searchable: true, type: 'string' },
- { aggregatable: true, name: 'destination.address', searchable: true, type: 'string' },
- { aggregatable: true, name: 'destination.bytes', searchable: true, type: 'number' },
- { aggregatable: true, name: 'destination.domain', searchable: true, type: 'string' },
- { aggregatable: true, name: 'destination.ip', searchable: true, type: 'ip' },
- { aggregatable: true, name: 'destination.port', searchable: true, type: 'long' },
- { aggregatable: true, name: 'source.ip', searchable: true, type: 'ip' },
- { aggregatable: true, name: 'source.port', searchable: true, type: 'long' },
- { aggregatable: true, name: 'event.end', searchable: true, type: 'date' },
-];
-
export const mockBrowserFields: BrowserFields = {
agent: {
fields: {
@@ -575,8 +542,26 @@ export const mockBrowserFields: BrowserFields = {
},
},
},
+
+ process: {
+ fields: {
+ 'process.args': {
+ name: 'process.args',
+ type: 'string',
+ esTypes: ['keyword'],
+ searchable: true,
+ aggregatable: true,
+ readFromDocValues: true,
+ format: '',
+ },
+ },
+ },
};
+export const mockIndexFields = flatten(
+ Object.values(mockBrowserFields).map((fieldItem) => Object.values(fieldItem.fields ?? {}))
+);
+
const runTimeType: MappingRuntimeFieldType = 'keyword' as const;
export const mockRuntimeMappings = {
diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts b/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts
index cfa0b1dadec15..98949d47047de 100644
--- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts
+++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts
@@ -5,7 +5,10 @@
* 2.0.
*/
+import { mockGlobalState } from '../../mock';
+import type { SelectedDataView } from '../../store/sourcerer/model';
import { initSourcererScope } from '../../store/sourcerer/model';
+import { mockBrowserFields, mockRuntimeMappings } from '../source/mock';
export const mockPatterns = [
'auditbeat-*',
@@ -17,24 +20,23 @@ export const mockPatterns = [
'journalbeat-*',
];
-export const mockSourcererScope = {
+export const mockSourcererScope: SelectedDataView = {
...initSourcererScope,
- scopePatterns: mockPatterns,
browserFields: {
+ ...mockBrowserFields,
_id: {
fields: {
_id: {
- __typename: 'IndexField',
aggregatable: false,
category: '_id',
description: 'Each document has an _id that uniquely identifies it',
- esTypes: null,
+ esTypes: undefined,
example: 'Y-6TfmcB0WOhS6qyMv3s',
- format: null,
+ format: undefined,
indexes: mockPatterns,
name: '_id',
searchable: true,
- subType: null,
+ subType: undefined,
type: 'string',
},
},
@@ -44,16 +46,20 @@ export const mockSourcererScope = {
fields: [
{
aggregatable: false,
- esTypes: null,
+ esTypes: undefined,
name: '_id',
searchable: true,
- subType: null,
+ subType: undefined,
type: 'string',
},
],
title: mockPatterns.join(),
},
+ sourcererDataView: mockGlobalState.sourcerer.defaultDataView,
selectedPatterns: mockPatterns,
indicesExist: true,
loading: false,
+ dataViewId: mockGlobalState.sourcerer.defaultDataView.id,
+ runtimeMappings: mockRuntimeMappings,
+ patternList: mockPatterns,
};
diff --git a/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts
new file mode 100644
index 0000000000000..2084685c9b6a0
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ExperimentalFeatures } from '../../../../common/experimental_features';
+import { allowedExperimentalValues } from '../../../../common/experimental_features';
+
+export const useIsExperimentalFeatureEnabled = jest
+ .fn()
+ .mockImplementation((feature: keyof ExperimentalFeatures): boolean => {
+ if (feature in allowedExperimentalValues) {
+ return allowedExperimentalValues[feature];
+ }
+
+ throw new Error(`Invalid experimental value ${feature}}`);
+ });
diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts
index 4c596481b4605..bb3c595ee3d9d 100644
--- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts
+++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts
@@ -16,6 +16,10 @@ import { URL_PARAM_KEY } from '../use_url_state';
import { useIsExperimentalFeatureEnabled } from '../use_experimental_features';
export const useInitTimelineFromUrlParam = () => {
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const isEsqlTabDisabled = useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled');
const queryTimelineById = useQueryTimelineById();
@@ -33,10 +37,11 @@ export const useInitTimelineFromUrlParam = () => {
timelineId: initialState.id,
openTimeline: initialState.isOpen,
savedSearchId: initialState.savedSearchId,
+ unifiedComponentsInTimelineEnabled,
});
}
},
- [isEsqlTabDisabled, queryTimelineById]
+ [isEsqlTabDisabled, queryTimelineById, unifiedComponentsInTimelineEnabled]
);
useEffect(() => {
diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.test.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.test.ts
index 985c69c71a849..ef86f71777484 100644
--- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.test.ts
+++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.test.ts
@@ -10,6 +10,8 @@ import { useQueryTimelineByIdOnUrlChange } from './use_query_timeline_by_id_on_u
import { renderHook } from '@testing-library/react-hooks';
import { timelineDefaults } from '../../../timelines/store/defaults';
+jest.mock('../use_experimental_features');
+
jest.mock('../../../timelines/components/open_timeline/helpers');
const mockFlyoutTimeline = jest
diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.ts
index 60d4ff10b8de1..a3f498daa4fce 100644
--- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.ts
+++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_query_timeline_by_id_on_url_change.ts
@@ -21,6 +21,7 @@ import {
getQueryStringFromLocation,
} from '../../utils/global_query_string/helpers';
import { URL_PARAM_KEY } from '../use_url_state';
+import { useIsExperimentalFeatureEnabled } from '../use_experimental_features';
/**
* After the initial load of the security solution, timeline is not updated when the timeline URL search value is changed
@@ -41,6 +42,10 @@ export const useQueryTimelineByIdOnUrlChange = () => {
const oldSearch = usePrevious(search);
const timelineIdFromReduxStore = flyoutTimeline?.savedObjectId ?? '';
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const [previousTimeline, currentTimeline] = useMemo(() => {
const oldUrlStateString = getQueryStringKeyValue({
urlKey: URL_PARAM_KEY.timeline,
@@ -69,9 +74,18 @@ export const useQueryTimelineByIdOnUrlChange = () => {
graphEventId,
timelineId: newId,
openTimeline: true,
+ unifiedComponentsInTimelineEnabled,
});
}
- }, [timelineIdFromReduxStore, oldId, newId, activeTab, graphEventId, queryTimelineById]);
+ }, [
+ timelineIdFromReduxStore,
+ oldId,
+ newId,
+ activeTab,
+ graphEventId,
+ queryTimelineById,
+ unifiedComponentsInTimelineEnabled,
+ ]);
};
export const getQueryStringKeyValue = ({ search, urlKey }: { search: string; urlKey: string }) =>
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 5cd2363e32d0c..f950d53d1212f 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
@@ -54,6 +54,10 @@ import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
import { contractStartServicesMock } from '../../../mocks';
import { getDefaultConfigSettings } from '../../../../common/config_settings';
+import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
+import { indexPatternFieldEditorPluginMock } from '@kbn/data-view-field-editor-plugin/public/mocks';
+import { UpsellingService } from '@kbn/security-solution-upselling/service';
+import { calculateBounds } from '@kbn/data-plugin/common';
const mockUiSettings: Record = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@@ -125,6 +129,24 @@ export const createStartServicesMock = (
const mockSetHeaderActionMenu = jest.fn();
const mockTimelineFilterManager = createFilterManagerMock();
+ /*
+ * Below mocks are needed by unified field list
+ * when data service is passed through as a prop
+ *
+ * */
+ data.query.timefilter.timefilter.getAbsoluteTime = jest.fn(() => ({
+ from: '2021-08-31T22:00:00.000Z',
+ to: '2022-09-01T09:16:29.553Z',
+ }));
+ data.query.timefilter.timefilter.getTime = jest.fn(() => {
+ return { from: 'now-15m', to: 'now' };
+ });
+ data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
+ return { pause: true, value: 1000 };
+ });
+ data.query.timefilter.timefilter.calculateBounds = jest.fn(calculateBounds);
+ /** ************************************************* */
+
return {
...core,
...contractStartServicesMock,
@@ -137,14 +159,7 @@ export const createStartServicesMock = (
dataViews: dataViewServiceMock,
data: {
...data,
- dataViews: {
- create: jest.fn(),
- getIdsWithTitle: jest.fn(),
- get: jest.fn(),
- getIndexPattern: jest.fn(),
- getFieldsForWildcard: jest.fn(),
- getRuntimeMappings: jest.fn(),
- },
+ dataViews: dataViewServiceMock,
query: {
...data.query,
savedQueries: {
@@ -206,6 +221,15 @@ export const createStartServicesMock = (
getHoverActions: jest.fn().mockReturnValue({
getAddToTimelineButton: jest.fn(),
}),
+ getUseAddToTimeline: jest.fn().mockReturnValue(
+ jest.fn().mockReturnValue({
+ startDragToTimeline: jest.fn(),
+ beginDrag: jest.fn(),
+ dragLocation: jest.fn(),
+ endDrag: jest.fn(),
+ cancelDrag: jest.fn(),
+ })
+ ),
},
osquery: {
OsqueryResults: jest.fn().mockReturnValue(null),
@@ -222,6 +246,9 @@ export const createStartServicesMock = (
uiActions: uiActionsPluginMock.createStartContract(),
savedSearch: savedSearchPluginMock.createStartContract(),
setHeaderActionMenu: mockSetHeaderActionMenu,
+ fieldFormats: fieldFormatsMock,
+ dataViewFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(),
+ upselling: new UpsellingService(),
timelineFilterManager: mockTimelineFilterManager,
} as unknown as StartServices;
};
diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts
index f36dec94e29b5..b59b30f629c9a 100644
--- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts
+++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts
@@ -6,7 +6,7 @@
*/
import { TableId } from '@kbn/securitysolution-data-table';
-import type { DataViewSpec } from '@kbn/data-views-plugin/public';
+import type { DataViewSpec, FieldSpec } from '@kbn/data-views-plugin/public';
import { HostsFields } from '../../../common/api/search_strategy/hosts/model/sort';
import { InputsModelId } from '../store/inputs/constants';
import {
@@ -58,7 +58,7 @@ export const mockSourcererState: SourcererState = {
...initialSourcererState.defaultDataView,
browserFields: mockBrowserFields,
id: DEFAULT_DATA_VIEW_ID,
- indexFields: mockIndexFields,
+ indexFields: mockIndexFields as FieldSpec[],
fields: mockFieldMap,
loading: false,
patternList: [...DEFAULT_INDEX_PATTERN, `${DEFAULT_SIGNALS_INDEX}-spacename`],
@@ -383,6 +383,7 @@ export const mockGlobalState: State = {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: true,
+ sampleSize: 500,
},
},
insertTimeline: null,
diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts
index c88e4fc3e8547..757eb40f1fcb0 100644
--- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts
+++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts
@@ -2034,6 +2034,7 @@ export const mockTimelineModel: TimelineModel = {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: false,
+ sampleSize: 500,
};
export const mockDataTableModel: DataTableModel = {
@@ -2216,6 +2217,8 @@ export const defaultTimelineProps: CreateTimelineProps = {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: false,
+ sampleSize: 500,
+ rowHeight: 3,
},
to: '2018-11-05T19:03:25.937Z',
notes: null,
diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
index f5e866f240e16..de7614999cf8f 100644
--- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
+++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
@@ -8,19 +8,25 @@
import { useCallback } from 'react';
import { useQueryTimelineById } from '../../../timelines/components/open_timeline/helpers';
import type { TimelineErrorCallback } from '../../../timelines/components/open_timeline/types';
+import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
export const useTimelineClick = () => {
const queryTimelineById = useQueryTimelineById();
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const handleTimelineClick = useCallback(
(timelineId: string, onError: TimelineErrorCallback, graphEventId?: string) => {
queryTimelineById({
graphEventId,
timelineId,
onError,
+ unifiedComponentsInTimelineEnabled,
});
},
- [queryTimelineById]
+ [queryTimelineById, unifiedComponentsInTimelineEnabled]
);
return handleTimelineClick;
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
index a362f6a542531..13d2a8e940c9d 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
@@ -21,8 +21,10 @@ import * as helpers from './helpers';
import { mockAlertSearchResponse } from './mock_data';
import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu';
import { AlertsHistogramPanel, LEGEND_WITH_COUNTS_WIDTH } from '.';
-import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { LensEmbeddable } from '../../../../common/components/visualization_actions/lens_embeddable';
+import type { ExperimentalFeatures } from '../../../../../common';
+import { allowedExperimentalValues } from '../../../../../common';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
jest.mock('../../../../common/containers/query_toggle');
@@ -110,7 +112,12 @@ jest.mock('../common/hooks', () => {
};
});
-const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock;
+const mockUseIsExperimentalFeatureEnabled = jest.fn((feature: keyof ExperimentalFeatures) => {
+ if (feature === 'alertsPageChartsEnabled') return false;
+ if (feature === 'chartEmbeddablesEnabled') return false;
+ return allowedExperimentalValues[feature];
+});
+
jest.mock('../../../../common/hooks/use_experimental_features');
jest.mock('../../../hooks/alerts_visualization/use_alert_histogram_count', () => ({
useAlertHistogramCount: jest.fn().mockReturnValue(999),
@@ -131,6 +138,10 @@ describe('AlertsHistogramPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+
+ (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(
+ mockUseIsExperimentalFeatureEnabled
+ );
});
it('renders correctly', () => {
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx
index 76e9aab7081ce..5049ccd92e116 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx
@@ -454,6 +454,8 @@ describe('alert actions', () => {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: false,
+ rowHeight: 3,
+ sampleSize: 500,
},
to: '2018-11-05T19:03:25.937Z',
ruleNote: '# this is some markdown documentation',
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx
index 30ff3978ef5b9..b9a3ce21b58fd 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx
@@ -950,7 +950,6 @@ export const sendAlertToTimelineAction = async ({
: ruleTimelineId
: '';
const { to, from } = determineToAndFrom({ ecs });
-
// For now we do not want to populate the template timeline if we have alertIds
if (!isEmpty(timelineId)) {
try {
@@ -974,6 +973,7 @@ export const sendAlertToTimelineAction = async ({
)
),
]);
+
const resultingTimeline: TimelineResult = getOr({}, 'data.getOneTimeline', responseTimeline);
const eventData: TimelineEventsDetailsItem[] = eventDataResp.data ?? [];
if (!isEmpty(resultingTimeline)) {
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx
index c620e78a83887..0cbd2daa58221 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx
@@ -18,6 +18,7 @@ import { useApi } from '@kbn/securitysolution-list-hooks';
import type { Filter } from '@kbn/es-query';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineId } from '../../../../../common/types/timeline';
@@ -32,6 +33,8 @@ import { getField } from '../../../../helpers';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions';
+import { defaultUdtHeaders } from '../../../../timelines/components/timeline/unified_components/default_headers';
+import { defaultHeaders } from '../../../../timelines/components/timeline/body/column_headers/default_headers';
interface UseInvestigateInTimelineActionProps {
ecsRowData?: Ecs | Ecs[] | null;
@@ -141,6 +144,9 @@ export const useInvestigateInTimeline = ({
timelineType: TimelineType.default,
});
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
const updateTimeline = useUpdateTimeline();
const createTimeline = useCallback(
@@ -154,6 +160,7 @@ export const useInvestigateInTimeline = ({
notes: [],
timeline: {
...timeline,
+ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders,
indexNames: timeline.indexNames ?? [],
show: true,
},
@@ -161,7 +168,12 @@ export const useInvestigateInTimeline = ({
ruleNote,
});
},
- [updateTimeline, updateTimelineIsLoading, clearActiveTimeline]
+ [
+ updateTimeline,
+ updateTimelineIsLoading,
+ clearActiveTimeline,
+ unifiedComponentsInTimelineEnabled,
+ ]
);
const investigateInTimelineAlertClick = useCallback(async () => {
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.test.ts
index 3459c07d46823..669064e76bbc9 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.test.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.test.ts
@@ -17,6 +17,7 @@ import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
import { mockTimeline } from '../../../../../server/lib/timeline/__mocks__/create_timelines';
import type { TimelineModel } from '../../../..';
+jest.mock('../../../../common/hooks/use_experimental_features');
jest.mock('../../../../common/utils/global_query_string/helpers');
jest.mock('../../../../timelines/containers/api');
jest.mock('../../../../common/hooks/use_app_toasts');
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx
index 700f1332d773c..c5fbc1d35d127 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_from_timeline.tsx
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { i18n } from '@kbn/i18n';
import type { EqlOptionsSelected } from '@kbn/timelines-plugin/common';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { convertKueryToElasticSearchQuery } from '../../../../common/lib/kuery';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
@@ -47,6 +48,10 @@ export const useRuleFromTimeline = (setRuleQuery: SetRuleQuery): RuleFromTimelin
SourcererScopeName.timeline
);
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const isEql = useRef(false);
// selectedTimeline = timeline to set rule from
@@ -195,10 +200,11 @@ export const useRuleFromTimeline = (setRuleQuery: SetRuleQuery): RuleFromTimelin
queryTimelineById({
timelineId,
onOpenTimeline,
+ unifiedComponentsInTimelineEnabled,
});
}
},
- [onOpenTimeline, queryTimelineById, selectedTimeline]
+ [onOpenTimeline, queryTimelineById, selectedTimeline, unifiedComponentsInTimelineEnabled]
);
const [urlStateInitialized, setUrlStateInitialized] = useState(false);
diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx
index 0ec865591aa37..4c92de5f9b552 100644
--- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx
+++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx
@@ -8,6 +8,7 @@
import { EuiHorizontalRule, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo, useEffect } from 'react';
+import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { SortFieldTimeline, TimelineType } from '../../../../common/api/timeline';
import { useGetAllTimeline } from '../../../timelines/containers/all';
import { useQueryTimelineById } from '../../../timelines/components/open_timeline/helpers';
@@ -32,6 +33,10 @@ interface Props {
const PAGE_SIZE = 3;
const StatefulRecentTimelinesComponent: React.FC = ({ filterBy }) => {
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
const { navigateToApp } = useKibana().services.application;
@@ -42,9 +47,10 @@ const StatefulRecentTimelinesComponent: React.FC = ({ filterBy }) => {
queryTimelineById({
duplicate,
timelineId,
+ unifiedComponentsInTimelineEnabled,
});
},
- [queryTimelineById]
+ [queryTimelineById, unifiedComponentsInTimelineEnabled]
);
const goToTimelines = useCallback(
diff --git a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx
index 70cdeced764ca..8dec5280b669c 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/edit_data_provider/helpers.test.tsx
@@ -118,6 +118,7 @@ describe('helpers', () => {
},
],
},
+ { label: 'process', options: [{ label: 'process.args' }] },
{ label: 'source', options: [{ label: 'source.ip' }, { label: 'source.port' }] },
{
label: 'user',
diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx
index 41f6bd25b8793..097d2d256a2c7 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx
@@ -25,6 +25,8 @@ jest.mock('react-redux', () => {
};
});
+jest.mock('../../../../common/hooks/use_experimental_features');
+
const renderNewTimelineButton = () =>
render( , { wrapper: TestProviders });
diff --git a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx
index ae8025406da67..e1bc119235e2e 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx
@@ -27,6 +27,8 @@ jest.mock('react-redux', () => {
};
});
+jest.mock('../../../common/hooks/use_experimental_features');
+
const renderNewTimelineButton = (type: TimelineType) =>
render( , { wrapper: TestProviders });
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts
index f360343c4083a..3732e818dffcc 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts
@@ -7,6 +7,7 @@
import { cloneDeep, getOr, omit } from 'lodash/fp';
import { renderHook } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
import { mockTimelineResults, mockGetOneTimelineResult } from '../../../common/mock';
import { timelineDefaults } from '../../store/defaults';
@@ -29,6 +30,7 @@ import {
mockTemplate as mockSelectedTemplate,
} from './__mocks__';
import { resolveTimeline } from '../../containers/api';
+import { defaultUdtHeaders } from '../timeline/unified_components/default_headers';
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
@@ -494,6 +496,60 @@ describe('helpers', () => {
title: 'Awesome Timeline',
});
});
+
+ test('should produce correct model if unifiedComponentsInTimelineEnabled is true', () => {
+ const timeline = {
+ savedObjectId: 'savedObject-1',
+ title: 'Awesome Timeline',
+ version: '1',
+ status: TimelineStatus.active,
+ timelineType: TimelineType.default,
+ };
+
+ const newTimeline = defaultTimelineToTimelineModel(
+ timeline,
+ false,
+ TimelineType.default,
+ true
+ );
+ expect(newTimeline).toEqual({
+ ...defaultTimeline,
+ dateRange: { end: '2020-07-08T08:20:18.966Z', start: '2020-07-07T08:20:18.966Z' },
+ status: TimelineStatus.active,
+ title: 'Awesome Timeline',
+ timelineType: TimelineType.default,
+ defaultColumns: defaultUdtHeaders,
+ columns: defaultUdtHeaders,
+ });
+ });
+
+ test('should produce correct model if unifiedComponentsInTimelineEnabled is true and custom set of columns is passed', () => {
+ const customColumns = defaultUdtHeaders.slice(0, 2);
+ const timeline = {
+ savedObjectId: 'savedObject-1',
+ title: 'Awesome Timeline',
+ version: '1',
+ status: TimelineStatus.active,
+ timelineType: TimelineType.default,
+ columns: customColumns,
+ };
+
+ const newTimeline = defaultTimelineToTimelineModel(
+ timeline,
+ false,
+ TimelineType.default,
+ true
+ );
+ expect(newTimeline).toEqual({
+ ...defaultTimeline,
+ dateRange: { end: '2020-07-08T08:20:18.966Z', start: '2020-07-07T08:20:18.966Z' },
+ status: TimelineStatus.active,
+ title: 'Awesome Timeline',
+ timelineType: TimelineType.default,
+ defaultColumns: defaultUdtHeaders,
+ columns: customColumns,
+ });
+ });
});
describe('queryTimelineById', () => {
@@ -717,6 +773,119 @@ describe('helpers', () => {
});
});
});
+ describe('open a timeline when unifiedComponentsInTimelineEnabled is true', () => {
+ const untitledTimeline = { ...mockSelectedTimeline, title: '' };
+ const onOpenTimeline = jest.fn();
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should update timeline correctly when timeline is untitled', async () => {
+ const args: QueryTimelineById = {
+ duplicate: false,
+ graphEventId: '',
+ timelineId: undefined,
+ timelineType: TimelineType.default,
+ onOpenTimeline,
+ openTimeline: true,
+ unifiedComponentsInTimelineEnabled: true,
+ };
+ (resolveTimeline as jest.Mock).mockResolvedValue(untitledTimeline);
+ renderHook(async () => {
+ const queryTimelineById = useQueryTimelineById();
+ queryTimelineById(args);
+ });
+
+ expect(dispatchUpdateIsLoading).toHaveBeenCalledWith({
+ id: TimelineId.active,
+ isLoading: true,
+ });
+
+ expect(mockUpdateTimeline).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ id: TimelineId.active,
+ timeline: expect.objectContaining({
+ columns: defaultUdtHeaders,
+ }),
+ })
+ );
+ expect(dispatchUpdateIsLoading).toHaveBeenCalledWith({
+ id: TimelineId.active,
+ isLoading: false,
+ });
+ });
+
+ it('should update timeline correctly when timeline is already saved and onOpenTimeline is not provided', async () => {
+ const args: QueryTimelineById = {
+ duplicate: false,
+ graphEventId: '',
+ timelineId: TimelineId.active,
+ timelineType: TimelineType.default,
+ onOpenTimeline: undefined,
+ openTimeline: true,
+ unifiedComponentsInTimelineEnabled: true,
+ };
+
+ (resolveTimeline as jest.Mock).mockResolvedValue(mockSelectedTimeline);
+ renderHook(async () => {
+ const queryTimelineById = useQueryTimelineById();
+ queryTimelineById(args);
+ });
+
+ expect(dispatchUpdateIsLoading).toHaveBeenCalledWith({
+ id: TimelineId.active,
+ isLoading: true,
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateTimeline).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ timeline: expect.objectContaining({
+ columns: mockSelectedTimeline.data.timeline.columns.map((col) => ({
+ columnHeaderType: col.columnHeaderType,
+ id: col.id,
+ initialWidth: defaultUdtHeaders.find((defaultCol) => col.id === defaultCol.id)
+ ?.initialWidth,
+ })),
+ }),
+ })
+ );
+ });
+ });
+
+ it('should update timeline correctly when timeline is already saved and onOpenTimeline IS provided', async () => {
+ const args: QueryTimelineById = {
+ duplicate: false,
+ graphEventId: '',
+ timelineId: TimelineId.active,
+ timelineType: TimelineType.default,
+ onOpenTimeline,
+ openTimeline: true,
+ unifiedComponentsInTimelineEnabled: true,
+ };
+
+ (resolveTimeline as jest.Mock).mockResolvedValue(mockSelectedTimeline);
+ renderHook(async () => {
+ const queryTimelineById = useQueryTimelineById();
+ queryTimelineById(args);
+ });
+
+ waitFor(() => {
+ expect(onOpenTimeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ columns: mockSelectedTimeline.data.timeline.columns.map((col) => ({
+ columnHeaderType: col.columnHeaderType,
+ id: col.id,
+ initialWidth: defaultUdtHeaders.find((defaultCol) => col.id === defaultCol.id)
+ ?.initialWidth,
+ })),
+ })
+ );
+ });
+ });
+ });
});
describe('omitTypenameInTimeline', () => {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts
index 6a900a7ff89a6..835862c04ced8 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts
@@ -33,10 +33,6 @@ import {
defaultColumnHeaderType,
defaultHeaders,
} from '../timeline/body/column_headers/default_headers';
-import {
- DEFAULT_DATE_COLUMN_MIN_WIDTH,
- DEFAULT_COLUMN_MIN_WIDTH,
-} from '../timeline/body/constants';
import type { OpenTimelineResult, TimelineErrorCallback } from './types';
import { IS_OPERATOR } from '../timeline/data_providers/data_provider';
@@ -47,6 +43,7 @@ import {
DEFAULT_TO_MOMENT,
} from '../../../common/utils/default_date_settings';
import { resolveTimeline } from '../../containers/api';
+import { defaultUdtHeaders } from '../timeline/unified_components/default_headers';
import { timelineActions } from '../../store';
export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline';
@@ -88,7 +85,7 @@ const parseString = (params: string) => {
}
};
-const setTimelineColumn = (col: ColumnHeaderResult) =>
+const setTimelineColumn = (col: ColumnHeaderResult, defaultHeadersValue: ColumnHeaderOptions[]) =>
Object.entries(col).reduce(
(acc, [key, value]) => {
if (key !== 'id' && value != null) {
@@ -99,8 +96,8 @@ const setTimelineColumn = (col: ColumnHeaderResult) =>
{
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
- initialWidth:
- col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
+ initialWidth: defaultHeadersValue.find((defaultCol) => col.id === defaultCol.id)
+ ?.initialWidth,
}
);
@@ -235,13 +232,21 @@ export const getTimelineStatus = (
export const defaultTimelineToTimelineModel = (
timeline: TimelineResult,
duplicate: boolean,
- timelineType?: TimelineType
+ timelineType?: TimelineType,
+ unifiedComponentsInTimelineEnabled?: boolean
): TimelineModel => {
const isTemplate = timeline.timelineType === TimelineType.template;
+ const defaultHeadersValue = unifiedComponentsInTimelineEnabled
+ ? defaultUdtHeaders
+ : defaultHeaders;
+
const timelineEntries = {
...timeline,
- columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders,
- defaultColumns: defaultHeaders,
+ columns:
+ timeline.columns != null
+ ? timeline.columns.map((col) => setTimelineColumn(col, defaultHeadersValue))
+ : defaultHeadersValue,
+ defaultColumns: defaultHeadersValue,
dateRange:
timeline.status === TimelineStatus.immutable &&
timeline.timelineType === TimelineType.template
@@ -282,12 +287,18 @@ export const defaultTimelineToTimelineModel = (
export const formatTimelineResultToModel = (
timelineToOpen: TimelineResult,
duplicate: boolean = false,
- timelineType?: TimelineType
+ timelineType?: TimelineType,
+ unifiedComponentsInTimelineEnabled?: boolean
): { notes: Note[] | null | undefined; timeline: TimelineModel } => {
const { notes, ...timelineModel } = timelineToOpen;
return {
notes,
- timeline: defaultTimelineToTimelineModel(timelineModel, duplicate, timelineType),
+ timeline: defaultTimelineToTimelineModel(
+ timelineModel,
+ duplicate,
+ timelineType,
+ unifiedComponentsInTimelineEnabled
+ ),
};
};
@@ -301,6 +312,11 @@ export interface QueryTimelineById {
onOpenTimeline?: (timeline: TimelineModel) => void;
openTimeline?: boolean;
savedSearchId?: string;
+ /*
+ * Below feature flag will be removed once
+ * unified components have been fully migrated
+ * */
+ unifiedComponentsInTimelineEnabled?: boolean;
}
export const useQueryTimelineById = () => {
@@ -324,6 +340,7 @@ export const useQueryTimelineById = () => {
onOpenTimeline,
openTimeline = true,
savedSearchId,
+ unifiedComponentsInTimelineEnabled = false,
}: QueryTimelineById) => {
updateIsLoading({ id: TimelineId.active, isLoading: true });
if (timelineId == null) {
@@ -335,6 +352,7 @@ export const useQueryTimelineById = () => {
to: DEFAULT_TO_MOMENT.toISOString(),
timeline: {
...timelineDefaults,
+ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders,
id: TimelineId.active,
activeTab: activeTimelineTab,
show: openTimeline,
@@ -355,7 +373,8 @@ export const useQueryTimelineById = () => {
const { timeline, notes } = formatTimelineResultToModel(
timelineToOpen,
duplicate,
- timelineType
+ timelineType,
+ unifiedComponentsInTimelineEnabled
);
if (onOpenTimeline != null) {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
index 2cf293ee0ec2f..4ccefedf07822 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
@@ -9,6 +9,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { encode } from '@kbn/rison';
+import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
RULE_FROM_EQL_URL_PARAM,
RULE_FROM_TIMELINE_URL_PARAM,
@@ -55,6 +56,7 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction';
import { TIMELINE_ACTIONS } from '../../../common/lib/apm/user_actions';
+import { defaultUdtHeaders } from '../timeline/unified_components/default_headers';
interface OwnProps {
/** Displays open timeline in modal */
@@ -157,6 +159,9 @@ export const StatefulOpenTimelineComponent = React.memo(
);
const { dataViewId, selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline);
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
const {
customTemplateTimelineCount,
@@ -245,7 +250,7 @@ export const StatefulOpenTimelineComponent = React.memo(
dispatch(
dispatchCreateNewTimeline({
id: TimelineId.active,
- columns: defaultHeaders,
+ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders,
dataViewId,
indexNames: selectedPatterns,
show: false,
@@ -256,7 +261,15 @@ export const StatefulOpenTimelineComponent = React.memo(
await deleteTimelinesByIds(timelineIds, searchIds);
refetch();
},
- [startTransaction, timelineSavedObjectId, refetch, dispatch, dataViewId, selectedPatterns]
+ [
+ startTransaction,
+ timelineSavedObjectId,
+ refetch,
+ dispatch,
+ dataViewId,
+ selectedPatterns,
+ unifiedComponentsInTimelineEnabled,
+ ]
);
const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback(
@@ -353,6 +366,7 @@ export const StatefulOpenTimelineComponent = React.memo(
onOpenTimeline,
timelineId,
timelineType: timelineTypeToOpen,
+ unifiedComponentsInTimelineEnabled,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx
index ae2cd149ef69d..deb82934dc823 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx
@@ -12,6 +12,7 @@ import { EuiFlyout } from '@elastic/eui';
import type { EntityType } from '@kbn/timelines-plugin/common';
import { dataTableActions, dataTableSelectors } from '@kbn/securitysolution-data-table';
+import styled from 'styled-components';
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers';
import { timelineSelectors } from '../../store';
import { timelineDefaults } from '../../store/defaults';
@@ -35,6 +36,11 @@ interface DetailsPanelProps {
isReadOnly?: boolean;
}
+// hack to to get around the fact that this flyout causes issue with timeline modal z-index
+const StyleEuiFlyout = styled(EuiFlyout)`
+ z-index: 1002;
+`;
+
/**
* This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages.
* To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used
@@ -87,7 +93,7 @@ export const DetailsPanel = React.memo(
}
}, [dispatch, scopeId]);
- const activeTab = tabType ?? TimelineTabs.query;
+ const activeTab: TimelineTabs = tabType ?? TimelineTabs.query;
const closePanel = useCallback(() => {
if (handleOnPanelClosed) handleOnPanelClosed();
else defaultOnPanelClose();
@@ -166,7 +172,7 @@ export const DetailsPanel = React.memo(
}
return isFlyoutView ? (
-
{visiblePanel}
-
+
) : (
visiblePanel
);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap
index 12e78df1e49ec..14a17ec011923 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap
@@ -648,6 +648,21 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
},
},
},
+ "process": Object {
+ "fields": Object {
+ "process.args": Object {
+ "aggregatable": true,
+ "esTypes": Array [
+ "keyword",
+ ],
+ "format": "",
+ "name": "process.args",
+ "readFromDocValues": true,
+ "searchable": true,
+ "type": "string",
+ },
+ },
+ },
"source": Object {
"fields": Object {
"source.ip": Object {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts
index 317400f3bae90..8a1aaf6295b9a 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts
@@ -11,6 +11,7 @@ import type { BrowserFields } from '../../../../../../common/search_strategy';
import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants';
import { defaultHeaders } from './default_headers';
+import { defaultUdtHeaders } from '../../unified_components/default_headers';
import {
getColumnWidthFromType,
getColumnHeaders,
@@ -87,6 +88,17 @@ describe('helpers', () => {
type: 'date',
});
});
+
+ test('should return the expected metadata in case of unified header', () => {
+ const inputHeaders = defaultUdtHeaders;
+ expect(getColumnHeader('@timestamp', inputHeaders)).toEqual({
+ columnHeaderType: 'not-filtered',
+ id: '@timestamp',
+ initialWidth: 215,
+ esTypes: ['date'],
+ type: 'date',
+ });
+ });
});
describe('getColumnHeaders', () => {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts
index 23c79faf6d391..163721b2db659 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts
@@ -13,3 +13,5 @@ export const RESIZED_COLUMN_MIN_WITH = 70; // px
/** The default minimum width of a column of type `date` */
export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px
+
+export const DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH = 215; // px
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx
index 944ee20034d0e..6f402316c1c96 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx
@@ -70,6 +70,7 @@ const FormattedFieldValueComponent: React.FC<{
eventId: string;
isAggregatable?: boolean;
isObjectArray?: boolean;
+ isUnifiedDataTable?: boolean;
fieldFormat?: string;
fieldFromBrowserField?: BrowserField;
fieldName: string;
@@ -89,6 +90,7 @@ const FormattedFieldValueComponent: React.FC<{
eventId,
fieldFormat,
isAggregatable = false,
+ isUnifiedDataTable,
fieldName,
fieldType = '',
fieldFromBrowserField,
@@ -130,9 +132,12 @@ const FormattedFieldValueComponent: React.FC<{
className={classNames}
fieldName={fieldName}
value={value}
- tooltipProps={{ position: 'bottom', className: dataGridToolTipOffset }}
+ tooltipProps={
+ isUnifiedDataTable ? undefined : { position: 'bottom', className: dataGridToolTipOffset }
+ }
/>
);
+ if (isUnifiedDataTable) return date;
return isDraggable ? (
{value}
);
} else {
+ // This should not be reached for the unified data table
const contentValue = getOrEmptyTagFromValue(value);
const content = truncate ? {contentValue} : contentValue;
return (
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx
new file mode 100644
index 0000000000000..d731b6e831f9d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { mockTimelineData } from '../../../../../common/mock';
+import { defaultUdtHeaders } from '../../unified_components/default_headers';
+import { getFormattedFields } from './formatted_field_udt';
+import type { DataTableRecord } from '@kbn/discover-utils/types';
+
+describe('formatted_fields_udt', () => {
+ describe('getFormattedFields', () => {
+ it('should return correct map for all the present headers', () => {
+ const result = getFormattedFields({
+ dataTableRows: mockTimelineData.map((row) => ({
+ ...row,
+ id: '',
+ raw: {} as DataTableRecord['raw'],
+ flattened: {} as DataTableRecord['flattened'],
+ })),
+ headers: defaultUdtHeaders,
+ scopeId: 'timeline',
+ });
+
+ const expected = {
+ '@timestamp': expect.any(Function),
+ message: expect.any(Function),
+ 'event.category': expect.any(Function),
+ 'event.action': expect.any(Function),
+ 'host.name': expect.any(Function),
+ 'source.ip': expect.any(Function),
+ 'destination.ip': expect.any(Function),
+ 'user.name': expect.any(Function),
+ };
+
+ expect(result).toMatchObject(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.tsx
new file mode 100644
index 0000000000000..545a198593fe8
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_udt.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
+import React from 'react';
+
+import type { ColumnHeaderOptions, TimelineItem } from '@kbn/timelines-plugin/common';
+import type { DataTableRecord } from '@kbn/discover-utils/types';
+import { DefaultCellRenderer } from '../../cell_rendering/default_cell_renderer';
+
+export const getFormattedFields = ({
+ dataTableRows,
+ headers,
+ scopeId,
+}: {
+ dataTableRows: Array;
+ headers: ColumnHeaderOptions[];
+ scopeId: string;
+}) => {
+ return headers.reduce(
+ (
+ obj: Record React.ReactNode>,
+ header: ColumnHeaderOptions
+ ) => {
+ obj[header.id] = function UnifiedFieldRender(props: EuiDataGridCellValueElementProps) {
+ return (
+
+ );
+ };
+ return obj;
+ },
+ {}
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx
new file mode 100644
index 0000000000000..401fe8763ada5
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.test.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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 { TimelineTabs } from '../../../../../common/types';
+import { DataLoadingState } from '@kbn/unified-data-table';
+import React from 'react';
+import { UnifiedTimeline } from '../unified_components';
+import { defaultUdtHeaders } from '../unified_components/default_headers';
+import type { UnifiedTimelineBodyProps } from './unified_timeline_body';
+import { UnifiedTimelineBody } from './unified_timeline_body';
+import { render, screen } from '@testing-library/react';
+import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock';
+
+jest.mock('../unified_components', () => {
+ return {
+ UnifiedTimeline: jest.fn(),
+ };
+});
+
+const mockEventsData = structuredClone(mockTimelineData);
+
+const defaultProps: UnifiedTimelineBodyProps = {
+ activeTab: TimelineTabs.query,
+ columns: defaultUdtHeaders,
+ dataLoadingState: DataLoadingState.loading,
+ events: mockEventsData,
+ expandedDetail: {},
+ header:
,
+ isTextBasedQuery: false,
+ itemsPerPage: 25,
+ itemsPerPageOptions: [10, 25, 50],
+ onChangePage: jest.fn(),
+ onEventClosed: jest.fn(),
+ refetch: jest.fn(),
+ rowRenderers: [],
+ showExpandedDetails: false,
+ sort: [],
+ timelineId: 'timeline-1',
+ totalCount: 0,
+ updatedAt: 0,
+ pageInfo: {
+ activePage: 0,
+ querySize: 0,
+ },
+};
+
+const renderTestComponents = (props?: UnifiedTimelineBodyProps) => {
+ return render( , {
+ wrapper: TestProviders,
+ });
+};
+
+const MockUnifiedTimelineComponent = jest.fn(() =>
);
+
+describe('UnifiedTimelineBody', () => {
+ beforeEach(() => {
+ (UnifiedTimeline as unknown as jest.Mock).mockImplementation(MockUnifiedTimelineComponent);
+ });
+ it('should pass correct page rows', () => {
+ const { rerender } = renderTestComponents();
+
+ expect(screen.getByTestId('unifiedTimelineBody')).toBeVisible();
+ expect(MockUnifiedTimelineComponent).toHaveBeenCalledTimes(2);
+
+ expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ events: mockEventsData.flat(),
+ }),
+ {}
+ );
+
+ const newEventsData = structuredClone([mockEventsData[0]]);
+
+ const newProps = {
+ ...defaultProps,
+ pageInfo: {
+ activePage: 1,
+ querySize: 0,
+ },
+ events: newEventsData,
+ };
+
+ MockUnifiedTimelineComponent.mockClear();
+ rerender( );
+ expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ events: [...mockEventsData, ...newEventsData].flat(),
+ }),
+ {}
+ );
+ });
+
+ it('should pass default columns when empty column list is supplied', () => {
+ const newProps = { ...defaultProps, columns: [] };
+ renderTestComponents(newProps);
+
+ expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ columns: defaultUdtHeaders,
+ }),
+ {}
+ );
+ });
+ it('should pass custom columns when supplied', () => {
+ const newProps = { ...defaultProps, columns: defaultHeaders };
+ renderTestComponents(newProps);
+
+ expect(MockUnifiedTimelineComponent).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ columns: defaultHeaders,
+ }),
+ {}
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx
new file mode 100644
index 0000000000000..1a131871dc4fe
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/unified_timeline_body.tsx
@@ -0,0 +1,91 @@
+/*
+ * 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 { ComponentProps, ReactElement } from 'react';
+import React, { useEffect, useState, useMemo } from 'react';
+import { RootDragDropProvider } from '@kbn/dom-drag-drop';
+import { isEmpty } from 'lodash';
+import { StyledTableFlexGroup, StyledTableFlexItem } from '../unified_components/styles';
+import { UnifiedTimeline } from '../unified_components';
+import { defaultUdtHeaders } from '../unified_components/default_headers';
+import type { PaginationInputPaginated, TimelineItem } from '../../../../../common/search_strategy';
+
+export interface UnifiedTimelineBodyProps extends ComponentProps {
+ header: ReactElement;
+ pageInfo: Pick;
+}
+
+export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => {
+ const {
+ header,
+ pageInfo,
+ columns,
+ rowRenderers,
+ timelineId,
+ itemsPerPage,
+ itemsPerPageOptions,
+ sort,
+ events,
+ refetch,
+ dataLoadingState,
+ totalCount,
+ onEventClosed,
+ expandedDetail,
+ showExpandedDetails,
+ onChangePage,
+ activeTab,
+ updatedAt,
+ } = props;
+
+ const [pageRows, setPageRows] = useState([]);
+
+ const rows = useMemo(() => pageRows.flat(), [pageRows]);
+
+ useEffect(() => {
+ setPageRows((currentPageRows) => {
+ if (pageInfo.activePage !== 0 && currentPageRows[pageInfo.activePage]?.length) {
+ return currentPageRows;
+ }
+ const newPageRows = pageInfo.activePage === 0 ? [] : [...currentPageRows];
+ newPageRows[pageInfo.activePage] = events;
+ return newPageRows;
+ });
+ }, [events, pageInfo.activePage]);
+
+ const columnsHeader = useMemo(() => {
+ return isEmpty(columns) ? defaultUdtHeaders : columns;
+ }, [columns]);
+
+ return (
+
+ {header}
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx
index ee803cad8674a..6ca5f2bb5f7f1 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx
@@ -22,6 +22,7 @@ import { connect, useDispatch } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import { InPortal } from 'react-reverse-portal';
+import { DataLoadingState } from '@kbn/unified-data-table';
import type { ControlColumnProps } from '../../../../../common/types';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { timelineActions, timelineSelectors } from '../../../store';
@@ -192,7 +193,7 @@ export const EqlTabContentComponent: React.FC = ({
};
const [
- isQueryLoading,
+ queryLoadingState,
{ events, inspect, totalCount, pageInfo, loadPage, refreshedAt, refetch },
] = useTimelineEvents({
dataViewId,
@@ -210,6 +211,13 @@ export const EqlTabContentComponent: React.FC = ({
timerangeKind,
});
+ const isQueryLoading = useMemo(
+ () =>
+ queryLoadingState === DataLoadingState.loading ||
+ queryLoadingState === DataLoadingState.loadingMore,
+ [queryLoadingState]
+ );
+
const handleOnPanelClosed = useCallback(() => {
onEventClosed({ tabType: TimelineTabs.eql, id: timelineId });
}, [onEventClosed, timelineId]);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx
index 019a4a1ce4132..a7ef4080c6d5e 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx
@@ -27,40 +27,10 @@ import type { OnChangePage } from '../events';
import { EVENTS_COUNT_BUTTON_CLASS_NAME } from '../helpers';
import * as i18n from './translations';
-import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context';
import { timelineActions, timelineSelectors } from '../../../store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useKibana } from '../../../../common/lib/kibana';
-
-export const isCompactFooter = (width: number): boolean => width < 600;
-
-interface FixedWidthLastUpdatedContainerProps {
- updatedAt: number;
-}
-
-const FixedWidthLastUpdatedContainer = React.memo(
- ({ updatedAt }) => {
- const { timelines } = useKibana().services;
- const width = useEventDetailsWidthContext();
- const compact = useMemo(() => isCompactFooter(width), [width]);
-
- return updatedAt > 0 ? (
-
- {timelines.getLastUpdated({ updatedAt, compact })}
-
- ) : null;
- }
-);
-
-FixedWidthLastUpdatedContainer.displayName = 'FixedWidthLastUpdatedContainer';
-
-const FixedWidthLastUpdated = styled.div<{ compact?: boolean }>`
- width: ${({ compact }) => (!compact ? 200 : 25)}px;
- overflow: hidden;
- text-align: end;
-`;
-
-FixedWidthLastUpdated.displayName = 'FixedWidthLastUpdated';
+import { FixedWidthLastUpdatedContainer } from './last_updated';
interface HeightProp {
height: number;
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.test.tsx
new file mode 100644
index 0000000000000..fd26a06d0f3cb
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context';
+import { FixedWidthLastUpdatedContainer } from './last_updated';
+import { useKibana } from '../../../../common/lib/kibana';
+import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock';
+
+jest.mock('../../../../common/components/events_viewer/event_details_width_context');
+jest.mock('../../../../common/lib/kibana');
+
+const mockEventDetailsWidthContainer = jest.fn().mockImplementation(() => {
+ return 800;
+});
+
+const mockUseLastUpdatedTimelinesPlugin = jest.fn().mockImplementation(() => {
+ return `Updated 2 minutes ago`;
+});
+
+describe('FixWidthLastUpdateContainer', () => {
+ beforeEach(() => {
+ (useKibana as jest.Mock).mockImplementation(() => {
+ return {
+ services: {
+ ...createStartServicesMock(),
+ timelines: {
+ getLastUpdated: mockUseLastUpdatedTimelinesPlugin,
+ },
+ },
+ };
+ });
+
+ (useEventDetailsWidthContext as jest.Mock).mockImplementation(mockEventDetailsWidthContainer);
+ });
+
+ it('should return normal version when width is greater than 600', () => {
+ render( );
+ expect(screen.getByTestId('fixed-width-last-updated')).toHaveTextContent(
+ 'Updated 2 minutes ago'
+ );
+ expect(screen.getByTestId('fixed-width-last-updated')).toHaveStyle({
+ width: '200px',
+ });
+ });
+ it('should return compact version when width is less than 600', () => {
+ mockEventDetailsWidthContainer.mockReturnValueOnce(400);
+ render( );
+ expect(screen.getByTestId('fixed-width-last-updated')).toHaveTextContent(
+ 'Updated 2 minutes ago'
+ );
+ expect(screen.getByTestId('fixed-width-last-updated')).toHaveStyle({
+ width: '25px',
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx
new file mode 100644
index 0000000000000..02359cb792378
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useMemo } from 'react';
+import styled from 'styled-components';
+import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context';
+
+import { useKibana } from '../../../../common/lib/kibana';
+
+interface FixedWidthLastUpdatedContainerProps {
+ updatedAt: number;
+}
+
+export const isCompactFooter = (width: number): boolean => width < 600;
+
+export const FixedWidthLastUpdatedContainer = React.memo(
+ ({ updatedAt }) => {
+ const { timelines } = useKibana().services;
+ const width = useEventDetailsWidthContext();
+ const compact = useMemo(() => isCompactFooter(width), [width]);
+
+ return updatedAt > 0 ? (
+
+ {timelines.getLastUpdated({ updatedAt, compact })}
+
+ ) : null;
+ }
+);
+
+FixedWidthLastUpdatedContainer.displayName = 'FixedWidthLastUpdatedContainer';
+
+const FixedWidthLastUpdated = styled.span<{ compact?: boolean }>`
+ width: ${({ compact }) => (!compact ? 200 : 25)}px;
+ text-overflow: ellipsis;
+ text-align: end;
+ overflow: hidden;
+`;
+
+FixedWidthLastUpdated.displayName = 'FixedWidthLastUpdated';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx
index 5faf91b9fd6ee..900789b24c6ed 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx
@@ -12,6 +12,7 @@ import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { isTab } from '@kbn/timelines-plugin/public';
+import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { timelineActions, timelineSelectors } from '../../store';
import { timelineDefaults } from '../../store/defaults';
@@ -32,6 +33,7 @@ import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_ful
import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict';
import { sourcererSelectors } from '../../../common/store';
import { TimelineTour } from './tour';
+import { defaultUdtHeaders } from './unified_components/default_headers';
const TimelineTemplateBadge = styled.div`
background: ${({ theme }) => theme.eui.euiColorVis3_behindText};
@@ -72,6 +74,11 @@ const StatefulTimelineComponent: React.FC = ({
openToggleRef,
}) => {
const dispatch = useDispatch();
+
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
const containerElement = useRef(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const selectedPatternsSourcerer = useSelector((state: State) => {
@@ -122,7 +129,7 @@ const StatefulTimelineComponent: React.FC = ({
dispatch(
timelineActions.createTimeline({
id: timelineId,
- columns: defaultHeaders,
+ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders,
dataViewId: selectedDataViewIdSourcerer,
indexNames: selectedPatternsSourcerer,
show: false,
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx
index 20eff77cfbaec..98f731d5f7f15 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx
@@ -14,6 +14,7 @@ import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
import deepEqual from 'fast-deep-equal';
+import { DataLoadingState } from '@kbn/unified-data-table';
import type { ControlColumnProps } from '../../../../../common/types';
import { timelineActions, timelineSelectors } from '../../../store';
import type { CellValueElementProps } from '../cell_rendering';
@@ -182,7 +183,7 @@ export const PinnedTabContentComponent: React.FC = ({
[sort]
);
- const [isQueryLoading, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] =
+ const [queryLoadingState, { events, totalCount, pageInfo, loadPage, refreshedAt, refetch }] =
useTimelineEvents({
endDate: '',
id: `pinned-${timelineId}`,
@@ -198,6 +199,11 @@ export const PinnedTabContentComponent: React.FC = ({
timerangeKind: undefined,
});
+ const isQueryLoading = useMemo(
+ () => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(queryLoadingState),
+ [queryLoadingState]
+ );
+
const handleOnPanelClosed = useCallback(() => {
onEventClosed({ tabType: TimelineTabs.pinned, id: timelineId });
}, [timelineId, onEventClosed]);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx
index 1fe1f2ebbfa7d..ff6fac5d733f0 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx
@@ -117,8 +117,7 @@ describe('Timeline', () => {
};
});
- // FLAKY: https://github.com/elastic/kibana/issues/156797
- describe.skip('rendering', () => {
+ describe('rendering', () => {
let spyCombineQueries: jest.SpyInstance;
beforeEach(() => {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx
index 40f8834264200..d8dd976848fd9 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx
@@ -23,6 +23,11 @@ import deepEqual from 'fast-deep-equal';
import { InPortal } from 'react-reverse-portal';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
+import { DataLoadingState } from '@kbn/unified-data-table';
+import type { BrowserFields, ColumnHeaderOptions } from '@kbn/timelines-plugin/common';
+import memoizeOne from 'memoize-one';
+import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import type { ControlColumnProps } from '../../../../../common/types';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { useInvalidFilterQuery } from '../../../../common/hooks/use_invalid_filter_query';
@@ -43,7 +48,7 @@ import type {
RowRenderer,
ToggleDetailPanel,
} from '../../../../../common/types/timeline';
-import { TimelineTabs } from '../../../../../common/types/timeline';
+import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config';
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
import type { inputsModel, State } from '../../../../common/store';
@@ -59,6 +64,16 @@ import { ExitFullScreen } from '../../../../common/components/exit_full_screen';
import { getDefaultControlColumn } from '../body/control_columns';
import { useLicense } from '../../../../common/hooks/use_license';
import { HeaderActions } from '../../../../common/components/header_actions/header_actions';
+import { defaultUdtHeaders } from '../unified_components/default_headers';
+import { UnifiedTimelineBody } from '../body/unified_timeline_body';
+import { getColumnHeaders } from '../body/column_headers/helpers';
+
+const memoizedGetColumnHeaders: (
+ headers: ColumnHeaderOptions[],
+ browserFields: BrowserFields,
+ isEventRenderedView: boolean
+) => ColumnHeaderOptions[] = memoizeOne(getColumnHeaders);
+
const QueryTabHeaderContainer = styled.div`
width: 100%;
`;
@@ -173,6 +188,7 @@ export const QueryTabContentComponent: React.FC = ({
status,
sort,
timerangeKind,
+ expandedDetail,
}) => {
const dispatch = useDispatch();
const { portalNode: timelineEventsCountPortalNode } = useTimelineEventsCountPortal();
@@ -192,6 +208,18 @@ export const QueryTabContentComponent: React.FC = ({
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5;
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
+
+ const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
+
+ const currentTimeline = useDeepEqualSelector((state) =>
+ getManageTimeline(state, timelineId ?? TimelineId.active)
+ );
+
+ const { sampleSize } = currentTimeline;
+
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
const kqlQuery: {
query: string;
@@ -237,9 +265,20 @@ export const QueryTabContentComponent: React.FC = ({
[combinedQueries, end, loadingSourcerer, start]
);
+ const defaultColumns = useMemo(
+ () => (unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders),
+ [unifiedComponentsInTimelineEnabled]
+ );
+
+ const localColumns = useMemo(
+ () => (isEmpty(columns) ? defaultColumns : columns),
+ [columns, defaultColumns]
+ );
+
+ const augumentedColumnHeaders = memoizedGetColumnHeaders(localColumns, browserFields, false);
+
const getTimelineQueryFields = () => {
- const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
- const columnFields = columnsHeader.map((c) => c.id);
+ const columnFields = augumentedColumnHeaders.map((c) => c.id);
return [...columnFields, ...requiredFieldsForActions];
};
@@ -255,12 +294,13 @@ export const QueryTabContentComponent: React.FC = ({
dispatch(
timelineActions.initializeTimelineSettings({
id: timelineId,
+ defaultColumns,
})
);
- }, [dispatch, timelineId]);
+ }, [dispatch, timelineId, defaultColumns]);
const [
- isQueryLoading,
+ dataLoadingState,
{ events, inspect, totalCount, pageInfo, loadPage, refreshedAt, refetch },
] = useTimelineEvents({
dataViewId,
@@ -270,7 +310,7 @@ export const QueryTabContentComponent: React.FC = ({
id: timelineId,
indexNames: selectedPatterns,
language: kqlQuery.language,
- limit: itemsPerPage,
+ limit: unifiedComponentsInTimelineEnabled ? sampleSize : itemsPerPage,
runtimeMappings,
skip: !canQueryTimeline,
sort: timelineQuerySortField,
@@ -278,6 +318,11 @@ export const QueryTabContentComponent: React.FC = ({
timerangeKind,
});
+ const isQueryLoading = useMemo(
+ () => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(dataLoadingState),
+ [dataLoadingState]
+ );
+
const handleOnPanelClosed = useCallback(() => {
onEventClosed({ tabType: TimelineTabs.query, id: timelineId });
}, [onEventClosed, timelineId]);
@@ -305,11 +350,88 @@ export const QueryTabContentComponent: React.FC = ({
// is not getting refreshed when using browser navigation.
const showEventsCountBadge = !isBlankTimeline && totalCount >= 0;
+ const header = useMemo(
+ () => (
+
+
+ {showEventsCountBadge ? {totalCount} : null}
+
+
+ {!unifiedComponentsInTimelineEnabled &&
+ timelineFullScreen &&
+ setTimelineFullScreen != null && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {/* TODO: This is a temporary solution to hide the KPIs until lens components play nicely with timelines */}
+ {/* https://github.com/elastic/kibana/issues/17156 */}
+ {/* */}
+ {/* */}
+ {/* */}
+
+
+ ),
+ [
+ activeTab,
+ timelineFilterManager,
+ show,
+ showCallOutUnauthorizedMsg,
+ status,
+ timelineId,
+ setTimelineFullScreen,
+ timelineFullScreen,
+ unifiedComponentsInTimelineEnabled,
+ timelineEventsCountPortalNode,
+ showEventsCountBadge,
+ totalCount,
+ ]
+ );
+
+ if (unifiedComponentsInTimelineEnabled) {
+ return (
+
+ );
+ }
+
return (
<>
-
- {showEventsCountBadge ? {totalCount} : null}
-
= ({
/>
-
-
- {timelineFullScreen && setTimelineFullScreen != null && (
-
-
-
-
-
- )}
-
-
-
-
-
- {/* TODO: This is a temporary solution to hide the KPIs until lens components play nicely with timelines */}
- {/* https://github.com/elastic/kibana/issues/17156 */}
- {/* */}
- {/* */}
- {/* */}
-
-
+ {header}
{
status,
timelineType,
} = timeline;
+
const kqlQueryTimeline = getKqlQueryTimeline(state, timelineId);
const timelineFilter = kqlMode === 'filter' ? filters || [] : [];
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/query_tab_unified_components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/query_tab_unified_components.test.tsx
new file mode 100644
index 0000000000000..05aae984f0357
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/query_tab_unified_components.test.tsx
@@ -0,0 +1,701 @@
+/*
+ * 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 { ComponentProps } from 'react';
+import React, { useEffect } from 'react';
+import QueryTabContent from '.';
+import { defaultRowRenderers } from '../body/renderers';
+import { TimelineId } from '../../../../../common/types/timeline';
+import { useTimelineEvents } from '../../../containers';
+import { useTimelineEventsDetails } from '../../../containers/details';
+import { useSourcererDataView } from '../../../../common/containers/sourcerer';
+import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks';
+import {
+ createSecuritySolutionStorageMock,
+ mockTimelineData,
+ TestProviders,
+} from '../../../../common/mock';
+import { DefaultCellRenderer } from '../cell_rendering/default_cell_renderer';
+import { render, screen, waitFor, fireEvent, within, cleanup } from '@testing-library/react';
+import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock';
+import type { StartServices } from '../../../../types';
+import { useKibana } from '../../../../common/lib/kibana';
+import { useDispatch } from 'react-redux';
+import { timelineActions } from '../../../store';
+import type { ExperimentalFeatures } from '../../../../../common';
+import { allowedExperimentalValues } from '../../../../../common';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
+import { cloneDeep, flatten } from 'lodash';
+
+jest.mock('../../../containers', () => ({
+ useTimelineEvents: jest.fn(),
+}));
+
+jest.mock('../../../containers/details');
+
+jest.mock('../../fields_browser', () => ({
+ useFieldBrowserOptions: jest.fn(),
+}));
+
+jest.mock('../body/events', () => ({
+ Events: () => <>>,
+}));
+
+jest.mock('../../../../common/containers/sourcerer');
+jest.mock('../../../../common/containers/sourcerer/use_signal_helpers', () => ({
+ useSignalHelpers: () => ({ signalIndexNeedsInit: false }),
+}));
+
+jest.mock('../../../../common/lib/kuery');
+
+jest.mock('../../../../common/hooks/use_experimental_features');
+
+// These tests can take more than standard timeout of 5s
+// that is why we are setting it to 15s
+const SPECIAL_TEST_TIMEOUT = 15000;
+
+const useIsExperimentalFeatureEnabledMock = jest.fn((feature: keyof ExperimentalFeatures) => {
+ if (feature === 'unifiedComponentsInTimelineEnabled') {
+ return true;
+ }
+ return allowedExperimentalValues[feature];
+});
+
+jest.mock('../../../../common/lib/kibana');
+
+// unified-field-list is is reporiting multiple analytics events
+jest.mock(`@kbn/analytics-client`);
+
+const TestComponent = (props: Partial>) => {
+ const testComponentDefaultProps: ComponentProps = {
+ timelineId: TimelineId.test,
+ renderCellValue: DefaultCellRenderer,
+ rowRenderers: defaultRowRenderers,
+ };
+
+ const dispatch = useDispatch();
+
+ // populating timeline so that it is not blank
+ useEffect(() => {
+ dispatch(
+ timelineActions.applyKqlFilterQuery({
+ id: TimelineId.test,
+ filterQuery: {
+ kuery: {
+ kind: 'kuery',
+ expression: '*',
+ },
+ serializedQuery: '*',
+ },
+ })
+ );
+ }, [dispatch]);
+
+ return ;
+};
+
+const renderTestComponents = (props?: Partial>) => {
+ return render( , {
+ wrapper: TestProviders,
+ });
+};
+
+const changeItemsPerPageTo = (newItemsPerPage: number) => {
+ fireEvent.click(screen.getByTestId('tablePaginationPopoverButton'));
+ fireEvent.click(screen.getByTestId(`tablePagination-${newItemsPerPage}-rows`));
+ expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent(
+ `Rows per page: ${newItemsPerPage}`
+ );
+};
+
+const loadPageMock = jest.fn();
+
+const useTimelineEventsMock = jest.fn(() => [
+ false,
+ {
+ events: cloneDeep(mockTimelineData),
+ pageInfo: {
+ activePage: 0,
+ totalPages: 10,
+ },
+ refreshedAt: Date.now(),
+ totalCount: 70,
+ loadPage: loadPageMock,
+ },
+]);
+
+const useSourcererDataViewMocked = jest.fn().mockReturnValue({
+ ...mockSourcererScope,
+});
+
+const { storage: storageMock } = createSecuritySolutionStorageMock();
+
+describe('query tab with unified timeline', () => {
+ const kibanaServiceMock: StartServices = {
+ ...createStartServicesMock(),
+ storage: storageMock,
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ storageMock.clear();
+ cleanup();
+ localStorage.clear();
+ });
+
+ beforeEach(() => {
+ // increase timeout for these tests as they are rendering a complete table with ~30 rows which can take time.
+ const ONE_SECOND = 1000;
+ jest.setTimeout(10 * ONE_SECOND);
+ HTMLElement.prototype.getBoundingClientRect = jest.fn(() => {
+ return {
+ width: 1000,
+ height: 1000,
+ x: 0,
+ y: 0,
+ } as DOMRect;
+ });
+
+ (useKibana as jest.Mock).mockImplementation(() => {
+ return {
+ services: kibanaServiceMock,
+ };
+ });
+
+ (useTimelineEvents as jest.Mock).mockImplementation(useTimelineEventsMock);
+
+ (useTimelineEventsDetails as jest.Mock).mockImplementation(() => [false, {}]);
+
+ (useSourcererDataView as jest.Mock).mockImplementation(useSourcererDataViewMocked);
+
+ (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(
+ useIsExperimentalFeatureEnabledMock
+ );
+ });
+
+ describe('render', () => {
+ it(
+ 'should render unifiedDataTable in timeline',
+ async () => {
+ renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should render unified-field-list in timeline',
+ async () => {
+ renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('timeline-sidebar')).toBeVisible();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('pagination', () => {
+ it(
+ 'should paginate correctly',
+ async () => {
+ renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent(
+ 'Rows per page: 5'
+ );
+ });
+
+ expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true');
+ expect(screen.getByTestId('pagination-button-6')).toBeVisible();
+
+ fireEvent.click(screen.getByTestId('pagination-button-6'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination-button-6')).toHaveAttribute('aria-current', 'true');
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should load more records according to sample size correctly',
+ async () => {
+ renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination-button-0')).toHaveAttribute('aria-current', 'true');
+ expect(screen.getByTestId('pagination-button-6')).toBeVisible();
+ });
+ // Go to last page
+ fireEvent.click(screen.getByTestId('pagination-button-6'));
+ await waitFor(() => {
+ expect(screen.getByTestId('dscGridSampleSizeFetchMoreLink')).toBeVisible();
+ });
+ fireEvent.click(screen.getByTestId('dscGridSampleSizeFetchMoreLink'));
+ expect(loadPageMock).toHaveBeenNthCalledWith(1, 1);
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('columns', () => {
+ it(
+ 'should move column left/right correctly ',
+ async () => {
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+ expect(container.querySelector('[data-gridcell-column-id="message"]')).toHaveAttribute(
+ 'data-gridcell-column-index',
+ '12'
+ );
+
+ expect(container.querySelector('[data-gridcell-column-id="message"]')).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="message"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Move left')).toBeEnabled();
+ });
+
+ fireEvent.click(screen.getByTitle('Move left'));
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-gridcell-column-id="message"]')).toHaveAttribute(
+ 'data-gridcell-column-index',
+ '11'
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should remove column left/right ',
+ async () => {
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(container.querySelector('[data-gridcell-column-id="message"]')).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="message"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Remove column')).toBeVisible();
+ });
+
+ fireEvent.click(screen.getByTitle('Remove column'));
+
+ await waitFor(() => {
+ expect(
+ container.querySelector('[data-gridcell-column-id="message"]')
+ ).not.toBeInTheDocument();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort date column',
+ async () => {
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector('[data-gridcell-column-id="@timestamp"]')
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="@timestamp"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Sort Old-New')).toBeVisible();
+ });
+ expect(screen.getByTitle('Sort New-Old')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort Old-New'));
+
+ await waitFor(() => {
+ expect(useTimelineEventsMock).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ sort: [
+ {
+ direction: 'asc',
+ esTypes: [],
+ field: '@timestamp',
+ type: 'date',
+ },
+ ],
+ })
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort string column correctly',
+ async () => {
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector('[data-gridcell-column-id="host.name"]')
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="host.name"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('dataGridHeaderCellActionGroup-host.name')).toBeVisible();
+ });
+
+ expect(screen.getByTitle('Sort A-Z')).toBeVisible();
+ expect(screen.getByTitle('Sort Z-A')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort A-Z'));
+
+ await waitFor(() => {
+ expect(useTimelineEventsMock).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ sort: [
+ {
+ direction: 'desc',
+ esTypes: [],
+ field: '@timestamp',
+ type: 'date',
+ },
+ {
+ direction: 'asc',
+ esTypes: [],
+ field: 'host.name',
+ type: 'string',
+ },
+ ],
+ })
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort number column',
+ async () => {
+ const field = {
+ name: 'event.severity',
+ type: 'number',
+ };
+
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`dataGridHeaderCellActionGroup-${field.name}`)).toBeVisible();
+ });
+
+ expect(screen.getByTitle('Sort Low-High')).toBeVisible();
+ expect(screen.getByTitle('Sort High-Low')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort Low-High'));
+
+ await waitFor(() => {
+ expect(useTimelineEventsMock).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ sort: [
+ {
+ direction: 'desc',
+ esTypes: [],
+ field: '@timestamp',
+ type: 'date',
+ },
+ {
+ direction: 'asc',
+ esTypes: [],
+ field: field.name,
+ type: field.type,
+ },
+ ],
+ })
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('left controls', () => {
+ it(
+ 'should clear all sorting',
+ async () => {
+ renderTestComponents();
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ expect(screen.getByTestId('dataGridColumnSortingButton')).toBeVisible();
+ expect(
+ within(screen.getByTestId('dataGridColumnSortingButton')).getByRole('marquee')
+ ).toHaveTextContent('1');
+
+ fireEvent.click(screen.getByTestId('dataGridColumnSortingButton'));
+
+ // // timestamp sorting indicators
+ expect(
+ await screen.findByTestId('euiDataGridColumnSorting-sortColumn-@timestamp')
+ ).toBeVisible();
+
+ expect(screen.getByTestId('dataGridHeaderCellSortingIcon-@timestamp')).toBeVisible();
+
+ fireEvent.click(screen.getByTestId('dataGridColumnSortingClearButton'));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('dataGridHeaderCellSortingIcon-@timestamp')).toBeNull();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should be able to sort by multiple columns',
+ async () => {
+ renderTestComponents();
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ expect(screen.getByTestId('dataGridColumnSortingButton')).toBeVisible();
+ expect(
+ within(screen.getByTestId('dataGridColumnSortingButton')).getByRole('marquee')
+ ).toHaveTextContent('1');
+
+ fireEvent.click(screen.getByTestId('dataGridColumnSortingButton'));
+
+ // // timestamp sorting indicators
+ expect(
+ await screen.findByTestId('euiDataGridColumnSorting-sortColumn-@timestamp')
+ ).toBeVisible();
+
+ expect(screen.getByTestId('dataGridHeaderCellSortingIcon-@timestamp')).toBeVisible();
+
+ // add more columns to sorting
+ fireEvent.click(screen.getByText(/Pick fields to sort by/));
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('dataGridColumnSortingPopoverColumnSelection-event.severity')
+ ).toBeVisible();
+ });
+
+ fireEvent.click(
+ screen.getByTestId('dataGridColumnSortingPopoverColumnSelection-event.severity')
+ );
+
+ // check new columns for sorting validity
+ await waitFor(() => {
+ expect(screen.getByTestId('dataGridHeaderCellSortingIcon-event.severity')).toBeVisible();
+ });
+ expect(
+ screen.getByTestId('euiDataGridColumnSorting-sortColumn-event.severity')
+ ).toBeVisible();
+
+ expect(screen.getByTestId('dataGridHeaderCellSortingIcon-@timestamp')).toBeVisible();
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('unified fields list', () => {
+ it(
+ 'should add the column when clicked on X sign',
+ async () => {
+ const field = {
+ name: 'event.severity',
+ };
+
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
+ '11'
+ );
+ });
+
+ // column exists in the table
+ expect(screen.getByTestId(`dataGridHeaderCell-${field.name}`)).toBeVisible();
+
+ fireEvent.click(screen.getAllByTestId(`fieldToggle-${field.name}`)[0]);
+
+ // column not longer exists in the table
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
+ '10'
+ );
+ });
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(0);
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should remove the column when clicked on ⊕ sign',
+ async () => {
+ const field = {
+ name: 'agent.id',
+ };
+
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
+ '11'
+ );
+ });
+
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(0);
+
+ // column exists in the table
+ const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
+
+ fireEvent.click(within(availableFields).getByTestId(`fieldToggle-${field.name}`));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
+ '12'
+ );
+ });
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(1);
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should should show callout when field search does not matches any field',
+ async () => {
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toHaveTextContent(
+ '36'
+ );
+ });
+
+ fireEvent.change(screen.getByTestId('fieldListFiltersFieldSearch'), {
+ target: { value: 'fake_field' },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('fieldListGroupedAvailableFieldsNoFieldsCallout-noFieldsMatch')
+ ).toBeVisible();
+ });
+
+ expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toHaveTextContent('0');
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should toggle side bar correctly',
+ async () => {
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toHaveTextContent(
+ '36'
+ );
+ });
+
+ fireEvent.click(screen.getByTitle('Hide sidebar'));
+
+ await waitFor(() => {
+ expect(screen.queryAllByTestId('fieldListGroupedAvailableFields-count')).toHaveLength(0);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should have all populated fields in Available fields section',
+ async () => {
+ const listOfPopulatedFields = new Set(
+ flatten(
+ mockTimelineData.map((dataItem) =>
+ dataItem.data.map((item) =>
+ item.value && item.value.length > 0 ? item.field : undefined
+ )
+ )
+ ).filter((item) => typeof item !== 'undefined')
+ );
+
+ renderTestComponents();
+
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ changeItemsPerPageTo(100);
+
+ const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
+
+ for (const field of listOfPopulatedFields) {
+ fireEvent.change(screen.getByTestId('fieldListFiltersFieldSearch'), {
+ target: { value: field },
+ });
+
+ expect(within(availableFields).getByTestId(`field-${field}`));
+ }
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx
new file mode 100644
index 0000000000000..4beae12e85a95
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.test.tsx
@@ -0,0 +1,397 @@
+/*
+ * 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 { createMockStore, mockTimelineData, TestProviders } from '../../../../../common/mock';
+import React from 'react';
+import { TimelineDataTable } from '.';
+import { defaultUdtHeaders } from '../default_headers';
+import { TimelineId, TimelineTabs } from '../../../../../../common/types';
+import { DataLoadingState } from '@kbn/unified-data-table';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
+import type { ComponentProps } from 'react';
+import { getColumnHeaders } from '../../body/column_headers/helpers';
+import { mockSourcererScope } from '../../../../../common/containers/sourcerer/mocks';
+import { timelineActions } from '../../../../store';
+import type { ExpandedDetailTimeline } from '../../../../../../common/types';
+
+jest.mock('../../../../../common/containers/sourcerer');
+
+const onFieldEditedMock = jest.fn();
+const refetchMock = jest.fn();
+const onEventClosedMock = jest.fn();
+const onChangePageMock = jest.fn();
+
+const initialEnrichedColumns = getColumnHeaders(
+ defaultUdtHeaders,
+ mockSourcererScope.browserFields
+);
+
+type TestComponentProps = Partial> & {
+ store?: ReturnType;
+};
+
+// These tests can take more than standard timeout of 5s
+// that is why we are setting it to 10s
+const SPECIAL_TEST_TIMEOUT = 10000;
+
+const TestComponent = (props: TestComponentProps) => {
+ const { store = createMockStore(), ...restProps } = props;
+ useSourcererDataView();
+ return (
+
+
+
+ );
+};
+
+const getTimelineFromStore = (
+ store: ReturnType,
+ timelineId: string = TimelineId.test
+) => {
+ return store.getState().timeline.timelineById[timelineId];
+};
+
+describe('unified data table', () => {
+ beforeEach(() => {
+ (useSourcererDataView as jest.Mock).mockReturnValue(mockSourcererScope);
+ });
+
+ it(
+ 'should display unified data table',
+ async () => {
+ render( );
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ describe('custom cell rendering based on data Type', () => {
+ it(
+ 'should render source.ip as link',
+ async () => {
+ const eventsWithSourceIp = mockTimelineData.filter(
+ (event) => event.ecs?.source?.ip?.length ?? -1 > 0
+ );
+
+ const { container } = render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const sourceIpCell = container.querySelector(
+ '[data-gridcell-column-id="source.ip"][data-test-subj="dataGridRowCell"]'
+ ) as HTMLElement;
+
+ expect(sourceIpCell).toBeVisible();
+
+ expect(within(sourceIpCell).getByTestId('network-details')).toHaveClass('euiLink');
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ it(
+ 'should render destination.ip as link',
+ async () => {
+ const eventsWithDestIp = mockTimelineData.filter(
+ (event) => event.ecs?.destination?.ip?.length ?? -1 > 0
+ );
+
+ const { container } = render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const destIpCell = container.querySelector(
+ '[data-gridcell-column-id="destination.ip"][data-test-subj="dataGridRowCell"]'
+ ) as HTMLElement;
+
+ expect(destIpCell).toBeVisible();
+
+ expect(within(destIpCell).getByTestId('network-details')).toHaveClass('euiLink');
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ it(
+ 'should render host.name as link',
+ async () => {
+ const hostTestId = 'host-details-button';
+ const eventsWithHostName = mockTimelineData.filter(
+ (event) => event.ecs?.host?.name?.length ?? -1 > 0
+ );
+
+ const { container } = render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const hostNameCell = container.querySelector(
+ '[data-gridcell-column-id="host.name"][data-test-subj="dataGridRowCell"]'
+ ) as HTMLElement;
+
+ expect(hostNameCell).toBeVisible();
+
+ expect(within(hostNameCell).getByTestId(hostTestId)).toHaveClass('euiLink');
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ it(
+ 'should render user.name as link',
+ async () => {
+ const userTestId = 'users-link-anchor';
+ const eventsWithUserName = mockTimelineData.filter(
+ (event) => event.ecs?.user?.name?.length ?? -1 > 0
+ );
+
+ const { container } = render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const userNameCell = container.querySelector(
+ '[data-gridcell-column-id="user.name"][data-test-subj="dataGridRowCell"]'
+ ) as HTMLElement;
+
+ expect(userNameCell).toBeVisible();
+
+ expect(within(userNameCell).getByTestId(userTestId)).toHaveClass('euiLink');
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ it(
+ 'should refetch on sample size change',
+ async () => {
+ render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const toolbarSettings = screen.getByTestId('dataGridDisplaySelectorButton');
+
+ fireEvent.click(toolbarSettings);
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId('unifiedDataTableSampleSizeInput')[0]).toBeVisible();
+ });
+
+ fireEvent.change(screen.getAllByTestId('unifiedDataTableSampleSizeInput')[0], {
+ target: { value: '10' },
+ });
+
+ await waitFor(() => {
+ expect(refetchMock).toHaveBeenCalledTimes(1);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should update row Height correctly',
+ async () => {
+ const rowHeight = {
+ initial: 2,
+ new: 1,
+ };
+ const customMockStore = createMockStore();
+
+ customMockStore.dispatch(
+ timelineActions.updateRowHeight({
+ id: TimelineId.test,
+ rowHeight: rowHeight.initial,
+ })
+ );
+
+ render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ const toolbarSettings = screen.getByTestId('dataGridDisplaySelectorButton');
+
+ fireEvent.click(toolbarSettings);
+
+ await waitFor(() => {
+ expect(
+ screen.getAllByTestId('unifiedDataTableRowHeightSettings_lineCountNumber')[0]
+ ).toBeVisible();
+ });
+ expect(
+ screen.getAllByTestId('unifiedDataTableRowHeightSettings_lineCountNumber')[0]
+ ).toHaveValue(String(rowHeight.initial));
+
+ fireEvent.change(
+ screen.getAllByTestId('unifiedDataTableRowHeightSettings_lineCountNumber')[0],
+ {
+ target: { value: String(rowHeight.new) },
+ }
+ );
+
+ await waitFor(() => {
+ expect(getTimelineFromStore(customMockStore)?.rowHeight).toEqual(rowHeight.new);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ describe('details flyout', () => {
+ it(
+ 'should show defails flyout when clicked on expand event',
+ async () => {
+ render( );
+
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+
+ fireEvent.click(screen.getAllByTestId('docTableExpandToggleColumn')[0]);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('timeline:details-panel:flyout')).toBeVisible();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should show details flyout when expandedDetails state is set',
+ async () => {
+ const customMockStore = createMockStore();
+ const mockExpandedDetail: ExpandedDetailTimeline = {
+ query: {
+ params: {
+ eventId: 'some_id',
+ indexName: 'security-*',
+ },
+ panelView: 'eventDetail',
+ },
+ };
+ customMockStore.dispatch(
+ timelineActions.toggleDetailPanel({
+ id: TimelineId.test,
+ tabType: TimelineTabs.query,
+ ...mockExpandedDetail.query,
+ })
+ );
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('timeline:details-panel:flyout')).toBeVisible();
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ it(
+ 'should close details flyout when close icon is clicked',
+ async () => {
+ const customMockStore = createMockStore();
+ const mockExpandedDetail: ExpandedDetailTimeline = {
+ query: {
+ params: {
+ eventId: 'some_id',
+ indexName: 'security-*',
+ },
+ panelView: 'eventDetail',
+ },
+ };
+
+ customMockStore.dispatch(
+ timelineActions.toggleDetailPanel({
+ id: TimelineId.test,
+ tabType: TimelineTabs.query,
+ ...mockExpandedDetail.query,
+ })
+ );
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('euiFlyoutCloseButton')).toBeVisible();
+ });
+
+ fireEvent.click(screen.getByTestId('euiFlyoutCloseButton'));
+ expect(onEventClosedMock).toHaveBeenCalledTimes(1);
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('pagination', () => {
+ // change the number of items per page
+ it(
+ 'should change the number of items per page',
+ async () => {
+ const customMockStore = createMockStore();
+ render( );
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+ // make sure that items per page is as per the props passed above
+ expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent(
+ `Rows per page: 50`
+ );
+ fireEvent.click(screen.getByTestId('tablePaginationPopoverButton'));
+ fireEvent.click(screen.getByTestId(`tablePagination-25-rows`));
+ await waitFor(() => {
+ expect(getTimelineFromStore(customMockStore)?.itemsPerPage).toEqual(25);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should be able to load more records once user have seen all the records loaded according to sample size',
+ async () => {
+ const customMockStore = createMockStore();
+ customMockStore.dispatch(
+ timelineActions.updateSampleSize({
+ id: TimelineId.test,
+ sampleSize: 10,
+ })
+ );
+ render(
+
+ );
+ expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
+ expect(screen.getByTestId('dscGridSampleSizeFetchMoreLink')).toBeVisible();
+ fireEvent.click(screen.getByTestId('dscGridSampleSizeFetchMoreLink'));
+ await waitFor(() => {
+ expect(onChangePageMock).toHaveBeenNthCalledWith(1, 1);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx
new file mode 100644
index 0000000000000..acb083bf95cb0
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx
@@ -0,0 +1,360 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, useMemo, useCallback, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import type { DataTableRecord } from '@kbn/discover-utils/types';
+import type {
+ UnifiedDataTableSettingsColumn,
+ UnifiedDataTableProps,
+} from '@kbn/unified-data-table';
+import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table';
+import type { DataView } from '@kbn/data-views-plugin/public';
+import { EmptyComponent } from '../../../../../common/lib/cell_actions/helpers';
+import { withDataView } from '../../../../../common/components/with_data_view';
+import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
+import type { ExpandedDetailTimeline, ExpandedDetailType } from '../../../../../../common/types';
+import type { TimelineItem } from '../../../../../../common/search_strategy';
+import { useKibana } from '../../../../../common/lib/kibana';
+import type {
+ ColumnHeaderOptions,
+ OnChangePage,
+ RowRenderer,
+ ToggleDetailPanel,
+} from '../../../../../../common/types/timeline';
+import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
+import type { State, inputsModel } from '../../../../../common/store';
+import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
+import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
+import { activeTimeline } from '../../../../containers/active_timeline_context';
+import { DetailsPanel } from '../../../side_panel';
+import { SecurityCellActionsTrigger } from '../../../../../actions/constants';
+import { getFormattedFields } from '../../body/renderers/formatted_field_udt';
+import { timelineBodySelector } from '../../body/selectors';
+import ToolbarAdditionalControls from './toolbar_additional_controls';
+import { StyledTimelineUnifiedDataTable, StyledEuiProgress } from '../styles';
+import { timelineDefaults } from '../../../../store/defaults';
+import { timelineActions } from '../../../../store';
+import { transformTimelineItemToUnifiedRows } from '../utils';
+
+export const SAMPLE_SIZE_SETTING = 500;
+const DataGridMemoized = React.memo(UnifiedDataTable);
+
+type CommonDataTableProps = {
+ columns: ColumnHeaderOptions[];
+ rowRenderers: RowRenderer[];
+ timelineId: string;
+ itemsPerPage: number;
+ itemsPerPageOptions: number[];
+ events: TimelineItem[];
+ refetch: inputsModel.Refetch;
+ onFieldEdited: () => void;
+ totalCount: number;
+ onEventClosed: (args: ToggleDetailPanel) => void;
+ expandedDetail: ExpandedDetailTimeline;
+ showExpandedDetails: boolean;
+ onChangePage: OnChangePage;
+ activeTab: TimelineTabs;
+ dataLoadingState: DataLoadingState;
+ updatedAt: number;
+ isTextBasedQuery?: boolean;
+} & Pick;
+
+interface DataTableProps extends CommonDataTableProps {
+ dataView: DataView;
+}
+
+export const TimelineDataTableComponent: React.FC = memo(
+ function TimelineDataTableMemo({
+ columns,
+ dataView,
+ activeTab,
+ timelineId,
+ itemsPerPage,
+ itemsPerPageOptions,
+ rowRenderers,
+ sort,
+ events,
+ onFieldEdited,
+ refetch,
+ dataLoadingState,
+ totalCount,
+ onEventClosed,
+ showExpandedDetails,
+ expandedDetail,
+ onChangePage,
+ updatedAt,
+ isTextBasedQuery = false,
+ onSetColumns,
+ onSort,
+ onFilter,
+ }) {
+ const dispatch = useDispatch();
+
+ // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created
+ const [activeStatefulEventContext] = useState({
+ timelineID: timelineId,
+ enableHostDetailsFlyout: true,
+ enableIpDetailsFlyout: true,
+ tabType: activeTab,
+ });
+
+ const {
+ services: {
+ uiSettings,
+ fieldFormats,
+ storage,
+ dataViewFieldEditor,
+ notifications: { toasts: toastsService },
+ theme,
+ data: dataPluginContract,
+ },
+ } = useKibana();
+
+ const [expandedDoc, setExpandedDoc] = useState();
+ const [fetchedPage, setFechedPage] = useState(0);
+
+ const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.timeline);
+
+ const showTimeCol = useMemo(() => !!dataView && !!dataView.timeFieldName, [dataView]);
+
+ const tableSettings = useMemo(() => {
+ const columnSettings = columns.reduce((acc, item) => {
+ if (item.initialWidth) {
+ acc[item.id] = { width: item.initialWidth };
+ }
+ return acc;
+ }, {} as Record);
+
+ return {
+ columns: columnSettings,
+ };
+ }, [columns]);
+
+ const defaultColumnIds = useMemo(() => columns.map((c) => c.id), [columns]);
+
+ const { timeline: { rowHeight, sampleSize } = timelineDefaults } = useSelector((state: State) =>
+ timelineBodySelector(state, timelineId)
+ );
+
+ const tableRows = useMemo(
+ () => transformTimelineItemToUnifiedRows({ events, dataView }),
+ [events, dataView]
+ );
+
+ const handleOnEventDetailPanelOpened = useCallback(
+ (eventData: DataTableRecord & TimelineItem) => {
+ const updatedExpandedDetail: ExpandedDetailType = {
+ panelView: 'eventDetail',
+ params: {
+ eventId: eventData.id,
+ indexName: eventData._index ?? '', // TODO: fix type error
+ refetch,
+ },
+ };
+
+ dispatch(
+ timelineActions.toggleDetailPanel({
+ ...updatedExpandedDetail,
+ tabType: TimelineTabs.query,
+ id: timelineId,
+ })
+ );
+
+ activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
+ },
+ [dispatch, refetch, timelineId]
+ );
+
+ const handleOnPanelClosed = useCallback(() => {
+ if (
+ expandedDetail[TimelineTabs.query]?.panelView &&
+ timelineId === TimelineId.active &&
+ showExpandedDetails
+ ) {
+ activeTimeline.toggleExpandedDetail({});
+ }
+ setExpandedDoc(undefined);
+ onEventClosed({ tabType: TimelineTabs.query, id: timelineId });
+ }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]);
+
+ const onSetExpandedDoc = useCallback(
+ (newDoc?: DataTableRecord) => {
+ if (newDoc) {
+ const timelineDoc = tableRows.find((r) => r.id === newDoc.id);
+ setExpandedDoc(timelineDoc);
+ if (timelineDoc) {
+ handleOnEventDetailPanelOpened(timelineDoc);
+ }
+ } else {
+ handleOnPanelClosed();
+ }
+ },
+ [tableRows, handleOnEventDetailPanelOpened, handleOnPanelClosed]
+ );
+
+ const onColumnResize = useCallback(
+ ({ columnId, width }: { columnId: string; width: number }) => {
+ dispatch(
+ timelineActions.updateColumnWidth({
+ columnId,
+ id: timelineId,
+ width, // initialWidth?
+ })
+ );
+ },
+ [dispatch, timelineId]
+ );
+
+ const onResizeDataGrid = useCallback(
+ (colSettings) => {
+ onColumnResize({ columnId: colSettings.columnId, width: Math.round(colSettings.width) });
+ },
+ [onColumnResize]
+ );
+
+ const onChangeItemsPerPage = useCallback(
+ (itemsChangedPerPage) => {
+ dispatch(
+ timelineActions.updateItemsPerPage({ id: timelineId, itemsPerPage: itemsChangedPerPage })
+ );
+ },
+ [dispatch, timelineId]
+ );
+
+ const customColumnRenderers = useMemo(
+ () =>
+ getFormattedFields({
+ dataTableRows: tableRows,
+ scopeId: 'timeline',
+ headers: columns,
+ }),
+ [columns, tableRows]
+ );
+
+ const handleFetchMoreRecords = useCallback(() => {
+ onChangePage(fetchedPage + 1);
+ setFechedPage(fetchedPage + 1);
+ }, [fetchedPage, onChangePage]);
+
+ const additionalControls = useMemo(
+ () => ,
+ [timelineId, updatedAt]
+ );
+
+ const cellActionsMetadata = useMemo(() => ({ scopeId: timelineId }), [timelineId]);
+
+ const onUpdateSampleSize = useCallback(
+ (newSampleSize: number) => {
+ if (newSampleSize !== sampleSize) {
+ dispatch(timelineActions.updateSampleSize({ id: timelineId, sampleSize: newSampleSize }));
+ refetch();
+ }
+ },
+ [dispatch, sampleSize, timelineId, refetch]
+ );
+
+ const onUpdateRowHeight = useCallback(
+ (newRowHeight: number) => {
+ if (newRowHeight !== rowHeight) {
+ dispatch(timelineActions.updateRowHeight({ id: timelineId, rowHeight: newRowHeight }));
+ }
+ },
+ [dispatch, rowHeight, timelineId]
+ );
+
+ const dataGridServices = useMemo(() => {
+ return {
+ theme,
+ fieldFormats,
+ storage,
+ toastNotifications: toastsService,
+ uiSettings,
+ dataViewFieldEditor,
+ data: dataPluginContract,
+ };
+ }, [
+ theme,
+ fieldFormats,
+ storage,
+ toastsService,
+ uiSettings,
+ dataViewFieldEditor,
+ dataPluginContract,
+ ]);
+
+ return (
+
+
+ {(dataLoadingState === DataLoadingState.loading ||
+ dataLoadingState === DataLoadingState.loadingMore) && (
+
+ )}
+
+ {showExpandedDetails && (
+
+ )}
+
+
+ );
+ }
+);
+
+export const TimelineDataTable = withDataView(TimelineDataTableComponent);
+
+// eslint-disable-next-line import/no-default-export
+export { TimelineDataTable as default };
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx
new file mode 100644
index 0000000000000..1c7fb20206095
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/toolbar_additional_controls.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
+import React, { useMemo, useCallback, useRef } from 'react';
+
+import { isActiveTimeline } from '../../../../../helpers';
+import { TimelineId } from '../../../../../../common/types/timeline';
+import {
+ useGlobalFullScreen,
+ useTimelineFullScreen,
+} from '../../../../../common/containers/use_full_screen';
+import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser';
+import * as i18n from './translations';
+import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../../../common/components/exit_full_screen';
+import { FixedWidthLastUpdatedContainer } from '../../footer/last_updated';
+
+export const isFullScreen = ({
+ globalFullScreen,
+ isActiveTimelines,
+ timelineFullScreen,
+}: {
+ globalFullScreen: boolean;
+ isActiveTimelines: boolean;
+ timelineFullScreen: boolean;
+}) =>
+ (isActiveTimelines && timelineFullScreen) || (isActiveTimelines === false && globalFullScreen);
+
+interface Props {
+ timelineId: string;
+ updatedAt: number;
+}
+
+export const ToolbarAdditionalControlsComponent: React.FC = ({ timelineId, updatedAt }) => {
+ const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
+ const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
+
+ const toolTipRef = useRef(null);
+
+ const hideToolTip = () => toolTipRef.current?.hideToolTip();
+
+ const toggleFullScreen = useCallback(() => {
+ hideToolTip();
+ if (timelineId === TimelineId.active) {
+ setTimelineFullScreen(!timelineFullScreen);
+ } else {
+ setGlobalFullScreen(!globalFullScreen);
+ }
+ }, [
+ timelineId,
+ setTimelineFullScreen,
+ timelineFullScreen,
+ setGlobalFullScreen,
+ globalFullScreen,
+ ]);
+ const fullScreen = useMemo(
+ () =>
+ isFullScreen({
+ globalFullScreen,
+ isActiveTimelines: isActiveTimeline(timelineId),
+ timelineFullScreen,
+ }),
+ [globalFullScreen, timelineFullScreen, timelineId]
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const ToolbarAdditionalControls = React.memo(ToolbarAdditionalControlsComponent);
+// eslint-disable-next-line import/no-default-export
+export { ToolbarAdditionalControls as default };
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/translations.ts
new file mode 100644
index 0000000000000..a0c5526d191b7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/translations.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const DRAG_DROP_FIELD = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.dropZoneTableLabel',
+ {
+ defaultMessage: 'Drop zone to add field as a column to the table',
+ }
+);
+
+export const FULL_SCREEN = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.fullScreenButton',
+ {
+ defaultMessage: 'Enter fullscreen',
+ }
+);
+
+export const EXIT_FULL_SCREEN = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.exitFullScreenButton',
+ {
+ defaultMessage: 'Exit fullscreen (esc)',
+ }
+);
+
+export const EVENTS = i18n.translate('xpack.securitySolution.timeline.footer.events', {
+ defaultMessage: 'Events',
+});
+
+export const OF = i18n.translate('xpack.securitySolution.timeline.footer.of', {
+ defaultMessage: 'of',
+});
+
+export const ROWS = i18n.translate('xpack.securitySolution.timeline.footer.rows', {
+ defaultMessage: 'rows',
+});
+
+export const LOADING = i18n.translate('xpack.securitySolution.timeline.footer.loadingLabel', {
+ defaultMessage: 'Loading',
+});
+
+export const TOTAL_COUNT_OF_EVENTS = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.footer.totalCountOfEvents',
+ {
+ defaultMessage: 'events',
+ }
+);
+
+export const AUTO_REFRESH_ACTIVE = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.footer.autoRefreshActiveDescription',
+ {
+ defaultMessage: 'Auto-Refresh Active',
+ }
+);
+
+export const LOADING_EVENTS = i18n.translate(
+ 'xpack.securitySolution.timeline.dataTable.footer.loadingEventsDataLabel',
+ {
+ defaultMessage: 'Loading Events',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx
new file mode 100644
index 0000000000000..a0cf9d6355b9d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/default_headers.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ColumnHeaderOptions, ColumnHeaderType } from '../../../../../common/types';
+import {
+ DEFAULT_COLUMN_MIN_WIDTH,
+ DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH,
+} from '../body/constants';
+
+export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered';
+
+export const defaultUdtHeaders: ColumnHeaderOptions[] = [
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: '@timestamp',
+ initialWidth: DEFAULT_UNIFIED_TABLE_DATE_COLUMN_MIN_WIDTH,
+ esTypes: ['date'],
+ type: 'date',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'message',
+ initialWidth: DEFAULT_COLUMN_MIN_WIDTH * 2,
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'event.category',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'event.action',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'host.name',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'source.ip',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'destination.ip',
+ },
+ {
+ columnHeaderType: defaultColumnHeaderType,
+ id: 'user.name',
+ },
+];
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/get_fields_list_creation_options.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/get_fields_list_creation_options.ts
new file mode 100644
index 0000000000000..bbcf9eb4f4a40
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/get_fields_list_creation_options.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { FieldsGroupNames } from '@kbn/unified-field-list';
+import type { UnifiedFieldListSidebarContainerProps } from '@kbn/unified-field-list';
+
+// This is passed to the unified components container to initialize the field list on the left side of the view
+export const getFieldsListCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] =
+ () => {
+ return {
+ originatingApp: 'security_solution',
+ localStorageKeyPrefix: 'securitySolution',
+ timeRangeUpdatesType: 'timefilter',
+ compressed: true,
+ showSidebarToggleButton: true,
+ disablePopularFields: false,
+ buttonAddFieldToWorkspaceProps: {
+ 'aria-label': i18n.translate(
+ 'xpack.securitySolution.fieldChooser.timelineField.addFieldTooltip',
+ {
+ defaultMessage: 'Add field as column',
+ }
+ ),
+ },
+ buttonRemoveFieldFromWorkspaceProps: {
+ 'aria-label': i18n.translate(
+ 'xpack.securitySolution.timeline.fieldChooser.timelineField.removeFieldTooltip',
+ {
+ defaultMessage: 'Remove field from table',
+ }
+ ),
+ },
+ onOverrideFieldGroupDetails: (groupName) => {
+ if (groupName === FieldsGroupNames.AvailableFields) {
+ return {
+ helpText: i18n.translate(
+ 'xpack.securitySolution.timeline.fieldChooser.availableFieldsTooltip',
+ {
+ defaultMessage: 'Fields available for display in the table.',
+ }
+ ),
+ };
+ }
+ },
+ dataTestSubj: {
+ fieldListAddFieldButtonTestSubj: 'dataView-add-field_btn',
+ fieldListSidebarDataTestSubj: 'timeline-sidebar',
+ fieldListItemStatsDataTestSubj: 'dscFieldStats',
+ fieldListItemDndDataTestSubjPrefix: 'dscFieldListPanelField',
+ fieldListItemPopoverDataTestSubj: 'timelineFieldListPanelPopover',
+ fieldListItemPopoverHeaderDataTestSubjPrefix: 'timelineFieldListPanel',
+ },
+ };
+ };
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx
new file mode 100644
index 0000000000000..2ca35818df744
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.test.tsx
@@ -0,0 +1,688 @@
+/*
+ * 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 { ComponentProps } from 'react';
+import React, { useEffect } from 'react';
+import type QueryTabContent from '.';
+import { UnifiedTimeline } from '.';
+import { TimelineId } from '../../../../../common/types/timeline';
+import { useTimelineEvents } from '../../../containers';
+import { useTimelineEventsDetails } from '../../../containers/details';
+import { useSourcererDataView } from '../../../../common/containers/sourcerer';
+import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks';
+import {
+ createSecuritySolutionStorageMock,
+ mockTimelineData,
+ TestProviders,
+} from '../../../../common/mock';
+import { createMockStore } from '../../../../common/mock/create_store';
+import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
+import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock';
+import type { StartServices } from '../../../../types';
+import { useKibana } from '../../../../common/lib/kibana';
+import { useDispatch } from 'react-redux';
+import { timelineActions } from '../../../store';
+import type { ExperimentalFeatures } from '../../../../../common';
+import { allowedExperimentalValues } from '../../../../../common';
+import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
+import { TimelineTabs } from '@kbn/securitysolution-data-table';
+import { DataLoadingState } from '@kbn/unified-data-table';
+import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
+import { getColumnHeaders } from '../body/column_headers/helpers';
+import { defaultUdtHeaders } from './default_headers';
+import type { ColumnHeaderType } from '../../../../../common/types';
+
+jest.mock('../../../containers', () => ({
+ useTimelineEvents: jest.fn(),
+}));
+
+jest.mock('../../../containers/details');
+
+jest.mock('../../fields_browser', () => ({
+ useFieldBrowserOptions: jest.fn(),
+}));
+
+jest.mock('../body/events', () => ({
+ Events: () => <>>,
+}));
+
+jest.mock('../../../../common/containers/sourcerer');
+jest.mock('../../../../common/containers/sourcerer/use_signal_helpers', () => ({
+ useSignalHelpers: () => ({ signalIndexNeedsInit: false }),
+}));
+
+jest.mock('../../../../common/lib/kuery');
+
+jest.mock('../../../../common/hooks/use_experimental_features');
+
+const useIsExperimentalFeatureEnabledMock = jest.fn((feature: keyof ExperimentalFeatures) => {
+ if (feature === 'unifiedComponentsInTimelineEnabled') {
+ return true;
+ }
+ return allowedExperimentalValues[feature];
+});
+
+jest.mock('../../../../common/lib/kibana');
+
+// unified-field-list is is reporiting multiple analytics events
+jest.mock(`@kbn/analytics-client`);
+
+const columnsToDisplay = [
+ ...defaultUdtHeaders,
+ {
+ columnHeaderType: 'not-filtered' as ColumnHeaderType,
+ id: 'event.severity',
+ },
+];
+
+// These tests can take more than standard timeout of 5s
+// that is why we are setting it to 10s
+const SPECIAL_TEST_TIMEOUT = 10000;
+
+const localMockedTimelineData = structuredClone(mockTimelineData);
+
+const TestComponent = (props: Partial>) => {
+ const testComponentDefaultProps: ComponentProps = {
+ columns: getColumnHeaders(columnsToDisplay, mockSourcererScope.browserFields),
+ activeTab: TimelineTabs.query,
+ rowRenderers: [],
+ timelineId: TimelineId.test,
+ itemsPerPage: 10,
+ itemsPerPageOptions: [],
+ sort: [
+ {
+ columnId: '@timestamp',
+ columnType: 'date',
+ esTypes: ['date'],
+ sortDirection: 'desc',
+ },
+ ],
+ events: localMockedTimelineData,
+ refetch: jest.fn(),
+ totalCount: localMockedTimelineData.length,
+ onEventClosed: jest.fn(),
+ expandedDetail: {},
+ showExpandedDetails: false,
+ onChangePage: jest.fn(),
+ dataLoadingState: DataLoadingState.loaded,
+ updatedAt: Date.now(),
+ isTextBasedQuery: false,
+ };
+
+ const dispatch = useDispatch();
+
+ // populating timeline so that it is not blank
+ useEffect(() => {
+ dispatch(
+ timelineActions.applyKqlFilterQuery({
+ id: TimelineId.test,
+ filterQuery: {
+ kuery: {
+ kind: 'kuery',
+ expression: '*',
+ },
+ serializedQuery: '*',
+ },
+ })
+ );
+ }, [dispatch]);
+
+ return ;
+};
+
+const customStore = createMockStore();
+
+const TestProviderWrapperWithCustomStore: React.FC = ({ children }) => {
+ return {children} ;
+};
+
+const renderTestComponents = (props?: Partial>) => {
+ return render( , {
+ wrapper: TestProviderWrapperWithCustomStore,
+ });
+};
+
+const getTimelineFromStore = (
+ store: ReturnType,
+ timelineId: string = TimelineId.test
+) => {
+ return store.getState().timeline.timelineById[timelineId];
+};
+
+const loadPageMock = jest.fn();
+
+const useTimelineEventsMock = jest.fn(() => [
+ false,
+ {
+ events: localMockedTimelineData,
+ pageInfo: {
+ activePage: 0,
+ totalPages: 10,
+ },
+ refreshedAt: Date.now(),
+ totalCount: 70,
+ loadPage: loadPageMock,
+ },
+]);
+
+const useSourcererDataViewMocked = jest.fn().mockReturnValue({
+ ...mockSourcererScope,
+});
+
+const { storage: storageMock } = createSecuritySolutionStorageMock();
+const mockTimelineFilterManager = createFilterManagerMock();
+
+describe('unified timeline', () => {
+ const kibanaServiceMock: StartServices = {
+ ...createStartServicesMock(),
+ storage: storageMock,
+ timelineFilterManager: mockTimelineFilterManager,
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ storageMock.clear();
+ cleanup();
+ localStorage.clear();
+ });
+
+ beforeEach(() => {
+ const ONE_SECOND = 1000;
+ jest.setTimeout(10 * ONE_SECOND);
+ HTMLElement.prototype.getBoundingClientRect = jest.fn(() => {
+ return {
+ width: 1000,
+ height: 1000,
+ x: 0,
+ y: 0,
+ } as DOMRect;
+ });
+
+ (useKibana as jest.Mock).mockImplementation(() => {
+ return {
+ services: kibanaServiceMock,
+ };
+ });
+
+ (useTimelineEvents as jest.Mock).mockImplementation(useTimelineEventsMock);
+
+ (useTimelineEventsDetails as jest.Mock).mockImplementation(() => [false, {}]);
+
+ (useSourcererDataView as jest.Mock).mockImplementation(useSourcererDataViewMocked);
+
+ (useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(
+ useIsExperimentalFeatureEnabledMock
+ );
+ });
+
+ describe('columns', () => {
+ it(
+ 'should move column left correctly ',
+ async () => {
+ const field = {
+ name: 'message',
+ };
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toHaveAttribute('data-gridcell-column-index', '3');
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Move left')).toBeEnabled();
+ });
+
+ fireEvent.click(screen.getByTitle('Move left'));
+
+ await waitFor(() => {
+ const newColumns = getTimelineFromStore(customStore).columns;
+ expect(newColumns[0].id).toBe('message');
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should move column right correctly ',
+ async () => {
+ const field = {
+ name: 'message',
+ };
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toHaveAttribute('data-gridcell-column-index', '3');
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Move right')).toBeEnabled();
+ });
+
+ fireEvent.click(screen.getByTitle('Move right'));
+
+ await waitFor(() => {
+ const newColumns = getTimelineFromStore(customStore).columns;
+ expect(newColumns[2].id).toBe('message');
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should remove column ',
+ async () => {
+ const field = {
+ name: 'message',
+ };
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ // column is currently present in the state
+ const currentColumns = getTimelineFromStore(customStore).columns;
+ expect(currentColumns).toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: field.name,
+ }),
+ ])
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Remove column')).toBeVisible();
+ });
+
+ fireEvent.click(screen.getByTitle('Remove column'));
+
+ // column should now be removed
+ await waitFor(() => {
+ const newColumns = getTimelineFromStore(customStore).columns;
+ expect(newColumns).not.toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: field.name,
+ }),
+ ])
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort date column',
+ async () => {
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector('[data-gridcell-column-id="@timestamp"]')
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="@timestamp"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Sort Old-New')).toBeVisible();
+ });
+ expect(screen.getByTitle('Sort New-Old')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort Old-New'));
+
+ await waitFor(() => {
+ const newSort = getTimelineFromStore(customStore).sort;
+ expect(newSort).toMatchObject([
+ {
+ columnId: '@timestamp',
+ columnType: 'date',
+ sortDirection: 'asc',
+ },
+ ]);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort string column correctly',
+ async () => {
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector('[data-gridcell-column-id="host.name"]')
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ '[data-gridcell-column-id="host.name"] .euiDataGridHeaderCell__icon'
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('dataGridHeaderCellActionGroup-host.name')).toBeVisible();
+ });
+
+ expect(screen.getByTitle('Sort A-Z')).toBeVisible();
+ expect(screen.getByTitle('Sort Z-A')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort A-Z'));
+
+ await waitFor(() => {
+ const newSort = getTimelineFromStore(customStore).sort;
+ expect(newSort).toMatchObject([
+ {
+ columnId: '@timestamp',
+ columnType: 'date',
+ sortDirection: 'desc',
+ },
+ {
+ sortDirection: 'asc',
+ columnId: 'host.name',
+ columnType: 'string',
+ },
+ ]);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should sort number column',
+ async () => {
+ const field = {
+ name: 'event.severity',
+ type: 'number',
+ };
+
+ const { container } = renderTestComponents();
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`dataGridHeaderCellActionGroup-${field.name}`)).toBeVisible();
+ });
+
+ expect(screen.getByTitle('Sort Low-High')).toBeVisible();
+ expect(screen.getByTitle('Sort High-Low')).toBeVisible();
+
+ useTimelineEventsMock.mockClear();
+
+ fireEvent.click(screen.getByTitle('Sort Low-High'));
+
+ await waitFor(() => {
+ const newSort = getTimelineFromStore(customStore).sort;
+ expect(newSort).toMatchObject([
+ {
+ columnId: '@timestamp',
+ columnType: 'date',
+ sortDirection: 'desc',
+ },
+ {
+ sortDirection: 'asc',
+ columnId: 'event.severity',
+ columnType: 'number',
+ },
+ ]);
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should be able to edit DataView Field',
+ async () => {
+ const field = {
+ name: 'message',
+ };
+ const { container } = renderTestComponents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('discoverDocTable')).toBeVisible();
+ });
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toHaveAttribute('data-gridcell-column-index', '3');
+
+ expect(
+ container.querySelector(`[data-gridcell-column-id="${field.name}"]`)
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ container.querySelector(
+ `[data-gridcell-column-id="${field.name}"] .euiDataGridHeaderCell__icon`
+ ) as HTMLElement
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit data view field')).toBeEnabled();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit data view field'));
+
+ await waitFor(() => {
+ expect(kibanaServiceMock.dataViewFieldEditor.openEditor).toHaveBeenNthCalledWith(1, {
+ ctx: {
+ dataView: expect.objectContaining({
+ id: 'security-solution',
+ }),
+ },
+ fieldName: 'message',
+ onSave: expect.any(Function),
+ });
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+
+ describe('unified field list', () => {
+ it(
+ 'should be able to add filters',
+ async () => {
+ const field = {
+ name: 'event.severity',
+ };
+
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toBeVisible();
+ });
+
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(1);
+
+ const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
+
+ fireEvent.click(within(availableFields).getByTestId(`field-${field.name}-showDetails`));
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`timelineFieldListPanelAddExistFilter-${field.name}`));
+ });
+
+ fireEvent.click(screen.getByTestId(`timelineFieldListPanelAddExistFilter-${field.name}`));
+ await waitFor(() => {
+ expect(mockTimelineFilterManager.addFilters).toHaveBeenNthCalledWith(
+ 1,
+ expect.arrayContaining([
+ expect.objectContaining({
+ query: {
+ exists: {
+ field: field.name,
+ },
+ },
+ meta: expect.objectContaining({
+ negate: false,
+ disabled: false,
+ }),
+ }),
+ ])
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ it(
+ 'should add the column when clicked on + sign',
+ async () => {
+ const fieldToBeAdded = {
+ name: 'agent.id',
+ };
+
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toBeVisible();
+ });
+
+ expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent('9');
+
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${fieldToBeAdded.name}`)).toHaveLength(
+ 0
+ );
+
+ const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
+
+ // / new columns does not exists yet
+ const currentColumns = getTimelineFromStore(customStore).columns;
+ expect(currentColumns).not.toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: fieldToBeAdded.name,
+ }),
+ ])
+ );
+
+ fireEvent.click(within(availableFields).getByTestId(`fieldToggle-${fieldToBeAdded.name}`));
+
+ // new columns should exist now
+ await waitFor(() => {
+ const newColumns = getTimelineFromStore(customStore).columns;
+ expect(newColumns).toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: fieldToBeAdded.name,
+ }),
+ ])
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+
+ it(
+ 'should remove the column when clicked on X sign',
+ async () => {
+ const fieldToBeRemoved = {
+ name: 'event.severity',
+ };
+
+ renderTestComponents();
+ expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toBeVisible();
+ });
+
+ expect(screen.queryAllByTestId(`dataGridHeaderCell-${fieldToBeRemoved.name}`)).toHaveLength(
+ 1
+ );
+
+ const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
+
+ // new columns does exists
+ const currentColumns = getTimelineFromStore(customStore).columns;
+ expect(currentColumns).toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: fieldToBeRemoved.name,
+ }),
+ ])
+ );
+
+ fireEvent.click(
+ within(availableFields).getByTestId(`fieldToggle-${fieldToBeRemoved.name}`)
+ );
+
+ // new columns should not exist now
+ await waitFor(() => {
+ const newColumns = getTimelineFromStore(customStore).columns;
+ expect(newColumns).not.toMatchObject(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: fieldToBeRemoved.name,
+ }),
+ ])
+ );
+ });
+ },
+ SPECIAL_TEST_TIMEOUT
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx
new file mode 100644
index 0000000000000..1c4a923e7efcc
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/index.tsx
@@ -0,0 +1,411 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { EuiFlexGroup, EuiFlexItem, EuiHideFor } from '@elastic/eui';
+import React, { useMemo, useCallback, useState, useRef } from 'react';
+import { useDispatch } from 'react-redux';
+
+import { generateFilters } from '@kbn/data-plugin/public';
+import type { DataView, DataViewField } from '@kbn/data-plugin/common';
+import type { SortOrder } from '@kbn/saved-search-plugin/public';
+import type { DataLoadingState } from '@kbn/unified-data-table';
+import { useColumns } from '@kbn/unified-data-table';
+import { popularizeField } from '@kbn/unified-data-table/src/utils/popularize_field';
+import type { DropType } from '@kbn/dom-drag-drop';
+import styled from 'styled-components';
+import { Droppable, DropOverlayWrapper, useDragDropContext } from '@kbn/dom-drag-drop';
+import type {
+ UnifiedFieldListSidebarContainerApi,
+ UnifiedFieldListSidebarContainerProps,
+} from '@kbn/unified-field-list';
+import { UnifiedFieldListSidebarContainer } from '@kbn/unified-field-list';
+import type { EuiTheme } from '@kbn/react-kibana-context-styled';
+import type { CoreStart } from '@kbn/core-lifecycle-browser';
+import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
+import { withDataView } from '../../../../common/components/with_data_view';
+import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
+import type { ExpandedDetailTimeline } from '../../../../../common/types';
+import type { TimelineItem } from '../../../../../common/search_strategy';
+import { useKibana } from '../../../../common/lib/kibana';
+import { defaultHeaders } from '../body/column_headers/default_headers';
+import type {
+ ColumnHeaderOptions,
+ OnChangePage,
+ RowRenderer,
+ SortColumnTimeline,
+ ToggleDetailPanel,
+ TimelineTabs,
+} from '../../../../../common/types/timeline';
+import type { inputsModel } from '../../../../common/store';
+import { getColumnHeader } from '../body/column_headers/helpers';
+import { StyledPageContentWrapper, StyledMainEuiPanel, StyledSplitFlexItem } from './styles';
+import { DRAG_DROP_FIELD } from './data_table/translations';
+import { TimelineResizableLayout } from './resizable_layout';
+import TimelineDataTable from './data_table';
+import { timelineActions } from '../../../store';
+import { getFieldsListCreationOptions } from './get_fields_list_creation_options';
+import { defaultUdtHeaders } from './default_headers';
+
+const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({
+ className: `${className}`,
+}))`
+ width: 100%;
+ height: 100%;
+`;
+
+const DataGridMemoized = React.memo(TimelineDataTable);
+
+const DROP_PROPS = {
+ value: {
+ id: 'dscDropZoneTable',
+ humanData: {
+ label: DRAG_DROP_FIELD,
+ },
+ },
+ order: [1, 0, 0, 0],
+ types: ['field_add'] as DropType[],
+};
+
+const SidebarPanelFlexGroup = styled(EuiFlexGroup)`
+ height: 100%;
+
+ .unifiedFieldListSidebar {
+ padding-bottom: ${(props) => (props.theme as EuiTheme).eui.euiSizeS};
+ padding-left: 0px;
+ border-top: 1px solid ${(props) => (props.theme as EuiTheme).eui.euiColorLightShade};
+
+ .unifiedFieldListSidebar__group {
+ .euiFlexItem:last-child {
+ /* padding-right: ${(props) => (props.theme as EuiTheme).eui.euiSizeS}; */
+ }
+ .unifiedFieldListSidebar__list {
+ padding-left: 0px;
+ }
+
+ .unifiedFieldListSidebar__addBtn {
+ margin-right: ${(props) => (props.theme as EuiTheme).eui.euiSizeS};
+ }
+ }
+ }
+`;
+
+export const SAMPLE_SIZE_SETTING = 500;
+export const HIDE_FOR_SIZES = ['xs', 's'];
+
+interface Props {
+ columns: ColumnHeaderOptions[];
+ rowRenderers: RowRenderer[];
+ timelineId: string;
+ itemsPerPage: number;
+ itemsPerPageOptions: number[];
+ sort: SortColumnTimeline[];
+ events: TimelineItem[];
+ refetch: inputsModel.Refetch;
+ totalCount: number;
+ onEventClosed: (args: ToggleDetailPanel) => void;
+ expandedDetail: ExpandedDetailTimeline;
+ showExpandedDetails: boolean;
+ onChangePage: OnChangePage;
+ activeTab: TimelineTabs;
+ dataLoadingState: DataLoadingState;
+ updatedAt: number;
+ isTextBasedQuery?: boolean;
+ dataView: DataView;
+}
+
+const UnifiedTimelineComponent: React.FC = ({
+ columns,
+ activeTab,
+ timelineId,
+ itemsPerPage,
+ itemsPerPageOptions,
+ rowRenderers,
+ sort,
+ events,
+ refetch,
+ dataLoadingState,
+ totalCount,
+ onEventClosed,
+ showExpandedDetails,
+ expandedDetail,
+ onChangePage,
+ updatedAt,
+ isTextBasedQuery,
+ dataView,
+}) => {
+ const dispatch = useDispatch();
+ const unifiedFieldListContainerRef = useRef(null);
+
+ const {
+ services: {
+ uiSettings,
+ fieldFormats,
+ dataViews,
+ dataViewFieldEditor,
+ application: { capabilities },
+ data: dataPluginContract,
+ uiActions,
+ charts,
+ docLinks,
+ analytics,
+ timelineFilterManager,
+ },
+ } = useKibana();
+
+ const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
+ () => ({
+ fieldFormats,
+ dataViews,
+ dataViewFieldEditor,
+ data: dataPluginContract,
+ uiActions,
+ charts,
+ core: {
+ analytics,
+ uiSettings,
+ docLinks,
+ } as CoreStart,
+ }),
+ [
+ fieldFormats,
+ dataViews,
+ dataViewFieldEditor,
+ dataPluginContract,
+ uiActions,
+ charts,
+ uiSettings,
+ docLinks,
+ analytics,
+ ]
+ );
+
+ const [sidebarContainer, setSidebarContainer] = useState(null);
+ const [, setMainContainer] = useState(null);
+
+ const columnIds = useMemo(() => {
+ return columns.map((c) => c.id);
+ }, [columns]);
+
+ const sortingColumns = useMemo(() => {
+ return (
+ (sort?.map((sortingCol) => [
+ sortingCol.columnId,
+ sortingCol.sortDirection as 'asc' | 'desc',
+ ]) as SortOrder[]) || []
+ );
+ }, [sort]);
+
+ const onSort = useCallback(
+ (nextSort: string[][]) => {
+ dispatch(
+ timelineActions.updateSort({
+ id: timelineId,
+ sort: nextSort.map(([id, direction]) => {
+ const currentColumn = columns.find((column) => column.id === id);
+ const columnType = currentColumn ? currentColumn.type : 'keyword';
+ return {
+ columnId: id,
+ columnType,
+ sortDirection: direction,
+ } as SortColumnTimeline;
+ }),
+ })
+ );
+ },
+ [dispatch, timelineId, columns]
+ );
+
+ const setAppState = useCallback(
+ (newState: { columns: string[]; sort?: string[][] }) => {
+ if (newState.sort) {
+ onSort(newState.sort);
+ } else {
+ const columnsStates = newState.columns.map((columnId) =>
+ getColumnHeader(columnId, defaultUdtHeaders)
+ );
+ dispatch(timelineActions.updateColumns({ id: timelineId, columns: columnsStates }));
+ }
+ },
+ [dispatch, onSort, timelineId]
+ );
+
+ const { onAddColumn, onRemoveColumn, onSetColumns } = useColumns({
+ capabilities,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ dataView: dataView!,
+ dataViews,
+ setAppState,
+ useNewFieldsApi: true,
+ columns: columnIds,
+ sort: sortingColumns,
+ });
+
+ const onAddFilter = useCallback(
+ (field: DataViewField | string, values: unknown, operation: '+' | '-') => {
+ if (dataView && timelineFilterManager) {
+ const fieldName = typeof field === 'string' ? field : field.name;
+ popularizeField(dataView, fieldName, dataViews, capabilities);
+ const newFilters = generateFilters(
+ timelineFilterManager,
+ field,
+ values,
+ operation,
+ dataView
+ );
+ return timelineFilterManager.addFilters(newFilters);
+ }
+ },
+ [timelineFilterManager, dataView, dataViews, capabilities]
+ );
+
+ const [{ dragging }] = useDragDropContext();
+ const draggingFieldName = dragging?.id;
+
+ const onToggleColumn = useCallback(
+ (columnId: string) => {
+ dispatch(
+ timelineActions.upsertColumn({
+ column: getColumnHeader(columnId, defaultHeaders),
+ id: timelineId,
+ index: 1,
+ })
+ );
+ },
+ [dispatch, timelineId]
+ );
+
+ const isDropAllowed = useMemo(() => {
+ if (!draggingFieldName || columnIds.includes(draggingFieldName)) {
+ return false;
+ }
+ return true;
+ }, [draggingFieldName, columnIds]);
+
+ const onDropFieldToTable = useCallback(() => {
+ if (draggingFieldName) {
+ onAddColumn(draggingFieldName);
+ onToggleColumn(draggingFieldName);
+ }
+ }, [draggingFieldName, onAddColumn, onToggleColumn]);
+
+ const onAddFieldToWorkspace = useCallback(
+ (field: DataViewField) => {
+ onAddColumn(field.name);
+ onToggleColumn(field.name);
+ },
+ [onAddColumn, onToggleColumn]
+ );
+
+ const onRemoveFieldFromWorkspace = useCallback(
+ (field: DataViewField) => {
+ if (columns.some(({ id }) => id === field.name)) {
+ dispatch(
+ timelineActions.removeColumn({
+ columnId: field.name,
+ id: timelineId,
+ })
+ );
+ }
+ onRemoveColumn(field.name);
+ },
+ [columns, dispatch, onRemoveColumn, timelineId]
+ );
+
+ const onFieldEdited = useCallback(() => {
+ refetch();
+ }, [refetch]);
+
+ const wrappedOnFieldEdited = useCallback(async () => {
+ onFieldEdited();
+ }, [onFieldEdited]);
+
+ return (
+
+
+
+ {dataView ? (
+
+ ) : null}
+
+
+
+
+
+ }
+ mainPanel={
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+};
+
+export const UnifiedTimeline = React.memo(withDataView(UnifiedTimelineComponent));
+// eslint-disable-next-line import/no-default-export
+export { UnifiedTimeline as default };
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.test.tsx
new file mode 100644
index 0000000000000..106be16c82329
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.test.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import TimelineResizableLayout from './resizable_layout';
+
+const TestSideBarPanel = {'Sidebar Panel'}
;
+
+const MainPanel = {'Main Panel'}
;
+
+const container = document.createElement('div');
+container.style.width = '1000px';
+container.style.height = '1000px';
+container.getBoundingClientRect = jest.fn(() => {
+ return {
+ width: 1000,
+ height: 1000,
+ x: 0,
+ y: 0,
+ } as DOMRect;
+});
+
+const TestComponent = () => {
+ return (
+
+ );
+};
+
+describe('ResizableLayout', () => {
+ it('should render without any issues', async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sidebar__panel')).toBeVisible();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.tsx
new file mode 100644
index 0000000000000..2bcb14a7df805
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/resizable_layout.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 { useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui';
+import {
+ ResizableLayout,
+ ResizableLayoutDirection,
+ ResizableLayoutMode,
+} from '@kbn/resizable-layout';
+import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list';
+import type { ReactNode } from 'react';
+import React, { useState } from 'react';
+import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
+import useLocalStorage from 'react-use/lib/useLocalStorage';
+import useObservable from 'react-use/lib/useObservable';
+import { of } from 'rxjs';
+
+export const SIDEBAR_WIDTH_KEY = 'timeline:sidebarWidth';
+
+// TODO: This is almost a duplicate of the logic here: src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx
+// Should this layout be a shared package or just an accepted dupe since the is already shared?
+
+export const TimelineResizableLayoutComponent = ({
+ container,
+ sidebarPanel,
+ mainPanel,
+ unifiedFieldListSidebarContainerApi,
+}: {
+ container: HTMLElement | null;
+ sidebarPanel: ReactNode;
+ mainPanel: ReactNode;
+ unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null;
+}) => {
+ const [sidebarPanelNode] = useState(() =>
+ createHtmlPortalNode({ attributes: { class: 'eui-fullHeight sidebarPanel' } })
+ );
+ const [mainPanelNode] = useState(() =>
+ createHtmlPortalNode({ attributes: { class: 'eui-fullHeight mainPanel' } })
+ );
+
+ const { euiTheme } = useEuiTheme();
+ const minSidebarWidth = euiTheme.base * 13;
+ const defaultSidebarWidth = euiTheme.base * 19;
+ const minMainPanelWidth = euiTheme.base * 30;
+
+ const [sidebarWidth, setSidebarWidth] = useLocalStorage(SIDEBAR_WIDTH_KEY, defaultSidebarWidth);
+
+ const isMobile = useIsWithinBreakpoints(['xs', 's']);
+
+ const isSidebarCollapsed = useObservable(
+ unifiedFieldListSidebarContainerApi?.sidebarVisibility.isCollapsed$ ?? of(true),
+ true
+ );
+ const layoutMode =
+ isMobile || isSidebarCollapsed ? ResizableLayoutMode.Static : ResizableLayoutMode.Resizable;
+ const layoutDirection = isMobile
+ ? ResizableLayoutDirection.Vertical
+ : ResizableLayoutDirection.Horizontal;
+
+ return (
+ <>
+ {sidebarPanel}
+ {mainPanel}
+ }
+ flexPanel={ }
+ resizeButtonClassName="timelineSidebarResizeButton"
+ data-test-subj="timelineUnifiedComponentsLayout"
+ onFixedPanelSizeChange={setSidebarWidth}
+ />
+ >
+ );
+};
+
+export const TimelineResizableLayout = React.memo(TimelineResizableLayoutComponent);
+// eslint-disable-next-line import/no-default-export
+export { TimelineResizableLayout as default };
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx
new file mode 100644
index 0000000000000..279284bf004a4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/styles.tsx
@@ -0,0 +1,148 @@
+/*
+ * 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 styled from 'styled-components';
+import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiProgress } from '@elastic/eui';
+import { euiThemeVars } from '@kbn/ui-theme';
+
+export const StyledTableFlexGroup = styled(EuiFlexGroup).attrs(({ className = '' }) => ({
+ className: `${className}`,
+}))`
+ margin: 0;
+ width: 100%;
+ overflow: hidden;
+
+ .dscPageBody__contents {
+ overflow: hidden;
+ height: 100%;
+ }
+`;
+
+export const StyledTableFlexItem = styled(EuiFlexItem).attrs(({ className = '' }) => ({
+ className: `${className}`,
+}))`
+ ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`}
+ overflow: hidden;
+`;
+
+export const StyledSplitFlexItem = styled(EuiFlexItem).attrs(({ className = '' }) => ({
+ className: `${className}`,
+}))`
+ border-right: ${euiThemeVars.euiBorderThin};
+`;
+
+export const StyledEuiProgress = styled(EuiProgress)`
+ z-index: 2;
+`;
+
+export const StyledPageContentWrapper = styled.div.attrs(({ className = '' }) => ({
+ className: `${className}`,
+}))`
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+`;
+
+export const StyledMainEuiPanel = styled(EuiPanel).attrs(({ className = '' }) => ({
+ className: `udtPageContent__wrapper ${className}`,
+}))`
+ overflow: hidden; // Ensures horizontal scroll of table
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`;
+
+export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = '' }) => ({
+ className: `unifiedDataTable ${className}`,
+ role: 'rowgroup',
+}))`
+ .udtTimeline [data-gridcell-column-id|='select'] {
+ border-right: none;
+ }
+ .udtTimeline [data-gridcell-column-id|='openDetails'] .euiDataGridRowCell__contentByHeight {
+ margin-top: 3px;
+ }
+
+ .udtTimeline .euiDataGridRowCell--controlColumn {
+ overflow: visible;
+ }
+
+ .udtTimeline [data-gridcell-column-id|='select'] .euiDataGridRowCell__contentByHeight {
+ margin-top: 5px;
+ }
+
+ .udtTimeline
+ .euiDataGridRow:hover
+ .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn {
+ ${({ theme }) => `background-color: ${theme.eui.colorLightShade};`};
+ }
+
+ .udtTimeline .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn {
+ ${({ theme }) => `background-color: ${theme.eui.emptyShade};`};
+ }
+
+ .udtTimeline .siemEventsTable__trSupplement--summary {
+ border-radius: 8px;
+ }
+
+ .udtTimeline .euiDataGridRow:has(.buildingBlockType) {
+ background: repeating-linear-gradient(
+ 127deg,
+ rgba(245, 167, 0, 0.2),
+ rgba(245, 167, 0, 0.2) 1px,
+ rgba(245, 167, 0, 0.05) 2px,
+ rgba(245, 167, 0, 0.05) 10px
+ );
+ }
+ .udtTimeline .euiDataGridRow:has(.eqlSequence) {
+ .euiDataGridRowCell--firstColumn {
+ ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorPrimary};`}
+ }
+ background: repeating-linear-gradient(
+ 127deg,
+ rgba(0, 107, 180, 0.2),
+ rgba(0, 107, 180, 0.2) 1px,
+ rgba(0, 107, 180, 0.05) 2px,
+ rgba(0, 107, 180, 0.05) 10px
+ );
+ }
+ .udtTimeline .euiDataGridRow:has(.eqlNonSequence) {
+ .euiDataGridRowCell--firstColumn {
+ ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorAccent};`}
+ }
+ background: repeating-linear-gradient(
+ 127deg,
+ rgba(221, 10, 115, 0.2),
+ rgba(221, 10, 115, 0.2) 1px,
+ rgba(221, 10, 115, 0.05) 2px,
+ rgba(221, 10, 115, 0.05) 10px
+ );
+ }
+ .udtTimeline .euiDataGridRow:has(.nonRawEvent) .euiDataGridRowCell--firstColumn {
+ ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorWarning};`}
+ }
+ .udtTimeline .euiDataGridRow:has(.rawEvent) .euiDataGridRowCell--firstColumn {
+ ${({ theme }) => `border-left: 4px solid ${theme.eui.euiColorLightShade};`}
+ }
+
+ .udtTimeline .rowCellWrapper {
+ display: flex;
+ width: fit-content;
+ }
+
+ .udtTimeline .rightPosition {
+ position: absolute;
+ right: 5px;
+ button {
+ ${({ theme }) => `color: ${theme.eui.euiColorDarkShade};`}
+ }
+ }
+
+ .udtTimeline .euiDataGrid__rightControls {
+ padding-right: 30px;
+ }
+`;
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.test.ts
new file mode 100644
index 0000000000000..e0a7007c3a16d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { DataView } from '@kbn/data-views-plugin/common';
+import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
+
+import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks';
+import { mockTimelineData } from '../../../../common/mock';
+import { transformTimelineItemToUnifiedRows } from './utils';
+
+const testTimelineData = mockTimelineData;
+
+describe('utils', () => {
+ describe('transformTimelineItemToUnifiedRows', () => {
+ it('should return correct result', () => {
+ const result = transformTimelineItemToUnifiedRows({
+ events: testTimelineData,
+ dataView: new DataView({
+ spec: mockSourcererScope.sourcererDataView,
+ fieldFormats: fieldFormatsMock,
+ }),
+ });
+
+ expect(result[0]).toEqual({
+ _id: testTimelineData[0]._id,
+ id: testTimelineData[0]._id,
+ data: testTimelineData[0].data,
+ ecs: testTimelineData[0].ecs,
+ raw: {
+ _id: testTimelineData[0]._id,
+ _index: String(testTimelineData[0]._index),
+ _source: testTimelineData[0].ecs,
+ },
+ flattened: {
+ _id: testTimelineData[0]._id,
+ timestamp: '2018-11-05T19:03:25.937Z',
+ 'host.name': ['apache'],
+ 'host.ip': ['192.168.0.1'],
+ 'event.id': ['1'],
+ 'event.action': ['Action'],
+ 'event.category': ['Access'],
+ 'event.module': ['nginx'],
+ 'event.severity': [3],
+ 'source.ip': ['192.168.0.1'],
+ 'source.port': [80],
+ 'destination.ip': ['192.168.0.3'],
+ 'destination.port': [6343],
+ 'user.id': ['1'],
+ 'user.name': ['john.dee'],
+ 'geo.region_name': ['xx'],
+ 'geo.country_iso_code': ['xx'],
+ },
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.ts
new file mode 100644
index 0000000000000..20b769455d3ea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/utils.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { flattenHit } from '@kbn/data-service';
+import type { DataView } from '@kbn/data-views-plugin/public';
+import type { DataTableRecord } from '@kbn/discover-utils/types';
+import type { TimelineItem } from '../../../../../common/search_strategy';
+
+interface TransformTimelineItemToUnifiedRows {
+ events: TimelineItem[];
+ dataView: DataView;
+}
+
+export function transformTimelineItemToUnifiedRows(
+ args: TransformTimelineItemToUnifiedRows
+): Array {
+ const { events, dataView } = args;
+ const unifiedDataTableRows = events.map(({ _id, _index, ecs, data }) => {
+ const _source = ecs as unknown as Record;
+ const hit = { _id, _index: String(_index), _source };
+ /*
+ * Ideally for unified data table we only need raw and flattened keys
+ * but we use this transformed data within other parts of security solution
+ * so we create a combined data format for timeline item and DataTableRecord
+ *
+ * */
+ return {
+ _id,
+ id: _id,
+ data,
+ ecs,
+ raw: hit,
+ flattened: flattenHit(hit, dataView, {
+ includeIgnoredValues: true,
+ }),
+ };
+ });
+
+ return unifiedDataTableRows;
+}
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts
index 350bedb40b196..383825da33038 100644
--- a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts
+++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts
@@ -9,6 +9,8 @@ import type {
TimelineEventsAllOptionsInput,
TimelineEqlRequestOptionsInput,
} from '@kbn/timelines-plugin/common';
+import type { ExpandedDetailTimeline, ExpandedDetailType } from '../../../common/types';
+import { TimelineTabs } from '../../../common/types/timeline';
import type { TimelineArgs } from '.';
/*
@@ -24,6 +26,7 @@ import type { TimelineArgs } from '.';
class ActiveTimelineEvents {
private _activePage: number = 0;
+ private _expandedDetail: ExpandedDetailTimeline = {};
private _pageName: string = '';
private _request: TimelineEventsAllOptionsInput | null = null;
private _response: TimelineArgs | null = null;
@@ -38,6 +41,42 @@ class ActiveTimelineEvents {
this._activePage = activePage;
}
+ getExpandedDetail() {
+ return this._expandedDetail;
+ }
+
+ toggleExpandedDetail(expandedDetail: ExpandedDetailType) {
+ const queryTab = TimelineTabs.query;
+ const currentExpandedDetail = this._expandedDetail[queryTab];
+ let isSameExpandedDetail;
+
+ // Check if the stored details matches the incoming detail
+ if (currentExpandedDetail?.panelView === 'eventDetail') {
+ isSameExpandedDetail =
+ expandedDetail?.panelView === 'eventDetail' &&
+ expandedDetail?.params?.eventId === currentExpandedDetail?.params?.eventId;
+ } else if (currentExpandedDetail?.panelView === 'hostDetail') {
+ isSameExpandedDetail =
+ expandedDetail?.panelView === 'hostDetail' &&
+ expandedDetail?.params?.hostName === currentExpandedDetail?.params?.hostName;
+ } else if (currentExpandedDetail?.panelView === 'networkDetail') {
+ isSameExpandedDetail =
+ expandedDetail?.panelView === 'networkDetail' &&
+ expandedDetail?.params?.ip === currentExpandedDetail?.params?.ip;
+ }
+
+ // if so, unset it, otherwise set it
+ if (isSameExpandedDetail) {
+ this._expandedDetail = {};
+ } else {
+ this._expandedDetail = { [queryTab]: { ...expandedDetail } };
+ }
+ }
+
+ setExpandedDetail(expandedDetail: ExpandedDetailTimeline) {
+ this._expandedDetail = expandedDetail;
+ }
+
getPageName() {
return this._pageName;
}
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx
index 13189f4ed77e6..e2ec3d2117725 100644
--- a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx
@@ -5,6 +5,7 @@
* 2.0.
*/
+import { DataLoadingState } from '@kbn/unified-data-table';
import { renderHook, act } from '@testing-library/react-hooks';
import type { TimelineArgs, UseTimelineEventsProps } from '.';
import { initSortDefault, useTimelineEvents } from '.';
@@ -125,7 +126,7 @@ describe('useTimelineEvents', () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineEventsProps,
- [boolean, TimelineArgs]
+ [DataLoadingState, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
@@ -133,7 +134,7 @@ describe('useTimelineEvents', () => {
// useEffect on params request
await waitForNextUpdate();
expect(result.current).toEqual([
- false,
+ DataLoadingState.loaded,
{
events: [],
id: TimelineId.active,
@@ -152,7 +153,7 @@ describe('useTimelineEvents', () => {
await act(async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
UseTimelineEventsProps,
- [boolean, TimelineArgs]
+ [DataLoadingState, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
@@ -165,7 +166,7 @@ describe('useTimelineEvents', () => {
expect(mockSearch).toHaveBeenCalledTimes(2);
expect(result.current).toEqual([
- false,
+ DataLoadingState.loaded,
{
events: mockEvents,
id: TimelineId.active,
@@ -184,7 +185,7 @@ describe('useTimelineEvents', () => {
await act(async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
UseTimelineEventsProps,
- [boolean, TimelineArgs]
+ [DataLoadingState, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
@@ -208,7 +209,7 @@ describe('useTimelineEvents', () => {
expect(mockSearch).toHaveBeenCalledTimes(2);
expect(result.current).toEqual([
- false,
+ DataLoadingState.loaded,
{
events: mockEvents,
id: TimelineId.active,
@@ -227,7 +228,7 @@ describe('useTimelineEvents', () => {
await act(async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
UseTimelineEventsProps,
- [boolean, TimelineArgs]
+ [DataLoadingState, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: {
...props,
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx
index 19e8e7b92b870..80ffd7ea30836 100644
--- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx
@@ -13,6 +13,7 @@ import { Subscription } from 'rxjs';
import type { DataView } from '@kbn/data-plugin/common';
import { isRunningResponse } from '@kbn/data-plugin/common';
+import { DataLoadingState } from '@kbn/unified-data-table';
import type {
TimelineEqlRequestOptionsInput,
TimelineEventsAllOptionsInput,
@@ -147,14 +148,14 @@ export const useTimelineEventsHandler = ({
sort = initSortDefault,
skip = false,
timerangeKind,
-}: UseTimelineEventsProps): [boolean, TimelineArgs, TimelineEventsSearchHandler] => {
+}: UseTimelineEventsProps): [DataLoadingState, TimelineArgs, TimelineEventsSearchHandler] => {
const [{ pageName }] = useRouteSpy();
const dispatch = useDispatch();
const { data } = useKibana().services;
const refetch = useRef(noop);
const abortCtrl = useRef(new AbortController());
const searchSubscription$ = useRef(new Subscription());
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(DataLoadingState.loaded);
const [activePage, setActivePage] = useState(
id === TimelineId.active ? activeTimeline.getActivePage() : 0
);
@@ -219,7 +220,11 @@ export const useTimelineEventsHandler = ({
const asyncSearch = async () => {
prevTimelineRequest.current = request;
abortCtrl.current = new AbortController();
- setLoading(true);
+ if (activePage === 0) {
+ setLoading(DataLoadingState.loading);
+ } else {
+ setLoading(DataLoadingState.loadingMore);
+ }
const { endTracking } = startTracking({ name: `${APP_UI_ID} timeline events search` });
searchSubscription$.current = data.search
.search, TimelineResponse>(request, {
@@ -233,7 +238,7 @@ export const useTimelineEventsHandler = ({
next: (response) => {
if (!isRunningResponse(response)) {
endTracking('success');
- setLoading(false);
+ setLoading(DataLoadingState.loaded);
setTimelineResponse((prevResponse) => {
const newTimelineResponse = {
...prevResponse,
@@ -262,7 +267,7 @@ export const useTimelineEventsHandler = ({
},
error: (msg) => {
endTracking(abortCtrl.current.signal.aborted ? 'aborted' : 'error');
- setLoading(false);
+ setLoading(DataLoadingState.loaded);
data.search.showError(msg);
searchSubscription$.current.unsubscribe();
},
@@ -276,7 +281,7 @@ export const useTimelineEventsHandler = ({
) {
activeTimeline.setPageName(pageName);
abortCtrl.current.abort();
- setLoading(false);
+ setLoading(DataLoadingState.loaded);
if (request.language === 'eql') {
prevTimelineRequest.current = activeTimeline.getEqlRequest();
@@ -312,7 +317,17 @@ export const useTimelineEventsHandler = ({
await asyncSearch();
refetch.current = asyncSearch;
},
- [pageName, skip, id, startTracking, data.search, dataViewId, refetchGrid, wrappedLoadPage]
+ [
+ pageName,
+ skip,
+ id,
+ activePage,
+ startTracking,
+ data.search,
+ dataViewId,
+ refetchGrid,
+ wrappedLoadPage,
+ ]
);
useEffect(() => {
@@ -450,8 +465,8 @@ export const useTimelineEvents = ({
sort = initSortDefault,
skip = false,
timerangeKind,
-}: UseTimelineEventsProps): [boolean, TimelineArgs] => {
- const [loading, timelineResponse, timelineSearchHandler] = useTimelineEventsHandler({
+}: UseTimelineEventsProps): [DataLoadingState, TimelineArgs] => {
+ const [dataLoadingState, timelineResponse, timelineSearchHandler] = useTimelineEventsHandler({
dataViewId,
endDate,
eqlOptions,
@@ -473,5 +488,5 @@ export const useTimelineEvents = ({
timelineSearchHandler();
}, [timelineSearchHandler]);
- return [loading, timelineResponse];
+ return [dataLoadingState, timelineResponse];
};
diff --git a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx
index a40c16495ef6c..7a5f0a0adf154 100644
--- a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.test.tsx
@@ -33,6 +33,8 @@ jest.mock('../../common/containers/use_global_time', () => {
});
jest.mock('../../common/lib/kibana');
+jest.mock('../../common/hooks/use_experimental_features');
+
describe('useCreateTimeline', () => {
const resetDiscoverAppState = jest.fn().mockResolvedValue({});
(useDiscoverInTimelineContext as jest.Mock).mockReturnValue({ resetDiscoverAppState });
diff --git a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx
index 78f1e54f3b2cd..199549ef103ea 100644
--- a/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx
@@ -20,6 +20,8 @@ import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { appActions } from '../../common/store/app';
import type { TimeRange } from '../../common/store/inputs/model';
import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context';
+import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
+import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers';
export interface UseCreateTimelineParams {
/**
@@ -47,6 +49,9 @@ export const useCreateTimeline = ({
onClick,
}: UseCreateTimelineParams): ((options?: { timeRange?: TimeRange }) => Promise) => {
const dispatch = useDispatch();
+ const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
+ 'unifiedComponentsInTimelineEnabled'
+ );
const { id: dataViewId, patternList: selectedPatterns } = useSelector(
sourcererSelectors.defaultDataView
) ?? { id: '', patternList: [] };
@@ -72,7 +77,7 @@ export const useCreateTimeline = ({
);
dispatch(
timelineActions.createTimeline({
- columns: defaultHeaders,
+ columns: unifiedComponentsInTimelineEnabled ? defaultUdtHeaders : defaultHeaders,
dataViewId,
id,
indexNames: selectedPatterns,
@@ -113,6 +118,7 @@ export const useCreateTimeline = ({
setTimelineFullScreen,
timelineFullScreen,
timelineType,
+ unifiedComponentsInTimelineEnabled,
]
);
diff --git a/x-pack/plugins/security_solution/public/timelines/store/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/actions.ts
index 38279428b495a..16bcdd748bce2 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/actions.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/actions.ts
@@ -290,6 +290,22 @@ export const setDataProviderVisibility = actionCreator<{
export const setChanged = actionCreator<{ id: string; changed: boolean }>('SET_CHANGED');
+export const updateColumnWidth = actionCreator<{
+ columnId: string;
+ id: string;
+ width: number;
+}>('UPDATE_COLUMN_WIDTH');
+
+export const updateRowHeight = actionCreator<{
+ id: string;
+ rowHeight: number;
+}>('UPDATE_ROW_HEIGHT');
+
+export const updateSampleSize = actionCreator<{
+ id: string;
+ sampleSize: number;
+}>('UPDATE_SAMPLE_SIZE');
+
export const setConfirmingNoteId = actionCreator<{
id: string;
confirmingNoteId: string | null | undefined;
diff --git a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts
index 30f673e3645cd..55155a30dbbbf 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/defaults.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/defaults.ts
@@ -16,7 +16,7 @@ import type { SubsetTimelineModel, TimelineModel } from './model';
const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false);
export const timelineDefaults: SubsetTimelineModel &
- Pick = {
+ Pick = {
activeTab: TimelineTabs.query,
prevActiveTab: TimelineTabs.query,
columns: defaultHeaders,
@@ -81,6 +81,8 @@ export const timelineDefaults: SubsetTimelineModel &
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: false,
+ sampleSize: 500,
+ rowHeight: 3,
};
export const getTimelineManageDefaults = (id: string) => ({
diff --git a/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts
index 827f94c6252e6..7ff9cb00a47e0 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/helpers.test.ts
@@ -40,11 +40,13 @@ import {
updateTimelineTitleAndDescription,
upsertTimelineColumn,
updateTimelineGraphEventId,
+ updateTimelineColumnWidth,
} from './helpers';
import type { TimelineModel } from './model';
import { timelineDefaults } from './defaults';
import type { TimelineById } from './types';
import { Direction } from '../../../common/search_strategy';
+import { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers';
jest.mock('../../common/utils/normalize_time_range');
jest.mock('../../common/utils/default_date_settings', () => {
@@ -131,6 +133,7 @@ const basicTimeline: TimelineModel = {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: true,
+ sampleSize: 500,
};
const timelineByIdMock: TimelineById = {
foo: { ...basicTimeline },
@@ -1833,4 +1836,34 @@ describe('Timeline', () => {
expect(update[TimelineId.active].prevActiveTab).toEqual(TimelineTabs.graph);
});
});
+
+ describe('#updateTimelineColumnWidth', () => {
+ let mockTimelineById: TimelineById;
+ beforeEach(() => {
+ mockTimelineById = structuredClone(timelineByIdMock);
+ mockTimelineById.foo.columns = structuredClone(defaultUdtHeaders);
+ });
+
+ it('should update column width correctly when correct column is supplied', () => {
+ const result = updateTimelineColumnWidth({
+ columnId: '@timestamp',
+ id: 'foo',
+ timelineById: mockTimelineById,
+ width: 500,
+ });
+
+ expect(result.foo.columns[0]).toHaveProperty('initialWidth', 500);
+ });
+
+ it('should be no-op when incorrect column is supplied', () => {
+ const result = updateTimelineColumnWidth({
+ columnId: 'invalid-column',
+ id: 'foo',
+ timelineById: mockTimelineById,
+ width: 500,
+ });
+
+ expect(result.foo.columns).toEqual(defaultUdtHeaders);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts
index 3a7e4ed83cb9f..86a03e3b23874 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts
@@ -1531,3 +1531,30 @@ export const applyDeltaToTableColumnWidth = ({
},
};
};
+
+export const updateTimelineColumnWidth = ({
+ columnId,
+ id,
+ timelineById,
+ width,
+}: {
+ columnId: string;
+ id: string;
+ timelineById: TimelineById;
+ width: number;
+}): TimelineById => {
+ const timeline = timelineById[id];
+
+ const columns = timeline.columns.map((x) => ({
+ ...x,
+ initialWidth: x.id === columnId ? width : x.initialWidth,
+ }));
+
+ return {
+ ...timelineById,
+ [id]: {
+ ...timeline,
+ columns,
+ },
+ };
+};
diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts
index 3e57a7b9f28bd..dfb88d47d7c17 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts
@@ -338,6 +338,7 @@ describe('Timeline save middleware', () => {
savedSearchId: null,
savedSearch: null,
isDataProviderVisible: true,
+ sampleSize: 500,
};
expect(
diff --git a/x-pack/plugins/security_solution/public/timelines/store/model.ts b/x-pack/plugins/security_solution/public/timelines/store/model.ts
index 33a9d9f545f7e..e87383be77a85 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/model.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/model.ts
@@ -139,6 +139,10 @@ export interface TimelineModel {
isDataProviderVisible: boolean;
/** used to mark the timeline as unsaved in the UI */
changed?: boolean;
+ /* row height, used only by unified data table */
+ rowHeight?: number;
+ /* sample size, total record number stored in in memory EuiDataGrid */
+ sampleSize: number;
/** the note id pending deletion */
confirmingNoteId?: string | null;
}
diff --git a/x-pack/plugins/security_solution/public/timelines/store/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/reducer.ts
index 54f72dbaa401f..2486c49b577db 100644
--- a/x-pack/plugins/security_solution/public/timelines/store/reducer.ts
+++ b/x-pack/plugins/security_solution/public/timelines/store/reducer.ts
@@ -62,6 +62,9 @@ import {
initializeSavedSearch,
setDataProviderVisibility,
setChanged,
+ updateRowHeight,
+ updateSampleSize,
+ updateColumnWidth,
setConfirmingNoteId,
deleteNoteFromEvent,
} from './actions';
@@ -103,6 +106,7 @@ import {
applyDeltaToTableColumnWidth,
updateTimelinePerPageOptions,
updateTimelineItemsPerPage,
+ updateTimelineColumnWidth,
} from './helpers';
import type { TimelineState } from './types';
@@ -566,6 +570,37 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
},
},
}))
+ .case(updateColumnWidth, (state, { id, columnId, width }) => ({
+ ...state,
+ timelineById: updateTimelineColumnWidth({
+ columnId,
+ id,
+ timelineById: state.timelineById,
+ width,
+ }),
+ }))
+
+ .case(updateSampleSize, (state, { id, sampleSize }) => ({
+ ...state,
+ timelineById: {
+ ...state.timelineById,
+ [id]: {
+ ...state.timelineById[id],
+ sampleSize,
+ },
+ },
+ }))
+
+ .case(updateRowHeight, (state, { id, rowHeight }) => ({
+ ...state,
+ timelineById: {
+ ...state.timelineById,
+ [id]: {
+ ...state.timelineById[id],
+ rowHeight,
+ },
+ },
+ }))
.case(setConfirmingNoteId, (state, { id, confirmingNoteId }) => ({
...state,
timelineById: {
diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts
index 16b7308aa73d9..be4022d3f2839 100644
--- a/x-pack/plugins/security_solution/public/types.ts
+++ b/x-pack/plugins/security_solution/public/types.ts
@@ -56,6 +56,7 @@ import type { DiscoverStart } from '@kbn/discover-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { UpsellingService } from '@kbn/security-solution-upselling/service';
+import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
@@ -139,7 +140,9 @@ export interface StartPlugins {
navigation: NavigationPublicPluginStart;
expressions: ExpressionsStart;
dataViewEditor: DataViewEditorStart;
+ charts: ChartsPluginStart;
savedSearch: SavedSearchPublicPluginStart;
+ core: CoreStart;
}
export interface StartPluginsDependencies extends StartPlugins {
diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json
index cce960b04dc43..cf7216e476a93 100644
--- a/x-pack/plugins/security_solution/tsconfig.json
+++ b/x-pack/plugins/security_solution/tsconfig.json
@@ -175,6 +175,12 @@
"@kbn/react-kibana-mount",
"@kbn/react-kibana-context-styled",
"@kbn/unified-doc-viewer-plugin",
+ "@kbn/openapi-generator",
+ "@kbn/unified-data-table",
+ "@kbn/unified-doc-viewer",
+ "@kbn/dom-drag-drop",
+ "@kbn/unified-field-list",
+ "@kbn/resizable-layout",
"@kbn/shared-ux-error-boundary",
"@kbn/zod-helpers",
"@kbn/core-http-common",
@@ -187,6 +193,7 @@
"@kbn/core-test-helpers-kbn-server",
"@kbn/core-ui-settings-server",
"@kbn/core-http-request-handler-context-server",
- "@kbn/core-http-server-mocks"
+ "@kbn/core-http-server-mocks",
+ "@kbn/data-service"
]
}
diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unified_components/query_tab.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unified_components/query_tab.cy.ts
new file mode 100644
index 0000000000000..058025b596956
--- /dev/null
+++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/unified_components/query_tab.cy.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 {
+ closeTimelineFlyout,
+ openEventDetailsFlyout,
+ openHostDetailsFlyout,
+ openUserDetailsFlyout,
+} from '../../../../tasks/unified_timeline';
+import {
+ GET_UNIFIED_DATA_GRID_CELL_HEADER,
+ HOST_DETAILS_FLYOUT,
+ TIMELINE_DETAILS_FLYOUT,
+ USER_DETAILS_FLYOUT,
+} from '../../../../screens/unified_timeline';
+import { GET_DISCOVER_DATA_GRID_CELL_HEADER } from '../../../../screens/discover';
+import { addFieldToTable, removeFieldFromTable } from '../../../../tasks/discover';
+import { login } from '../../../../tasks/login';
+import { visitWithTimeRange } from '../../../../tasks/navigation';
+import { openTimelineUsingToggle } from '../../../../tasks/security_main';
+import { createNewTimeline, executeTimelineSearch } from '../../../../tasks/timeline';
+import { ALERTS_URL } from '../../../../urls/navigation';
+
+describe(
+ 'Unsaved Timeline query tab',
+ {
+ tags: ['@ess', '@serverless', '@brokenInServerlessQA'],
+ env: {
+ ftrConfig: {
+ kbnServerArgs: [
+ `--xpack.securitySolution.enableExperimental=${JSON.stringify([
+ 'unifiedComponentsInTimelineEnabled',
+ ])}`,
+ ],
+ },
+ },
+ },
+ () => {
+ beforeEach(() => {
+ login();
+ visitWithTimeRange(ALERTS_URL);
+ openTimelineUsingToggle();
+ createNewTimeline();
+ executeTimelineSearch('*');
+ });
+ it('should be able to add/remove columns correctly', () => {
+ cy.get(GET_UNIFIED_DATA_GRID_CELL_HEADER('agent.type')).should('not.exist');
+ addFieldToTable('agent.type');
+ cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('agent.type')).should('be.visible');
+ removeFieldFromTable('agent.type');
+ cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('agent.type')).should('not.exist');
+ });
+
+ context('flyout', () => {
+ it('should be able to open/close details details/host/user flyout', () => {
+ cy.log('Event Details Flyout');
+ openEventDetailsFlyout(0);
+ cy.get(TIMELINE_DETAILS_FLYOUT).should('be.visible');
+ closeTimelineFlyout();
+ cy.log('Host Details Flyout');
+ openHostDetailsFlyout(0);
+ cy.get(HOST_DETAILS_FLYOUT).should('be.visible');
+ closeTimelineFlyout();
+ cy.log('User Details Flyout');
+ openUserDetailsFlyout(0);
+ cy.get(USER_DETAILS_FLYOUT).should('be.visible');
+ });
+ });
+ }
+);
diff --git a/x-pack/test/security_solution_cypress/cypress/screens/unified_timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/unified_timeline.ts
new file mode 100644
index 0000000000000..36aeb7d616128
--- /dev/null
+++ b/x-pack/test/security_solution_cypress/cypress/screens/unified_timeline.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 { getDataTestSubjectSelector } from '../helpers/common';
+
+export const TIMELINE_DETAILS_FLYOUT_BTN = getDataTestSubjectSelector('docTableExpandToggleColumn');
+
+export const HOST_DETAILS_LINK = getDataTestSubjectSelector('host-details-button');
+
+export const USER_DETAILS_LINK = getDataTestSubjectSelector('users-link-anchor');
+
+export const TIMELINE_DETAILS_FLYOUT = getDataTestSubjectSelector('timeline:details-panel:flyout');
+
+export const HOST_DETAILS_FLYOUT = getDataTestSubjectSelector('host-panel-header');
+
+export const USER_DETAILS_FLYOUT = getDataTestSubjectSelector('user-panel-header');
+
+export const TIMELINE_DETAILS_FLYOUT_CLOSE_BTN = getDataTestSubjectSelector('euiFlyoutCloseButton');
+
+export const TIMELINE_UNIFIED_DATA_GRID = `${getDataTestSubjectSelector(
+ 'timelineUnifiedComponentsLayoutResizablePanelFlex'
+)} ${getDataTestSubjectSelector('docTable')}`;
+
+export const CELL_FILTER_IN_BUTTON = getDataTestSubjectSelector(
+ 'dataGridColumnCellAction-security-default-cellActions-filterIn'
+);
+
+export const CELL_FILTER_OUT_BUTTON = getDataTestSubjectSelector(
+ 'dataGridColumnCellAction-security-default-cellActions-filterOut'
+);
+
+export const CELL_ADD_TO_TIMELINE_BUTTON = getDataTestSubjectSelector(
+ 'dataGridColumnCellAction-security-default-cellActions-addToTimeline'
+);
+
+export const CELL_SHOW_TOP_FIELD_BUTTON = getDataTestSubjectSelector(
+ 'dataGridColumnCellAction-security-default-cellActions-showTopN'
+);
+
+export const GET_UNIFIED_DATA_GRID_CELL_HEADER = (columnId: string) =>
+ getDataTestSubjectSelector(`dataGridHeaderCell-${columnId}`);
+
+export const GET_UNIFIED_DATA_GRID_CELL = (columnId: string, rowIndex: number) => {
+ return `${TIMELINE_UNIFIED_DATA_GRID} ${getDataTestSubjectSelector(
+ 'dataGridRowCell'
+ )}[data-gridcell-column-id="${columnId}"][data-gridcell-row-index="${rowIndex}"] .unifiedDataTable__cellValue`;
+};
diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts b/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts
index 19e455cd4b648..e888f75149b2d 100644
--- a/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/discover.ts
@@ -112,6 +112,10 @@ export const addFieldToTable = (fieldId: string) => {
clearFieldSearch();
};
+export const removeFieldFromTable = (fieldId: string) => {
+ cy.get(GET_DISCOVER_COLUMN_TOGGLE_BTN(fieldId)).first().click();
+};
+
export const createAdHocDataView = (name: string, indexPattern: string, save: boolean = false) => {
openDataViewSwitcher();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.CREATE_NEW).trigger('click');
diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/unified_timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/unified_timeline.ts
new file mode 100644
index 0000000000000..d53ef784a8d24
--- /dev/null
+++ b/x-pack/test/security_solution_cypress/cypress/tasks/unified_timeline.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 {
+ GET_UNIFIED_DATA_GRID_CELL,
+ GET_UNIFIED_DATA_GRID_CELL_HEADER,
+ HOST_DETAILS_LINK,
+ TIMELINE_DETAILS_FLYOUT,
+ TIMELINE_DETAILS_FLYOUT_BTN,
+ TIMELINE_DETAILS_FLYOUT_CLOSE_BTN,
+ USER_DETAILS_LINK,
+} from '../screens/unified_timeline';
+
+export const openEventDetailsFlyout = (rowIndex: number) => {
+ cy.get(TIMELINE_DETAILS_FLYOUT_BTN).eq(rowIndex).click();
+};
+
+export const openHostDetailsFlyout = (rowIndex: number) => {
+ cy.get(HOST_DETAILS_LINK).eq(rowIndex).click();
+};
+
+export const openUserDetailsFlyout = (rowIndex: number) => {
+ cy.get(USER_DETAILS_LINK).eq(rowIndex).click();
+};
+
+export const getUnifiedTableHeaderColumn = (columnName: string) => {
+ return cy.get(GET_UNIFIED_DATA_GRID_CELL_HEADER(columnName));
+};
+
+export const getUnifiedTableHeaderColumnCell = (columnName: string, rowIndex: number) => {
+ return cy.get(GET_UNIFIED_DATA_GRID_CELL(columnName, rowIndex));
+};
+
+export const closeTimelineFlyout = () => {
+ cy.get(TIMELINE_DETAILS_FLYOUT_CLOSE_BTN).click();
+ cy.get(TIMELINE_DETAILS_FLYOUT).should('not.exist');
+};
From 6d55cc8e95e35c0d4f32ba7e3eb1c1615c80dc07 Mon Sep 17 00:00:00 2001
From: mohamedhamed-ahmed
Date: Tue, 2 Apr 2024 14:39:51 +0200
Subject: [PATCH 20/63] [Dataset quality] Add Flyout Integration Actions
(#179401)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
closes https://github.com/elastic/kibana/issues/178843
## 📝 Summary
This PR adds actions to the integration section in the dataset quality
flyout.
These actions navigate to different integration-related pages.
The Dashboards action is only visible if the integration does have
dashboard assets installed, otherwise its hidden.
## 🎥 Demo
https://github.com/elastic/kibana/assets/11225826/91c417e6-be7d-45eb-91dc-2f5b29e7aeb5
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
packages/deeplinks/analytics/constants.ts | 4 +
packages/deeplinks/analytics/index.ts | 1 +
packages/deeplinks/management/constants.ts | 1 +
.../src/get_router_link_props/index.ts | 7 +-
.../public/dashboard_app/dashboard_app.tsx | 2 +-
.../public/dashboard_app/locator/locator.ts | 3 +-
.../top_nav/share/show_share_modal.tsx | 2 +-
.../url/search_sessions_integration.ts | 2 +-
src/plugins/dashboard/tsconfig.json | 1 +
src/plugins/management/common/locator.test.ts | 3 +-
src/plugins/management/common/locator.ts | 3 +-
src/plugins/management/tsconfig.json | 1 +
.../dataset_quality/common/api_types.ts | 15 ++
.../common/data_streams_stats/integration.ts | 5 +-
.../common/data_streams_stats/types.ts | 6 +
.../public/components/flyout/fields_list.tsx | 11 +-
.../flyout/integration_actions_menu.tsx | 166 +++++++++++++++++
.../components/flyout/integration_summary.tsx | 4 +
.../public/controller/create_controller.ts | 5 +-
.../hooks/use_dataset_quality_flyout.tsx | 7 +-
.../hooks/use_flyout_integration_actions.tsx | 78 ++++++++
.../dataset_quality/public/plugin.tsx | 6 +
.../data_stream_details_client.ts | 64 +++++++
.../data_stream_details_service.ts | 27 +++
.../services/data_stream_details/index.ts | 10 ++
.../services/data_stream_details/types.ts | 31 ++++
.../data_streams_stats_client.ts | 22 ---
.../services/data_streams_stats/types.ts | 3 -
.../src/notifications.ts | 9 +
.../src/state_machine.ts | 99 +++++++++--
.../dataset_quality_controller/src/types.ts | 10 ++
.../routes/data_streams/get_integrations.ts | 49 ++++-
.../server/routes/data_streams/routes.ts | 34 +++-
.../dataset_quality/tsconfig.json | 5 +-
.../integration_dashboards.spec.ts | 116 ++++++++++++
.../dataset_quality/dataset_quality_flyout.ts | 167 ++++++++++++++++++
.../page_objects/dataset_quality.ts | 17 ++
.../dataset_quality/dataset_quality_flyout.ts | 167 ++++++++++++++++++
38 files changed, 1106 insertions(+), 57 deletions(-)
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_service.ts
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/index.ts
create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts
create mode 100644 x-pack/test/dataset_quality_api_integration/tests/data_streams/integration_dashboards.spec.ts
diff --git a/packages/deeplinks/analytics/constants.ts b/packages/deeplinks/analytics/constants.ts
index 9793f7bb1864f..3eed43677281c 100644
--- a/packages/deeplinks/analytics/constants.ts
+++ b/packages/deeplinks/analytics/constants.ts
@@ -13,3 +13,7 @@ export const DASHBOARD_APP_ID = 'dashboards';
export const VISUALIZE_APP_ID = 'visualize';
export const DISCOVER_ESQL_LOCATOR = 'DISCOVER_ESQL_LOCATOR';
+
+export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR';
+
+export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard';
diff --git a/packages/deeplinks/analytics/index.ts b/packages/deeplinks/analytics/index.ts
index aa01b0036a52d..ced2aeba50417 100644
--- a/packages/deeplinks/analytics/index.ts
+++ b/packages/deeplinks/analytics/index.ts
@@ -11,6 +11,7 @@ export {
DISCOVER_APP_ID,
VISUALIZE_APP_ID,
DISCOVER_ESQL_LOCATOR,
+ DASHBOARD_APP_LOCATOR,
} from './constants';
export type { AppId, DeepLinkId } from './deep_links';
diff --git a/packages/deeplinks/management/constants.ts b/packages/deeplinks/management/constants.ts
index 1910641ac47c8..d094d97268574 100644
--- a/packages/deeplinks/management/constants.ts
+++ b/packages/deeplinks/management/constants.ts
@@ -11,3 +11,4 @@ export const INTEGRATIONS_APP_ID = 'integrations';
export const FLEET_APP_ID = 'fleet';
export const OSQUERY_APP_ID = 'osquery';
export const MANAGEMENT_APP_ID = 'management';
+export const MANAGEMENT_APP_LOCATOR = 'MANAGEMENT_APP_LOCATOR';
diff --git a/packages/kbn-router-utils/src/get_router_link_props/index.ts b/packages/kbn-router-utils/src/get_router_link_props/index.ts
index f3def2e88650b..a0b7f53afc440 100644
--- a/packages/kbn-router-utils/src/get_router_link_props/index.ts
+++ b/packages/kbn-router-utils/src/get_router_link_props/index.ts
@@ -6,6 +6,11 @@
* Side Public License, v 1.
*/
+export interface RouterLinkProps {
+ href: string | undefined;
+ onClick: (event: React.MouseEvent) => void;
+}
+
interface GetRouterLinkPropsDeps {
href?: string;
onClick(): void;
@@ -28,7 +33,7 @@ const isLeftClickEvent = (event: React.MouseEvent) => event.b
* @returns An object that contains an href and a guardedClick handler that will
* manage behaviours such as leftClickEvent and event with modifiers (Ctrl, Shift, etc)
*/
-export const getRouterLinkProps = ({ href, onClick }: GetRouterLinkPropsDeps) => {
+export const getRouterLinkProps = ({ href, onClick }: GetRouterLinkPropsDeps): RouterLinkProps => {
const guardedClickHandler = (event: React.MouseEvent) => {
if (event.defaultPrevented) {
return;
diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
index 1ff789ab61201..320887bbf551c 100644
--- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
@@ -15,6 +15,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
+import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import {
DashboardAppNoDataPage,
isDashboardAppInNoDataState,
@@ -31,7 +32,6 @@ import {
} from './url/search_sessions_integration';
import { DashboardAPI, DashboardRenderer } from '..';
import { type DashboardEmbedSettings } from './types';
-import { DASHBOARD_APP_LOCATOR } from './locator/locator';
import { pluginServices } from '../services/plugin_services';
import { AwaitingDashboardAPI } from '../dashboard_container';
import { DashboardRedirect } from '../dashboard_container/types';
diff --git a/src/plugins/dashboard/public/dashboard_app/locator/locator.ts b/src/plugins/dashboard/public/dashboard_app/locator/locator.ts
index c902bc369e044..15eb56cd44714 100644
--- a/src/plugins/dashboard/public/dashboard_app/locator/locator.ts
+++ b/src/plugins/dashboard/public/dashboard_app/locator/locator.ts
@@ -14,6 +14,7 @@ import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public';
+import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import { DASHBOARD_APP_ID, SEARCH_SESSION_ID } from '../../dashboard_constants';
import { DashboardLocatorParams } from '../..';
@@ -31,8 +32,6 @@ export const cleanEmptyKeys = (stateObj: Record) => {
return stateObj;
};
-export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR';
-
export type DashboardAppLocator = LocatorPublic;
export interface DashboardAppLocatorDependencies {
diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx
index b2d5cd9f4fe98..24a383244dc8c 100644
--- a/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx
@@ -19,11 +19,11 @@ import { getStateFromKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public';
import type { SerializableControlGroupInput } from '@kbn/controls-plugin/common';
+import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import { dashboardUrlParams } from '../../dashboard_router';
import { shareModalStrings } from '../../_dashboard_app_strings';
import { pluginServices } from '../../../services/plugin_services';
import { convertPanelMapToSavedPanels } from '../../../../common';
-import { DASHBOARD_APP_LOCATOR } from '../../locator/locator';
import { DashboardLocatorParams } from '../../../dashboard_container';
const showFilterBarId = 'showFilterBar';
diff --git a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts
index 91451b225658c..7a93c530767a2 100644
--- a/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts
+++ b/src/plugins/dashboard/public/dashboard_app/url/search_sessions_integration.ts
@@ -18,11 +18,11 @@ import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
import type { Query } from '@kbn/es-query';
import { SearchSessionInfoProvider } from '@kbn/data-plugin/public';
+import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import { SEARCH_SESSION_ID } from '../../dashboard_constants';
import { DashboardContainer, DashboardLocatorParams } from '../../dashboard_container';
import { convertPanelMapToSavedPanels } from '../../../common';
import { pluginServices } from '../../services/plugin_services';
-import { DASHBOARD_APP_LOCATOR } from '../locator/locator';
export const removeSearchSessionIdFromURL = (kbnUrlStateStorage: IKbnUrlStateStorage) => {
kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => {
diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json
index 7244e9ee9472f..b484a9f6c51c3 100644
--- a/src/plugins/dashboard/tsconfig.json
+++ b/src/plugins/dashboard/tsconfig.json
@@ -76,6 +76,7 @@
"@kbn/shared-ux-utility",
"@kbn/managed-content-badge",
"@kbn/core-test-helpers-model-versions",
+ "@kbn/deeplinks-analytics",
],
"exclude": ["target/**/*"]
}
diff --git a/src/plugins/management/common/locator.test.ts b/src/plugins/management/common/locator.test.ts
index 20773b9732782..c45632a9ad776 100644
--- a/src/plugins/management/common/locator.test.ts
+++ b/src/plugins/management/common/locator.test.ts
@@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
+import { MANAGEMENT_APP_LOCATOR } from '@kbn/deeplinks-management/constants';
import { MANAGEMENT_APP_ID } from './contants';
-import { ManagementAppLocatorDefinition, MANAGEMENT_APP_LOCATOR } from './locator';
+import { ManagementAppLocatorDefinition } from './locator';
test('locator has the right ID', () => {
const locator = new ManagementAppLocatorDefinition();
diff --git a/src/plugins/management/common/locator.ts b/src/plugins/management/common/locator.ts
index 5169919846d9d..73b1d63f6610e 100644
--- a/src/plugins/management/common/locator.ts
+++ b/src/plugins/management/common/locator.ts
@@ -8,10 +8,9 @@
import type { SerializableRecord } from '@kbn/utility-types';
import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common';
+import { MANAGEMENT_APP_LOCATOR } from '@kbn/deeplinks-management/constants';
import { MANAGEMENT_APP_ID } from './contants';
-export const MANAGEMENT_APP_LOCATOR = 'MANAGEMENT_APP_LOCATOR';
-
export interface ManagementAppLocatorParams extends SerializableRecord {
sectionId: string;
appId?: string;
diff --git a/src/plugins/management/tsconfig.json b/src/plugins/management/tsconfig.json
index df0a876b4b3c4..116debbf72496 100644
--- a/src/plugins/management/tsconfig.json
+++ b/src/plugins/management/tsconfig.json
@@ -26,6 +26,7 @@
"@kbn/config-schema",
"@kbn/serverless",
"@kbn/shared-ux-error-boundary",
+ "@kbn/deeplinks-management",
],
"exclude": [
"target/**/*"
diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts b/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts
index 9fba1352fa944..4c88ae2cadb10 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/common/api_types.ts
@@ -21,6 +21,20 @@ export const dataStreamStatRt = rt.intersection([
export type DataStreamStat = rt.TypeOf;
+export const dashboardRT = rt.type({
+ id: rt.string,
+ title: rt.string,
+});
+
+export const integrationDashboardsRT = rt.type({
+ dashboards: rt.array(dashboardRT),
+});
+
+export type IntegrationDashboards = rt.TypeOf;
+export type Dashboard = rt.TypeOf;
+
+export const getIntegrationDashboardsResponseRt = rt.exact(integrationDashboardsRT);
+
export const integrationIconRt = rt.intersection([
rt.type({
path: rt.string,
@@ -42,6 +56,7 @@ export const integrationRt = rt.intersection([
version: rt.string,
icons: rt.array(integrationIconRt),
datasets: rt.record(rt.string, rt.string),
+ dashboards: rt.array(dashboardRT),
}),
]);
diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts
index 68a394c4b41c5..97d0a5f001d69 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/integration.ts
@@ -5,19 +5,21 @@
* 2.0.
*/
-import { IntegrationType } from './types';
+import { DashboardType, IntegrationType } from './types';
export class Integration {
name: IntegrationType['name'];
title: string;
version: string;
icons?: IntegrationType['icons'];
+ dashboards?: DashboardType[];
private constructor(integration: Integration) {
this.name = integration.name;
this.title = integration.title || integration.name;
this.version = integration.version || '1.0.0';
this.icons = integration.icons;
+ this.dashboards = integration.dashboards || [];
}
public static create(integration: IntegrationType) {
@@ -25,6 +27,7 @@ export class Integration {
...integration,
title: integration.title || integration.name,
version: integration.version || '1.0.0',
+ dashboards: integration.dashboards || [],
};
return new Integration(integrationProps);
diff --git a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts
index b8f8c37e46851..a2fc5f42fed4a 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/common/data_streams_stats/types.ts
@@ -41,5 +41,11 @@ export type GetDataStreamsEstimatedDataInBytesParams =
export type GetDataStreamsEstimatedDataInBytesResponse =
APIReturnType<`GET /internal/dataset_quality/data_streams/estimated_data`>;
+export type GetIntegrationDashboardsParams =
+ APIClientRequestParamsOf<`GET /internal/dataset_quality/integrations/{integration}/dashboards`>['params']['path'];
+export type GetIntegrationDashboardsResponse =
+ APIReturnType<`GET /internal/dataset_quality/integrations/{integration}/dashboards`>;
+export type DashboardType = GetIntegrationDashboardsResponse['dashboards'][0];
+
export type { DataStreamStat } from './data_stream_stat';
export type { DataStreamDetails } from '../api_types';
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/fields_list.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/fields_list.tsx
index 0824e85ddd363..a203d08365d2b 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/fields_list.tsx
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/fields_list.tsx
@@ -20,15 +20,20 @@ import {
export function FieldsList({
title,
fields,
+ actionsMenu: ActionsMenu,
}: {
title: string;
fields: Array<{ fieldTitle: string; fieldValue: ReactNode }>;
+ actionsMenu?: ReactNode;
}) {
return (
-
- {title}
-
+
+
+ {title}
+
+ {ActionsMenu}
+
{fields.map(({ fieldTitle, fieldValue }, index) => (
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx
new file mode 100644
index 0000000000000..7a3b2715f1782
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_actions_menu.tsx
@@ -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 React, { useMemo } from 'react';
+import { i18n } from '@kbn/i18n';
+import {
+ EuiButtonEmpty,
+ EuiButtonIcon,
+ EuiContextMenu,
+ EuiContextMenuPanelDescriptor,
+ EuiContextMenuPanelItemDescriptor,
+ EuiPopover,
+} from '@elastic/eui';
+import { css } from '@emotion/react';
+import { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props';
+import { Integration } from '../../../common/data_streams_stats/integration';
+import { useDatasetQualityFlyout } from '../../hooks';
+import { useFlyoutIntegrationActions } from '../../hooks/use_flyout_integration_actions';
+const seeIntegrationText = i18n.translate('xpack.datasetQuality.flyoutSeeIntegrationActionText', {
+ defaultMessage: 'See integration',
+});
+
+const indexTemplateText = i18n.translate('xpack.datasetQuality.flyoutIndexTemplateActionText', {
+ defaultMessage: 'Index template',
+});
+
+const viewDashboardsText = i18n.translate('xpack.datasetQuality.flyoutViewDashboardsActionText', {
+ defaultMessage: 'View dashboards',
+});
+
+export function IntegrationActionsMenu({ integration }: { integration: Integration }) {
+ const { type, name } = useDatasetQualityFlyout().dataStreamStat!;
+ const { dashboards = [], version, name: integrationName } = integration;
+ const {
+ isOpen,
+ handleCloseMenu,
+ handleToggleMenu,
+ getIntegrationOverviewLinkProps,
+ getIndexManagementLinkProps,
+ getDashboardLinkProps,
+ } = useFlyoutIntegrationActions();
+
+ const actionButton = (
+
+ );
+
+ const MenuActionItem = ({
+ dataTestSubject,
+ buttonText,
+ routerLinkProps,
+ iconType,
+ }: {
+ dataTestSubject: string;
+ buttonText: string;
+ routerLinkProps: RouterLinkProps;
+ iconType: string;
+ }) => (
+
+ {buttonText}
+
+ );
+
+ const panelItems = useMemo(() => {
+ const firstLevelItems: EuiContextMenuPanelItemDescriptor[] = [
+ {
+ renderItem: () => (
+
+ ),
+ },
+ {
+ renderItem: () => (
+
+ ),
+ },
+ {
+ isSeparator: true,
+ key: 'sep',
+ },
+ ];
+
+ if (dashboards.length) {
+ firstLevelItems.push({
+ icon: 'dashboardApp',
+ panel: 1,
+ name: viewDashboardsText,
+ 'data-test-subj': 'datasetQualityFlyoutIntegrationActionViewDashboards',
+ });
+ }
+
+ const panel: EuiContextMenuPanelDescriptor[] = [
+ {
+ id: 0,
+ items: firstLevelItems,
+ },
+ {
+ id: 1,
+ title: viewDashboardsText,
+ items: dashboards.map((dashboard) => {
+ return {
+ renderItem: () => (
+
+ ),
+ };
+ }),
+ },
+ ];
+
+ return panel;
+ }, [
+ dashboards,
+ getDashboardLinkProps,
+ getIndexManagementLinkProps,
+ getIntegrationOverviewLinkProps,
+ integrationName,
+ name,
+ type,
+ version,
+ ]);
+
+ return (
+
+
+
+ );
+}
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx
index 19b147bcea1fd..d77c68e4ac33b 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/integration_summary.tsx
@@ -16,12 +16,16 @@ import {
import { Integration } from '../../../common/data_streams_stats/integration';
import { IntegrationIcon } from '../common';
import { FieldsList } from './fields_list';
+import { IntegrationActionsMenu } from './integration_actions_menu';
export function IntegrationSummary({ integration }: { integration: Integration }) {
const { name, version } = integration;
+
+ const integrationActionsMenu = ;
return (
+ ({ core, dataStreamStatsClient, dataStreamDetailsClient }: Dependencies) =>
async ({
initialState = DEFAULT_CONTEXT,
}: {
@@ -38,6 +40,7 @@ export const createDatasetQualityControllerFactory =
initialContext,
toasts: core.notifications.toasts,
dataStreamStatsClient,
+ dataStreamDetailsClient,
});
const service = interpret(machine, {
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx
index cbbd6b8d6bc46..0a60c3c2ed2b5 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_dataset_quality_flyout.tsx
@@ -23,8 +23,11 @@ export const useDatasetQualityFlyout = () => {
} = useSelector(service, (state) => state.context.flyout);
const { timeRange } = useSelector(service, (state) => state.context.filters);
- const dataStreamDetailsLoading = useSelector(service, (state) =>
- state.matches('datasets.loaded.flyoutOpen.fetching')
+ const dataStreamDetailsLoading = useSelector(
+ service,
+ (state) =>
+ state.matches('datasets.loaded.flyoutOpen.fetching') ||
+ state.matches('flyout.initializing.dataStreamDetails.fetching')
);
return {
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx
new file mode 100644
index 0000000000000..29faaec2788ea
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx
@@ -0,0 +1,78 @@
+/*
+ * 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 { getRouterLinkProps } from '@kbn/router-utils';
+import { useMemo, useCallback } from 'react';
+import useToggle from 'react-use/lib/useToggle';
+import { MANAGEMENT_APP_LOCATOR } from '@kbn/deeplinks-management/constants';
+import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
+import { DashboardType } from '../../common/data_streams_stats';
+import { useKibanaContextForPlugin } from '../utils';
+
+export const useFlyoutIntegrationActions = () => {
+ const {
+ services: {
+ application: { navigateToUrl },
+ http: { basePath },
+ share,
+ },
+ } = useKibanaContextForPlugin();
+
+ const [isOpen, toggleIsOpen] = useToggle(false);
+
+ const dashboardLocator = useMemo(
+ () => share.url.locators.get(DASHBOARD_APP_LOCATOR),
+ [share.url.locators]
+ );
+ const indexManagementLocator = useMemo(
+ () => share.url.locators.get(MANAGEMENT_APP_LOCATOR),
+ [share.url.locators]
+ );
+
+ const handleCloseMenu = useCallback(() => {
+ toggleIsOpen();
+ }, [toggleIsOpen]);
+ const handleToggleMenu = useCallback(() => {
+ toggleIsOpen();
+ }, [toggleIsOpen]);
+
+ const getIntegrationOverviewLinkProps = useCallback(
+ (name: string, version: string) => {
+ const href = basePath.prepend(`/app/integrations/detail/${name}-${version}/overview`);
+ return getRouterLinkProps({
+ href,
+ onClick: () => navigateToUrl(href),
+ });
+ },
+ [basePath, navigateToUrl]
+ );
+ const getIndexManagementLinkProps = useCallback(
+ (params: { sectionId: string; appId: string }) =>
+ getRouterLinkProps({
+ href: indexManagementLocator?.getRedirectUrl(params),
+ onClick: () => indexManagementLocator?.navigate(params),
+ }),
+ [indexManagementLocator]
+ );
+ const getDashboardLinkProps = useCallback(
+ (dashboard: DashboardType) =>
+ getRouterLinkProps({
+ href: dashboardLocator?.getRedirectUrl({ dashboardId: dashboard?.id } || ''),
+ onClick: () => dashboardLocator?.navigate({ dashboardId: dashboard?.id } || ''),
+ }),
+ [dashboardLocator]
+ );
+
+ return {
+ isOpen,
+ handleCloseMenu,
+ handleToggleMenu,
+ getIntegrationOverviewLinkProps,
+ getIndexManagementLinkProps,
+ getDashboardLinkProps,
+ };
+};
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx
index 88ab62eff4703..6ea2655450607 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx
@@ -9,6 +9,7 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/cor
import { createDatasetQuality } from './components/dataset_quality';
import { createDatasetQualityControllerLazyFactory } from './controller/lazy_create_controller';
import { DataStreamsStatsService } from './services/data_streams_stats';
+import { DataStreamDetailsService } from './services/data_stream_details';
import {
DatasetQualityPluginSetup,
DatasetQualityPluginStart,
@@ -30,6 +31,10 @@ export class DatasetQualityPlugin
http: core.http,
}).client;
+ const dataStreamDetailsClient = new DataStreamDetailsService().start({
+ http: core.http,
+ }).client;
+
const DatasetQuality = createDatasetQuality({
core,
plugins,
@@ -39,6 +44,7 @@ export class DatasetQualityPlugin
const createDatasetQualityController = createDatasetQualityControllerLazyFactory({
core,
dataStreamStatsClient,
+ dataStreamDetailsClient,
});
return { DatasetQuality, createDatasetQualityController };
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts
new file mode 100644
index 0000000000000..1dd5b2d2220cd
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_client.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { HttpStart } from '@kbn/core/public';
+import { decodeOrThrow } from '@kbn/io-ts-utils';
+import {
+ getDataStreamsDetailsResponseRt,
+ integrationDashboardsRT,
+} from '../../../common/api_types';
+import {
+ GetDataStreamsStatsError,
+ GetDataStreamDetailsParams,
+ GetDataStreamDetailsResponse,
+ GetIntegrationDashboardsParams,
+ GetIntegrationDashboardsResponse,
+} from '../../../common/data_streams_stats';
+import { DataStreamDetails } from '../../../common/data_streams_stats';
+import { IDataStreamDetailsClient } from './types';
+
+export class DataStreamDetailsClient implements IDataStreamDetailsClient {
+ constructor(private readonly http: HttpStart) {}
+
+ public async getDataStreamDetails({ dataStream }: GetDataStreamDetailsParams) {
+ const response = await this.http
+ .get(
+ `/internal/dataset_quality/data_streams/${dataStream}/details`
+ )
+ .catch((error) => {
+ throw new GetDataStreamsStatsError(`Failed to fetch data stream details": ${error}`);
+ });
+
+ const dataStreamDetails = decodeOrThrow(
+ getDataStreamsDetailsResponseRt,
+ (message: string) =>
+ new GetDataStreamsStatsError(`Failed to decode data stream details response: ${message}"`)
+ )(response);
+
+ return dataStreamDetails as DataStreamDetails;
+ }
+
+ public async getIntegrationDashboards({ integration }: GetIntegrationDashboardsParams) {
+ const response = await this.http
+ .get(
+ `/internal/dataset_quality/integrations/${integration}/dashboards`
+ )
+ .catch((error) => {
+ throw new GetDataStreamsStatsError(`Failed to fetch integration dashboards": ${error}`);
+ });
+
+ const integrationDashboards = decodeOrThrow(
+ integrationDashboardsRT,
+ (message: string) =>
+ new GetDataStreamsStatsError(
+ `Failed to decode integration dashboards response: ${message}"`
+ )
+ )(response);
+
+ return integrationDashboards;
+ }
+}
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_service.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_service.ts
new file mode 100644
index 0000000000000..21d9eb492cc2a
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/data_stream_details_service.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { DataStreamDetailsClient } from './data_stream_details_client';
+import {
+ DataStreamDetailsServiceSetup,
+ DataStreamDetailsServiceStartDeps,
+ DataStreamDetailsServiceStart,
+} from './types';
+
+export class DataStreamDetailsService {
+ constructor() {}
+
+ public setup(): DataStreamDetailsServiceSetup {}
+
+ public start({ http }: DataStreamDetailsServiceStartDeps): DataStreamDetailsServiceStart {
+ const client = new DataStreamDetailsClient(http);
+
+ return {
+ client,
+ };
+ }
+}
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/index.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/index.ts
new file mode 100644
index 0000000000000..7d142e5f2a3cf
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/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 * from './data_stream_details_client';
+export * from './data_stream_details_service';
+export * from './types';
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts
new file mode 100644
index 0000000000000..068b36bab4fb1
--- /dev/null
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_stream_details/types.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { HttpStart } from '@kbn/core/public';
+import {
+ GetDataStreamDetailsParams,
+ DataStreamDetails,
+ GetIntegrationDashboardsParams,
+ GetIntegrationDashboardsResponse,
+} from '../../../common/data_streams_stats';
+
+export type DataStreamDetailsServiceSetup = void;
+
+export interface DataStreamDetailsServiceStart {
+ client: IDataStreamDetailsClient;
+}
+
+export interface DataStreamDetailsServiceStartDeps {
+ http: HttpStart;
+}
+
+export interface IDataStreamDetailsClient {
+ getDataStreamDetails(params: GetDataStreamDetailsParams): Promise;
+ getIntegrationDashboards(
+ params: GetIntegrationDashboardsParams
+ ): Promise;
+}
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts
index 625eafce2a282..69e8b2364006e 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/data_streams_stats_client.ts
@@ -12,7 +12,6 @@ import { Integration } from '../../../common/data_streams_stats/integration';
import {
getDataStreamsDegradedDocsStatsResponseRt,
getDataStreamsStatsResponseRt,
- getDataStreamsDetailsResponseRt,
getDataStreamsEstimatedDataInBytesResponseRt,
} from '../../../common/api_types';
import { DEFAULT_DATASET_TYPE, NONE } from '../../../common/constants';
@@ -23,12 +22,9 @@ import {
GetDataStreamsStatsError,
GetDataStreamsStatsQuery,
GetDataStreamsStatsResponse,
- GetDataStreamDetailsParams,
- GetDataStreamDetailsResponse,
GetDataStreamsEstimatedDataInBytesParams,
GetDataStreamsEstimatedDataInBytesResponse,
} from '../../../common/data_streams_stats';
-import { DataStreamDetails } from '../../../common/data_streams_stats';
import { DataStreamStat } from '../../../common/data_streams_stats/data_stream_stat';
import { IDataStreamsStatsClient } from './types';
@@ -95,24 +91,6 @@ export class DataStreamsStatsClient implements IDataStreamsStatsClient {
return degradedDocs;
}
- public async getDataStreamDetails({ dataStream }: GetDataStreamDetailsParams) {
- const response = await this.http
- .get(
- `/internal/dataset_quality/data_streams/${dataStream}/details`
- )
- .catch((error) => {
- throw new GetDataStreamsStatsError(`Failed to fetch data stream details": ${error}`);
- });
-
- const dataStreamDetails = decodeOrThrow(
- getDataStreamsDetailsResponseRt,
- (message: string) =>
- new GetDataStreamsStatsError(`Failed to decode data stream details response: ${message}"`)
- )(response);
-
- return dataStreamDetails as DataStreamDetails;
- }
-
public async getDataStreamsEstimatedDataInBytes(
params: GetDataStreamsEstimatedDataInBytesParams
) {
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts
index a2627ec4bf222..ed454233d36eb 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/data_streams_stats/types.ts
@@ -11,8 +11,6 @@ import {
DataStreamStatServiceResponse,
GetDataStreamsDegradedDocsStatsQuery,
GetDataStreamsStatsQuery,
- GetDataStreamDetailsParams,
- DataStreamDetails,
GetDataStreamsEstimatedDataInBytesParams,
GetDataStreamsEstimatedDataInBytesResponse,
} from '../../../common/data_streams_stats';
@@ -32,7 +30,6 @@ export interface IDataStreamsStatsClient {
getDataStreamsDegradedStats(
params?: GetDataStreamsDegradedDocsStatsQuery
): Promise;
- getDataStreamDetails(params: GetDataStreamDetailsParams): Promise;
getDataStreamsEstimatedDataInBytes(
params: GetDataStreamsEstimatedDataInBytesParams
): Promise;
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts
index c3cb600951463..ca249e3fa6022 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/notifications.ts
@@ -35,6 +35,15 @@ export const fetchDegradedStatsFailedNotifier = (toasts: IToasts, error: Error)
});
};
+export const fetchIntegrationDashboardsFailedNotifier = (toasts: IToasts, error: Error) => {
+ toasts.addDanger({
+ title: i18n.translate('xpack.datasetQuality.fetchIntegrationDashboardsFailed', {
+ defaultMessage: "We couldn't get your integration dashboards.",
+ }),
+ text: error.message,
+ });
+};
+
export const noDatasetSelected = i18n.translate(
'xpack.datasetQuality.fetchDatasetDetailsFailed.noDatasetSelected',
{
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts
index 4fd6d19e2012a..cef0f8496e4ae 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/state_machine.ts
@@ -8,7 +8,9 @@
import { IToasts } from '@kbn/core/public';
import { getDateISORange } from '@kbn/timerange';
import { assign, createMachine, DoneInvokeEvent, InterpreterFrom } from 'xstate';
+import { IDataStreamDetailsClient } from '../../../services/data_stream_details';
import {
+ DashboardType,
DataStreamDetails,
DataStreamStatServiceResponse,
GetDataStreamsStatsQuery,
@@ -23,6 +25,7 @@ import {
fetchDatasetDetailsFailedNotifier,
fetchDatasetStatsFailedNotifier,
fetchDegradedStatsFailedNotifier,
+ fetchIntegrationDashboardsFailedNotifier,
noDatasetSelected,
} from './notifications';
import {
@@ -35,7 +38,7 @@ import {
export const createPureDatasetQualityControllerStateMachine = (
initialContext: DatasetQualityControllerContext
) =>
- /** @xstate-layout N4IgpgJg5mDOIC5QBECGAXVszoIoFdUAbAS3QE8BhAewDt0AnaoosBgOggyx1nYDMcAYwAWJWlADEEOmHbiAbtQDWctJmx5CpCjXpMWbTt019B6UeKgJF1IRhJ0A2gAYAuq7eJQAB2qwyR1pvEAAPRAAWADYARnYATgBmAHYo+OSADniYlwBWGIAaEHJEZIAmDPYI+JcImJissuSXDIiAXzai9R4tYjIqOkZmVg4uDV4BYTEJSTYmDh8iDH5qBgBbdm7NAj7dQYMR43H0MymrG1ole3Qgz08QvwCbuhDwhCjKiNqY3OTE9LKEWqZSKJQQ5Vy7GS0Xq8Si0KBETKHS6JhwOx0A30wyMYx6fCI1FQEEgkgAqgAFZAAQQAKgBRAD6tOpACEADJMygAJQAkgy+dT7kgQI9Ai8RW9ctl2IkYpkMnL4oqIolQYgcnkEmVclEorkXPEykb4SiQFt0dp+nohoZRmiTuxCcTSbSAPIAcQ9nMZvIActTKLTeQA1Jk0lkAZXptMjwt8-nFwUliDSyXYaUSZUNSWVLnK6oQGRa7DKctyBuVcpciSiZotvUxNoOuIdBKJJIgkndXp9ADEyez2YyI9To7TGQGALL0uPuB6J57J0BvbOVeFRMvy3IRHcufWFzWQo26-WG41lPX1h0Y637HGjMBQBguiDIOynCzTKQyWhyWyqJsN5Wns2J2pwT4vp275CJ+lgSBcVwOM47jxqKi5BK8pQZJCyQxNmubpBEyTpIeTRxBkZQ6k0NapHWnTmsBuxYrahwks+r4wXB36zAw8zsIsyyrBsDa3qBrG4pBnEfpMX7nLY1y3Kh84imKS5Ye8UTsDEqrlC4LiAlkMTxPEZE4aWGTGS0yQ2Yk+7XscYksS2HD8EQ5DUPg6CSNGnJBpO9IAOojnSY4xmhamYSm4JxFRHzKmUOTRCkpnFIg-yQrq0TxF8dQxGkDk9E5zYPgI7med5lDsm60aMn27IAJpumStIRRhEorqUsWXlklFJVEKWHjpspVoqOVGkaeGFdsIHOaVbkeV5snwT+sjyJcKhqExTb3uBC0Vct36IXYyG0HcKkJk8UWdUWrTsAa8KpJZ+U2alYKJNE2q5JRmR1FE+aJNNlrMSVe3lUt5grbx-GCegKzrEBjmzaDhz7RDZwIQpp3nV4qntcuYSIBkfwZtCMQpNmOT-IWiVaYlLj5RkA2Klk9kMaJyO7aj4PoE6HaklVNVMvVTUtW1V0dYTCDxN991ZNCgJGsTTSHiWlkVv8Ro-ArMRA42d5gdzi2886nbklSdJMv6ka8h6AASsbMryM6Mty1J+h69Li0mGn4SkVQ-GWmSxJThRpQg9SQuT8rQi08J5BkevFVzRho7zQiEtgXZuhS9J+nVjXNa1F3oRLBNvHUETsMW0o5Sktb6eTNO5GU2k1Dp+GbsHyQdAxtDUCS8AihzIMpwwC5lxpAC0xrV7qTQmck+QtAzhZT-q1d2VmBkA9U+FJ5zhutscQ+XT70XZlU3y-P85RAtkUSFrkiTHjUSRZtCGSWci7PbQbEn2hPodKwE9z43RolCD4Nl6hJV6oWTciRZTkxVNWXcP9URI1HkfQB+I+avlAepaK+R4jaRIvCP2ek3qIGyOmOytcMppBlgfLBACIIcWgh+Ah10pYNG0rpbMBkIhGRMkNfIsoG4KjGkvdBjFME7WwWwqCkAuLAIkFwyWFctQ1hlvhLIz8cIRDIokdc310gmWMjuAazD5GsPYkot8MlTaQHUeXSIX9Sy6h3NUOyu4GaP3DuTKIVctbQmaNkGsQjrH-xcmVY2LiNJllLD1BK-UUjExpnEYEA1mhNHlqqKJ4kYlp1UVAeJ0V-qQlqF8Q0vxizEVyDTPUCRdz1DwvUAaG4ClzTBsbPBnYyk3XqEIue1QGapA+pRGmmQMzeOVPlXUTNchdJRqnHm7AM7+GcXjSe0U7Lpi8fKB+0JEgv0PP7FuqpdQtEZkkH+HQgA */
+ /** @xstate-layout N4IgpgJg5mDOIC5QBECGAXVszoIoFdUAbAS3QE8BhAewDt0AnaoosBgOggyx1gGIAqgAVkAQQAqAUQD64gJIBZGQCVRAOQDikgNoAGALqJQAB2qwyJOkZAAPRAE4A7ABoQ5RACYAjLoAs7AFYPAA4PR2CAgN1HewBmXwBfBNc0TGw8QlIKGnomFjZObnT+ZUkAMVKAZQAJaTFxUT1DJBBTc3RLWms7BCdXdwQPXQD-R10wxwA2X2jfey8klKKcAmIyKjpGZlYOLjTeQREJGTk1KQ1VeQB5NUqm6zaLKxaevrdEL0dfR3Zg+xHdMFJhFdMDgosQKkeBk1tlNnkdoV9uh+MJ6jI1KIlJUhKJKJI7gYHmYnl0Xg4XO8EF5gr5YuxProfLEvJNYvZJqyIVD0qsshtctsCntoaijlJpLgBJJlABNe4tR4dZ6gHqTDz0rwBP6g0LTLUefofbX0jxOL5gnwBSbc5Yw-k5Lb5XZ22DsABmOAAxgALEi0KB8CB0MDsf0AN2oAGtQzyVpl1o6EcLXR7vX6AwgI9QvRhOk0FSYScqyarEAF7PZ2LE-h5JpMgqzHI5DVSvPYgdXZlEZvFAbbkXzE-ChS7kW7Pehff7A2wmBxjEQMO7qAwALbsOP24eC51I0VpqcZqBZ2iR3MlgtExXFzrdcuV6u1+uNybN1sDbzDBn-AKORt0t4sQDtCQ5wruiIisU7BENQqAQJAhzorIogAEIADIyJQyhyFIOGNNeRbtHe5IIH8ATsICHjalM9bhLEkxGoMTJVqCvi+ECXzBBE3wgbyCbgU6kGprB8GIeIVwaBomHSKceLyAAajI9SiJUkjiISzREaS95kc2DK+NMsQeHWQI9kxNJOAy-66ICsQsvYgF8fGsICkJKbjjBcEIRAfASVJMllAI6HoXUEiqep0iYtihatLeKq2IgQLBOwYzWtqXjePW1EWcETLsPYtkmeMMzNokySQnaYFucmuxgFADBiRAyA5mKyHyEo0iqJoOiEXFxEJT0LZMRqMTVr4QweHMZq6JW5VLIOAk1aOnD1Y1PktV6JTlFUtQqbFSokWWCDDVSGpmoErJBGEEQxI4znboJtWrQ1TWbRO6YzkGIZhme0axlVS1JitCGvRtrWHtOmbZhe+YGAd8WlolCAjBR0zan4JlhBWI0zF41aVsZ7H2QxXwPdVwN7qD62QO9kPHnwc6ruwi7LquG5bhTI5U2tb0Q5OUMnjDeZ0FeWn9TppGo+w6N5RNJn-vYI2xLNDLUdxc3BO2bLk0D3OIu6RDkNQ+DoHwamYZQ4hRZIADqYUNGp4gIwNSNDR+iDsv4oQsvEYS6MZWu665lMG0bJtm5Q6FXGp0hlOhspXAIzt9Ydg2IKdn50jLfg0ir8yTfYwcOvrBSG8bpu-RYawAF5fcGtChrAmDoADi0h6XHDlxHVcdLXM4u5Lx2OZqRUB3+oRml4Fk0vjPF0uyLbxFMxc7u5Xfh5X-rV6QdcBvulSMGAqBrsgOCoCQRAfUe9c-dmMaboDHcQWXm-oL3JD9-vIqHwwx+n+fS+19BannPCLWgYtiSu10r4dsT57Bmi+IZWabILKzR+FqXwQQA6shKvdCqnM9Yvw3hXd+28+67xnAfI+J8z6YCAfTL6TMFxLnQCudcj924l2IR6N+H8v5QGoX-WhgCr6MOhn9WGot4ap0RjAuBNYEFOHYpMFBsQ0F+F+FrQEJodGxHwQtUCRD168NIfwyh+9-St1eiWNAsAfQACM4IMAgMAhmDdQz3zbkY5+Jju5b1oDvEge9BFWN5rYrAjjnGuPEULSR4DIE3mgaRdiFEhj52CF8WaZkAgWXZPjEqLINT-G4oZVeT0Vr+LIYEihwSqFhJsZ0OxUTUAuLcUwhg84WasPYRzJ+3C-F8PIZ-CxoT6DhKaZEpxrSYkC2PKAnMCSZHizTm7RAqT2DpOyVkjsDZcltjNClGymSYg+EcF4eI5Tlp7iqV5JqfAo4xxkPHROydB4ll0ggpiS9KJOAQRqTkcwyYEP6WvZ6tzRI+SQscWStw5AaGqBpWQigVDqC0O8o6yNMqaJutaEEnIvCZTQaEVK3xbLtjpNEYYVzQ6vzMV6WC2BfJXCEJINQccE5JxTisuRpEGJVkUSrJkxkWRErOgEfGwITQ1n0eMWywEIS0GoAheALRCG+NqlAoeyMAC0WCmLAgKiaaaIwaQhHBCCrhYKQaui1R8lJSs2z1l0IEFscxPgvm1EXS1PiBnPSgrwWJdrMU9Hshg7i4RgQNn+BxfZn5AQpX+CrOsXx5grx9fxDVNrPKQsgMG9OvQGwFXGGm9GPhCUjUJQK9kYQOIzBKjSzuL0abNVavmtZgxfAjRMnPEtjhk0ciBI2nh1M+ZbSDUk7VPQzQUT+A5GsWo8qckpJ+Yy+NjKIIXpMeYNoM0uT9SDXm4Nx25ogO23SWt8YxC1uaoYhlYhxs8GG1KzqOw1g1L2YdgzSHnr5RZa0myb31k+HWJRX7wVDJqSMupAZf3HQlUxYIKtNkPtA6o2adJvWGMzQem5kGgkhKEf-OhF8r5weRpWJiabAgZWBAXeI+jwOVPw7UwjP8aEAPoWIuZM5yM9C8N8NBnxKLhAuYVTBWsDGVStRUvDZjhkCKIyIrjboPF8cQNul15zATNkiFg2kj7qQBw8DLQqgIdH9u8B4Jjcme4KdGb9axjUIn2OmW09TvRHUDEyoS9gE0YgjDylWyINmw7yag4phpznJmueie02Dk77XHQEyuj4LIXWORiOlpNsDd3Yf3da2zASCP1PGY0ugzS3MxLU4lkNGcANzCBN8QL-5OR5LrDRjsLY4jnMvaFulPdT0ecJRxAqYn1RVvmFqGeSHKJZQ5BNbWCr8uPWuWFnuDKzB5tqwWusJnMqk3ZDxBBqXBj1j8xyKYAQWS6Z8LupIQA */
createMachine<
DatasetQualityControllerContext,
DatasetQualityControllerEvent,
@@ -132,18 +135,55 @@ export const createPureDatasetQualityControllerStateMachine = (
flyout: {
initial: 'closed',
states: {
- fetching: {
- invoke: {
- src: 'loadDataStreamDetails',
- onDone: {
- target: 'loaded',
- actions: ['storeDatasetDetails'],
+ initializing: {
+ type: 'parallel',
+ states: {
+ dataStreamDetails: {
+ initial: 'fetching',
+ states: {
+ fetching: {
+ invoke: {
+ src: 'loadDataStreamDetails',
+ onDone: {
+ target: 'done',
+ actions: ['storeDatasetDetails'],
+ },
+ onError: {
+ target: 'done',
+ actions: ['fetchDatasetDetailsFailedNotifier'],
+ },
+ },
+ },
+ done: {
+ type: 'final',
+ },
+ },
},
- onError: {
- target: 'loaded',
- actions: ['fetchDatasetDetailsFailedNotifier'],
+ integrationDashboards: {
+ initial: 'fetching',
+ states: {
+ fetching: {
+ invoke: {
+ src: 'loadIntegrationDashboards',
+ onDone: {
+ target: 'done',
+ actions: ['storeIntegrationDashboards'],
+ },
+ onError: {
+ target: 'done',
+ actions: ['notifyFetchIntegrationDashboardsFailed'],
+ },
+ },
+ },
+ done: {
+ type: 'final',
+ },
+ },
},
},
+ onDone: {
+ target: '#DatasetQualityController.flyout.loaded',
+ },
},
loaded: {
on: {
@@ -159,7 +199,7 @@ export const createPureDatasetQualityControllerStateMachine = (
closed: {
on: {
OPEN_FLYOUT: {
- target: '#DatasetQualityController.flyout.fetching',
+ target: '#DatasetQualityController.flyout.initializing',
actions: ['storeFlyoutOptions'],
},
},
@@ -167,7 +207,7 @@ export const createPureDatasetQualityControllerStateMachine = (
},
on: {
SELECT_NEW_DATASET: {
- target: '#DatasetQualityController.flyout.fetching',
+ target: '#DatasetQualityController.flyout.initializing',
actions: ['storeFlyoutOptions'],
},
CLOSE_FLYOUT: {
@@ -292,6 +332,22 @@ export const createPureDatasetQualityControllerStateMachine = (
}
: {};
}),
+ storeIntegrationDashboards: assign((context, event) => {
+ return 'data' in event && 'dashboards' in event.data
+ ? {
+ flyout: {
+ ...context.flyout,
+ dataset: {
+ ...context.flyout.dataset,
+ integration: {
+ ...context.flyout.dataset?.integration,
+ dashboards: event.data.dashboards as DashboardType[],
+ },
+ } as FlyoutDataset,
+ },
+ }
+ : {};
+ }),
storeDatasets: assign((context, _event) => {
return context.dataStreamStats && context.degradedDocStats
? {
@@ -312,12 +368,14 @@ export interface DatasetQualityControllerStateMachineDependencies {
initialContext?: DatasetQualityControllerContext;
toasts: IToasts;
dataStreamStatsClient: IDataStreamsStatsClient;
+ dataStreamDetailsClient: IDataStreamDetailsClient;
}
export const createDatasetQualityControllerStateMachine = ({
initialContext = DEFAULT_CONTEXT,
toasts,
dataStreamStatsClient,
+ dataStreamDetailsClient,
}: DatasetQualityControllerStateMachineDependencies) =>
createPureDatasetQualityControllerStateMachine(initialContext).withConfig({
actions: {
@@ -327,6 +385,8 @@ export const createDatasetQualityControllerStateMachine = ({
fetchDegradedStatsFailedNotifier(toasts, event.data),
notifyFetchDatasetDetailsFailed: (_context, event: DoneInvokeEvent) =>
fetchDatasetDetailsFailedNotifier(toasts, event.data),
+ notifyFetchIntegrationDashboardsFailed: (_context, event: DoneInvokeEvent) =>
+ fetchIntegrationDashboardsFailedNotifier(toasts, event.data),
},
services: {
loadDataStreamStats: (context) =>
@@ -353,7 +413,7 @@ export const createDatasetQualityControllerStateMachine = ({
const { type, name: dataset, namespace } = context.flyout.dataset;
- return dataStreamStatsClient.getDataStreamDetails({
+ return dataStreamDetailsClient.getDataStreamDetails({
dataStream: dataStreamPartsToIndexName({
type: type as DataStreamType,
dataset,
@@ -361,6 +421,19 @@ export const createDatasetQualityControllerStateMachine = ({
}),
});
},
+ loadIntegrationDashboards: (context) => {
+ if (!context.flyout.dataset) {
+ fetchDatasetDetailsFailedNotifier(toasts, new Error(noDatasetSelected));
+
+ return Promise.resolve({});
+ }
+
+ const { integration } = context.flyout.dataset;
+
+ return integration
+ ? dataStreamDetailsClient.getIntegrationDashboards({ integration: integration.name })
+ : Promise.resolve({});
+ },
},
});
diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts
index 967458b54bf9b..64c54d088ce1b 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/public/state_machines/dataset_quality_controller/src/types.ts
@@ -11,6 +11,7 @@ import { Integration } from '../../../../common/data_streams_stats/integration';
import { Direction, SortField } from '../../../hooks';
import { DegradedDocsStat } from '../../../../common/data_streams_stats/malformed_docs_stat';
import {
+ DashboardType,
DataStreamDegradedDocsStatServiceResponse,
DataStreamDetails,
DataStreamStatServiceResponse,
@@ -115,6 +116,14 @@ export type DatasetQualityControllerTypeState =
| {
value: 'datasets.loaded';
context: DefaultDatasetQualityStateContext;
+ }
+ | {
+ value: 'flyout.initializing.dataStreamDetails.fetching';
+ context: DefaultDatasetQualityStateContext;
+ }
+ | {
+ value: 'flyout.initializing.integrationDashboards.fetching';
+ context: DefaultDatasetQualityStateContext;
};
export type DatasetQualityControllerContext = DatasetQualityControllerTypeState['context'];
@@ -165,5 +174,6 @@ export type DatasetQualityControllerEvent =
query: string;
}
| DoneInvokeEvent
+ | DoneInvokeEvent
| DoneInvokeEvent
| DoneInvokeEvent;
diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_integrations.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_integrations.ts
index 44ffdc02a6731..f13e3661942c0 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_integrations.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/get_integrations.ts
@@ -5,9 +5,56 @@
* 2.0.
*/
+import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
+import { DASHBOARD_SAVED_OBJECT_TYPE } from '@kbn/deeplinks-analytics/constants';
import { PackageClient } from '@kbn/fleet-plugin/server';
import { PackageNotFoundError } from '@kbn/fleet-plugin/server/errors';
-import { DataStreamStat, Integration } from '../../../common/api_types';
+import { Dashboard, DataStreamStat, Integration } from '../../../common/api_types';
+
+export async function getIntegrationDashboards(
+ packageClient: PackageClient,
+ savedObjectsClient: SavedObjectsClientContract,
+ integration: string
+): Promise {
+ // Retrieve integration savedObject
+ const integrationSavedObjects = await packageClient.getInstallation(integration);
+ if (!integrationSavedObjects) return [];
+
+ // Extract dashboard ids
+ const dashboardIds: string[] = [];
+ integrationSavedObjects.installed_kibana.forEach((intSavedObject) => {
+ if (intSavedObject.type === DASHBOARD_SAVED_OBJECT_TYPE) {
+ dashboardIds.push(intSavedObject.id);
+ }
+ });
+
+ // Fetch dashboards savedObject
+ // We are directly querying the SO here
+ // The dashboard service is not exposed from the server side at the moment
+ // https://github.com/elastic/kibana/issues/179759
+ const dashboardsSavedObjects = await savedObjectsClient.bulkGet<{
+ title?: string;
+ }>(
+ dashboardIds.map((id) => ({
+ id,
+ type: DASHBOARD_SAVED_OBJECT_TYPE,
+ fields: ['title'],
+ }))
+ );
+
+ // Ignore faulty dashboards
+ const allValidDashboardSavedObjects = dashboardsSavedObjects.saved_objects.filter(
+ (so) => !so.error
+ );
+
+ // Construct dashboard result
+ const packageDashboards = allValidDashboardSavedObjects.map((so) => ({
+ id: so.id,
+ title: so.attributes.title || so.id,
+ }));
+
+ return packageDashboards;
+}
export async function getIntegrations(options: {
packageClient: PackageClient;
diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/routes.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/routes.ts
index dbca7e1d0b4bd..2bd5b3435acf2 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/routes.ts
+++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/data_streams/routes.ts
@@ -13,6 +13,7 @@ import {
DataStreamStat,
DegradedDocs,
Integration,
+ IntegrationDashboards,
} from '../../../common/api_types';
import { rangeRt, typeRt } from '../../types/default_api_types';
import { createDatasetQualityServerRoute } from '../create_datasets_quality_server_route';
@@ -20,7 +21,7 @@ import { getDataStreamDetails } from './get_data_stream_details';
import { getDataStreams } from './get_data_streams';
import { getDataStreamsStats } from './get_data_streams_stats';
import { getDegradedDocsPaginated } from './get_degraded_docs';
-import { getIntegrations } from './get_integrations';
+import { getIntegrationDashboards, getIntegrations } from './get_integrations';
import { getEstimatedDataInBytes } from './get_estimated_data_in_bytes';
const statsRoute = createDatasetQualityServerRoute({
@@ -163,9 +164,40 @@ const estimatedDataInBytesRoute = createDatasetQualityServerRoute({
},
});
+const integrationDashboardsRoute = createDatasetQualityServerRoute({
+ endpoint: 'GET /internal/dataset_quality/integrations/{integration}/dashboards',
+ params: t.type({
+ path: t.type({
+ integration: t.string,
+ }),
+ }),
+ options: {
+ tags: [],
+ },
+ async handler(resources): Promise {
+ const { context, params, plugins } = resources;
+ const { integration } = params.path;
+ const { savedObjects } = await context.core;
+
+ const fleetPluginStart = await plugins.fleet.start();
+ const packageClient = fleetPluginStart.packageService.asInternalUser;
+
+ const integrationDashboards = await getIntegrationDashboards(
+ packageClient,
+ savedObjects.client,
+ integration
+ );
+
+ return {
+ dashboards: integrationDashboards,
+ };
+ },
+});
+
export const dataStreamsRouteRepository = {
...statsRoute,
...degradedDocsRoute,
...dataStreamDetailsRoute,
...estimatedDataInBytesRoute,
+ ...integrationDashboardsRoute,
};
diff --git a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json
index e59d2baacde27..37201afd73082 100644
--- a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json
+++ b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json
@@ -36,7 +36,10 @@
"@kbn/data-views-plugin",
"@kbn/shared-ux-error-boundary",
"@kbn/embeddable-plugin",
- "@kbn/es-query"
+ "@kbn/es-query",
+ "@kbn/core-saved-objects-api-server",
+ "@kbn/deeplinks-management",
+ "@kbn/deeplinks-analytics"
],
"exclude": ["target/**/*"]
}
diff --git a/x-pack/test/dataset_quality_api_integration/tests/data_streams/integration_dashboards.spec.ts b/x-pack/test/dataset_quality_api_integration/tests/data_streams/integration_dashboards.spec.ts
new file mode 100644
index 0000000000000..e29b934223a31
--- /dev/null
+++ b/x-pack/test/dataset_quality_api_integration/tests/data_streams/integration_dashboards.spec.ts
@@ -0,0 +1,116 @@
+/*
+ * 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 { DatasetQualityApiClientKey } from '../../common/config';
+import { FtrProviderContext } from '../../common/ftr_provider_context';
+
+interface IntegrationPackage {
+ name: string;
+ version: string;
+}
+
+export default function ApiTest({ getService }: FtrProviderContext) {
+ const registry = getService('registry');
+ const supertest = getService('supertest');
+ const datasetQualityApiClient = getService('datasetQualityApiClient');
+
+ const integrationPackages: IntegrationPackage[] = [
+ {
+ // with dashboards
+ name: 'postgresql',
+ version: '1.19.0',
+ },
+ {
+ // without dashboards
+ name: 'apm',
+ version: '8.4.2',
+ },
+ ];
+
+ async function installPackage({ name, version }: IntegrationPackage) {
+ return supertest
+ .post(`/api/fleet/epm/packages/${name}/${version}`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({ force: true });
+ }
+
+ async function uninstallPackage({ name, version }: IntegrationPackage) {
+ return supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx');
+ }
+
+ async function callApiAs(integration: string) {
+ const user = 'datasetQualityLogsUser' as DatasetQualityApiClientKey;
+ return await datasetQualityApiClient[user]({
+ endpoint: 'GET /internal/dataset_quality/integrations/{integration}/dashboards',
+ params: {
+ path: {
+ integration,
+ },
+ },
+ });
+ }
+
+ registry.when('Integration dashboards', { config: 'basic' }, () => {
+ describe('gets the installed integration dashboards', () => {
+ before(async () => {
+ await Promise.all(
+ integrationPackages.map((pkg: IntegrationPackage) => installPackage(pkg))
+ );
+ });
+
+ it('returns a non-empty body', async () => {
+ const resp = await callApiAs(integrationPackages[0].name);
+ expect(resp.body).not.empty();
+ });
+
+ it('returns a list of dashboards in the correct format', async () => {
+ const expectedResult = {
+ dashboards: [
+ {
+ id: 'postgresql-158be870-87f4-11e7-ad9c-db80de0bf8d3',
+ title: '[Logs PostgreSQL] Overview',
+ },
+ {
+ id: 'postgresql-4288b790-b79f-11e9-a579-f5c0a5d81340',
+ title: '[Metrics PostgreSQL] Database Overview',
+ },
+ {
+ id: 'postgresql-e4c5f230-87f3-11e7-ad9c-db80de0bf8d3',
+ title: '[Logs PostgreSQL] Query Duration Overview',
+ },
+ ],
+ };
+ const resp = await callApiAs(integrationPackages[0].name);
+ expect(resp.body).to.eql(expectedResult);
+ });
+
+ it('returns an empty array for an integration without dashboards', async () => {
+ const expectedResult = {
+ dashboards: [],
+ };
+ const resp = await callApiAs(integrationPackages[1].name);
+ expect(resp.body).to.eql(expectedResult);
+ });
+
+ it('returns an empty array for an invalid integration', async () => {
+ const expectedResult = {
+ dashboards: [],
+ };
+ const resp = await callApiAs('invalid');
+ expect(resp.body).to.eql(expectedResult);
+ });
+
+ after(
+ async () =>
+ await Promise.all(
+ integrationPackages.map((pkg: IntegrationPackage) => uninstallPackage(pkg))
+ )
+ );
+ });
+ });
+}
diff --git a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts
index b458574273e18..78060f5c29571 100644
--- a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts
+++ b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts
@@ -9,6 +9,12 @@ import expect from '@kbn/expect';
import { DatasetQualityFtrProviderContext } from './config';
import { datasetNames, getInitialTestLogs, getLogsForDataset } from './data';
+const integrationActions = {
+ overview: 'Overview',
+ template: 'Template',
+ viewDashboards: 'ViewDashboards',
+};
+
export default function ({ getService, getPageObjects }: DatasetQualityFtrProviderContext) {
const PageObjects = getPageObjects([
'common',
@@ -18,6 +24,8 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid
]);
const testSubjects = getService('testSubjects');
const synthtrace = getService('logSynthtraceEsClient');
+ const retry = getService('retry');
+ const browser = getService('browser');
const to = '2024-01-01T12:00:00.000Z';
describe('Dataset quality flyout', () => {
@@ -109,5 +117,164 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid
await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText();
expect(datasetSelectorText).to.eql(testDatasetName);
});
+
+ it('Integration actions menu is present with correct actions', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const actions = await Promise.all(
+ Object.values(integrationActions).map((action) =>
+ PageObjects.datasetQuality.getIntegrationActionButtonByAction(action)
+ )
+ );
+
+ expect(actions.length).to.eql(3);
+ });
+
+ it('Integration dashboard action hidden for integrations without dashboards', async () => {
+ const bitbucketDatasetName = 'atlassian_bitbucket.audit';
+ const bitbucketDatasetHumanName = 'Bitbucket Audit Logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.installPackage({
+ name: 'atlassian_bitbucket',
+ version: '1.14.0',
+ });
+
+ // Index 10 logs for `atlassian_bitbucket.audit` dataset
+ await synthtrace.index(getLogsForDataset({ to, count: 10, dataset: bitbucketDatasetName }));
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(bitbucketDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ await testSubjects.missingOrFail(
+ PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutIntegrationAction(
+ integrationActions.viewDashboards
+ )
+ );
+ });
+
+ it('Integration overview action should navigate to the integration overview page', async () => {
+ const bitbucketDatasetName = 'atlassian_bitbucket.audit';
+ const bitbucketDatasetHumanName = 'Bitbucket Audit Logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.installPackage({
+ name: 'atlassian_bitbucket',
+ version: '1.14.0',
+ });
+
+ // Index 10 logs for `atlassian_bitbucket.audit` dataset
+ await synthtrace.index(getLogsForDataset({ to, count: 10, dataset: bitbucketDatasetName }));
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(bitbucketDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.overview
+ );
+
+ await action.click();
+
+ await retry.tryForTime(5000, async () => {
+ const currentUrl = await browser.getCurrentUrl();
+ const parsedUrl = new URL(currentUrl);
+
+ expect(parsedUrl.pathname).to.contain('/app/integrations/detail/atlassian_bitbucket');
+ });
+ });
+
+ it('Integration template action should navigate to the index template page', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.template
+ );
+
+ await action.click();
+
+ await retry.tryForTime(5000, async () => {
+ const currentUrl = await browser.getCurrentUrl();
+ const parsedUrl = new URL(currentUrl);
+ expect(parsedUrl.pathname).to.contain(
+ `/app/management/data/index_management/templates/logs-${apacheAccessDatasetName}`
+ );
+ });
+ });
+
+ it('Integration dashboard action should navigate to the selected dashboard', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.viewDashboards
+ );
+
+ await action.click();
+
+ const dashboardButtons = await PageObjects.datasetQuality.getIntegrationDashboardButtons();
+ const firstDashboardButton = await dashboardButtons[0];
+ const dashboardText = await firstDashboardButton.getVisibleText();
+
+ await firstDashboardButton.click();
+
+ const breadcrumbText = await testSubjects.getVisibleText('breadcrumb last');
+
+ expect(breadcrumbText).to.eql(dashboardText);
+ });
});
}
diff --git a/x-pack/test/functional/page_objects/dataset_quality.ts b/x-pack/test/functional/page_objects/dataset_quality.ts
index ba1f2dbe296fc..5663ffe077011 100644
--- a/x-pack/test/functional/page_objects/dataset_quality.ts
+++ b/x-pack/test/functional/page_objects/dataset_quality.ts
@@ -60,6 +60,9 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
datasetQualityFlyoutTitle: 'datasetQualityFlyoutTitle',
datasetQualityHeaderButton: 'datasetQualityHeaderButton',
datasetQualityFlyoutFieldValue: 'datasetQualityFlyoutFieldValue',
+ datasetQualityFlyoutIntegrationActionsButton: 'datasetQualityFlyoutIntegrationActionsButton',
+ datasetQualityFlyoutIntegrationAction: (action: string) =>
+ `datasetQualityFlyoutIntegrationAction${action}`,
datasetQualityFilterBarFieldSearch: 'datasetQualityFilterBarFieldSearch',
datasetQualityIntegrationsSelectable: 'datasetQualityIntegrationsSelectable',
datasetQualityIntegrationsSelectableButton: 'datasetQualityIntegrationsSelectableButton',
@@ -237,6 +240,20 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
return testSubjects.find(testSubjectSelectors.datasetQualityHeaderButton);
},
+ openIntegrationActionsMenu() {
+ return testSubjects.click(testSubjectSelectors.datasetQualityFlyoutIntegrationActionsButton);
+ },
+
+ getIntegrationActionButtonByAction(action: string) {
+ return testSubjects.find(testSubjectSelectors.datasetQualityFlyoutIntegrationAction(action));
+ },
+
+ getIntegrationDashboardButtons() {
+ return testSubjects.findAll(
+ testSubjectSelectors.datasetQualityFlyoutIntegrationAction('Dashboard')
+ );
+ },
+
async doestTextExistInFlyout(text: string, elementSelector: string) {
const flyoutContainer: WebElementWrapper = await testSubjects.find(
testSubjectSelectors.datasetQualityFlyoutBody
diff --git a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts
index 068e0b04088a1..75c423d618f86 100644
--- a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts
+++ b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts
@@ -9,6 +9,12 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { datasetNames, getInitialTestLogs, getLogsForDataset } from './data';
+const integrationActions = {
+ overview: 'Overview',
+ template: 'Template',
+ viewDashboards: 'ViewDashboards',
+};
+
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects([
'common',
@@ -20,6 +26,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
const testSubjects = getService('testSubjects');
const synthtrace = getService('svlLogsSynthtraceClient');
+ const retry = getService('retry');
+ const browser = getService('browser');
const to = '2024-01-01T12:00:00.000Z';
describe('Dataset quality flyout', () => {
@@ -113,5 +121,164 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText();
expect(datasetSelectorText).to.eql(testDatasetName);
});
+
+ it('Integration actions menu is present with correct actions', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const actions = await Promise.all(
+ Object.values(integrationActions).map((action) =>
+ PageObjects.datasetQuality.getIntegrationActionButtonByAction(action)
+ )
+ );
+
+ expect(actions.length).to.eql(3);
+ });
+
+ it('Integration dashboard action hidden for integrations without dashboards', async () => {
+ const bitbucketDatasetName = 'atlassian_bitbucket.audit';
+ const bitbucketDatasetHumanName = 'Bitbucket Audit Logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.installPackage({
+ name: 'atlassian_bitbucket',
+ version: '1.14.0',
+ });
+
+ // Index 10 logs for `atlassian_bitbucket.audit` dataset
+ await synthtrace.index(getLogsForDataset({ to, count: 10, dataset: bitbucketDatasetName }));
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(bitbucketDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ await testSubjects.missingOrFail(
+ PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutIntegrationAction(
+ integrationActions.viewDashboards
+ )
+ );
+ });
+
+ it('Integration overview action should navigate to the integration overview page', async () => {
+ const bitbucketDatasetName = 'atlassian_bitbucket.audit';
+ const bitbucketDatasetHumanName = 'Bitbucket Audit Logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.installPackage({
+ name: 'atlassian_bitbucket',
+ version: '1.14.0',
+ });
+
+ // Index 10 logs for `atlassian_bitbucket.audit` dataset
+ await synthtrace.index(getLogsForDataset({ to, count: 10, dataset: bitbucketDatasetName }));
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(bitbucketDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.overview
+ );
+
+ await action.click();
+
+ await retry.tryForTime(5000, async () => {
+ const currentUrl = await browser.getCurrentUrl();
+ const parsedUrl = new URL(currentUrl);
+
+ expect(parsedUrl.pathname).to.contain('/app/integrations/detail/atlassian_bitbucket');
+ });
+ });
+
+ it('Integration template action should navigate to the index template page', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.template
+ );
+
+ await action.click();
+
+ await retry.tryForTime(5000, async () => {
+ const currentUrl = await browser.getCurrentUrl();
+ const parsedUrl = new URL(currentUrl);
+ expect(parsedUrl.pathname).to.contain(
+ `/app/management/data/index_management/templates/logs-${apacheAccessDatasetName}`
+ );
+ });
+ });
+
+ it('Integration dashboard action should navigate to the selected dashboard', async () => {
+ const apacheAccessDatasetName = 'apache.access';
+ const apacheAccessDatasetHumanName = 'Apache access logs';
+
+ await PageObjects.observabilityLogsExplorer.navigateTo();
+
+ // Add initial integrations
+ await PageObjects.observabilityLogsExplorer.setupInitialIntegrations();
+
+ // Index 10 logs for `logs-apache.access` dataset
+ await synthtrace.index(
+ getLogsForDataset({ to, count: 10, dataset: apacheAccessDatasetName })
+ );
+
+ await PageObjects.datasetQuality.navigateTo();
+
+ await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
+ await PageObjects.datasetQuality.openIntegrationActionsMenu();
+
+ const action = await PageObjects.datasetQuality.getIntegrationActionButtonByAction(
+ integrationActions.viewDashboards
+ );
+
+ await action.click();
+
+ const dashboardButtons = await PageObjects.datasetQuality.getIntegrationDashboardButtons();
+ const firstDashboardButton = await dashboardButtons[0];
+ const dashboardText = await firstDashboardButton.getVisibleText();
+
+ await firstDashboardButton.click();
+
+ const breadcrumbText = await testSubjects.getVisibleText('breadcrumb last');
+
+ expect(breadcrumbText).to.eql(dashboardText);
+ });
});
}
From 9e6376cc1f3aaff8f661f8698507e0314f20edfe Mon Sep 17 00:00:00 2001
From: Alexey Antonov
Date: Tue, 2 Apr 2024 15:51:20 +0300
Subject: [PATCH 21/63] fix: [Integrations > Add integration prompt][SCREEN
READER]: Images are decorative and should be removed from the accessibility
tree (#179805)
Closes: https://github.com/elastic/security-team/issues/8989
## Description
The Add integration prompt has a number of images that are decorative,
but have alt text added. This could be confusing to screen reader users
because the images do not provide additional context or information.
Let's update them to have `alt=""` attributes so the images are removed
from the accessibility tree. Screenshot attached below.
### Steps to recreate
1. Create a new Security Serverless project if none exist
2. When the project is ready, open it and go to Integrations, under the
Project Settings in the lower left navigation
3. Search for DynamoDB in the Integrations, and click on the card
4. From the DynamoDB detail view, click "Add Amazon DynamoDB"
5. When the "Ready to add your first integration?" view loads, open Dev
Tools and verify the images highlighted have alt text.
### What was done?
1. _role="presentation"_ was set to image container
2. removed extra `alt`'s attributes
---
.../add_first_integration_splash.tsx | 27 +++++--------------
1 file changed, 6 insertions(+), 21 deletions(-)
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/add_first_integration_splash.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/add_first_integration_splash.tsx
index d09178f499717..7dbfae011d64f 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/add_first_integration_splash.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/add_first_integration_splash.tsx
@@ -60,7 +60,7 @@ const CenteredEuiStepNumber = styled(EuiStepNumber)`
// step numbers are not centered in smaller layouts without this
const CenteredEuiImage = (props: EuiImageProps) => (
-
+
);
@@ -93,10 +93,7 @@ const AddIntegrationStepsIllustrations = () => {
-
+
@@ -124,10 +121,7 @@ const AddIntegrationStepsIllustrations = () => {
-
+
@@ -136,10 +130,7 @@ const AddIntegrationStepsIllustrations = () => {
-
+
@@ -166,10 +157,7 @@ const AddIntegrationStepsIllustrations = () => {
-
+
@@ -178,10 +166,7 @@ const AddIntegrationStepsIllustrations = () => {
-
+
From 7b94769f76b89b9c727f643150877248eb91ecc9 Mon Sep 17 00:00:00 2001
From: Coen Warmer
Date: Tue, 2 Apr 2024 15:28:22 +0200
Subject: [PATCH 22/63] [Observability AI Assistant] Update label in
path-labeller.yml (#179824)
## Summary
This updates the github label which is added when there are changes in
AI Assistant for Observability owned code.
---
.github/paths-labeller.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml
index 4b6f27037ea5d..68a00be960c4b 100644
--- a/.github/paths-labeller.yml
+++ b/.github/paths-labeller.yml
@@ -22,7 +22,7 @@
- 'x-pack/test/fleet_api_integration/**/*.*'
- 'Team:obs-ux-management':
- 'x-pack/plugins/observability_solution/observability/**/*.*'
-- 'Team:obs-knowledge':
+- 'Team:Obs AI Assistant':
- 'x-pack/plugins/observability_solution/observability_ai_assistant/**/*.*'
- 'x-pack/plugins/observability_solution/observability_ai_assistant_*/**/*.*'
- 'x-pack/test/observability_ai_assistant_api_integration/**/*.*'
From d44f73928ebbbcc5e1699ca40cb2d4c59ef42706 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?=
Date: Tue, 2 Apr 2024 15:41:26 +0200
Subject: [PATCH 23/63] [ObsAIAssistant] Persist settings in ES instead of
local storage (#179380)
- Update AI Assistant settings to be stored in ES via "UI Settings"
instead of persisting in local storage.
- Add setting for logs indices
- Updated UI for settings to match UI for Kibana Advanced Settings
- Removed setting to update connector from the settings page
![image](https://github.com/elastic/kibana/assets/209966/5d88a86f-5bb6-418d-ae24-cff68f38c80a)
Link to "Manage connectors" directly from chat
![image](https://github.com/elastic/kibana/assets/209966/9c443f6a-6d6a-47d0-987f-d66315c70731)
---
.../app/service_inventory/index.tsx | 2 +-
.../app/settings/general_settings/index.tsx | 2 +-
.../labs/labs_flyout.tsx | 2 +-
.../source_configuration_settings.tsx | 2 +-
.../common/utils/advanced_settings.tsx | 10 +
.../public/hooks/use_chat.test.ts | 3 +
.../hooks/use_user_preferred_language.ts | 62 +++--
.../public/index.ts | 5 +
.../server/index.ts | 5 +
.../public/hooks/use_conversation.test.tsx | 39 +--
.../kibana.jsonc | 10 +-
.../public/app.tsx | 2 +
.../public/context/app_context.tsx | 2 +
.../public/helpers/test_helper.tsx | 2 +
.../public/plugin.ts | 4 +-
.../routes/components/settings_page.test.tsx | 6 +
.../routes/components/settings_page.tsx | 2 +-
.../routes/components/settings_tab.test.tsx | 71 -----
.../public/routes/components/settings_tab.tsx | 245 ------------------
.../settings_tab/get_field_definitions.ts | 88 +++++++
.../settings_tab/persisted_settings.tsx | 100 +++++++
.../settings_tab/settings_tab.test.tsx | 120 +++++++++
.../components/settings_tab/settings_tab.tsx | 109 ++++++++
.../tsconfig.json | 7 +-
.../public/hooks/use_editable_settings.tsx | 3 +-
.../profiling/public/views/settings/index.tsx | 2 +-
26 files changed, 519 insertions(+), 386 deletions(-)
create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/advanced_settings.tsx
delete mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.test.tsx
delete mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.tsx
create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/get_field_definitions.ts
create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/persisted_settings.tsx
create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
create mode 100644 x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx
diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/index.tsx
index 6dc372c71fc13..ea6565c8cc566 100644
--- a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/index.tsx
+++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/index.tsx
@@ -295,7 +295,7 @@ export function ServiceInventory() {
});
}, [mainStatisticsStatus, mainStatisticsData.items, setScreenContext]);
- const { fields, isSaving, saveSingleSetting } = useEditableSettings('apm', [
+ const { fields, isSaving, saveSingleSetting } = useEditableSettings([
apmEnableServiceInventoryTableSearchBar,
]);
diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/settings/general_settings/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/settings/general_settings/index.tsx
index 7fdccfb9e3346..dab7e7fcc5dab 100644
--- a/x-pack/plugins/observability_solution/apm/public/components/app/settings/general_settings/index.tsx
+++ b/x-pack/plugins/observability_solution/apm/public/components/app/settings/general_settings/index.tsx
@@ -82,7 +82,7 @@ export function GeneralSettings() {
saveAll,
isSaving,
cleanUnsavedChanges,
- } = useEditableSettings('apm', apmSettingsKeys);
+ } = useEditableSettings(apmSettingsKeys);
async function handleSave() {
try {
diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/app_root/apm_header_action_menu/labs/labs_flyout.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/app_root/apm_header_action_menu/labs/labs_flyout.tsx
index 65164232ef564..5ffb120c9ce8a 100644
--- a/x-pack/plugins/observability_solution/apm/public/components/routing/app_root/apm_header_action_menu/labs/labs_flyout.tsx
+++ b/x-pack/plugins/observability_solution/apm/public/components/routing/app_root/apm_header_action_menu/labs/labs_flyout.tsx
@@ -61,7 +61,7 @@ export function LabsFlyout({ onClose }: Props) {
saveAll,
isSaving,
cleanUnsavedChanges,
- } = useEditableSettings('apm', labsItems);
+ } = useEditableSettings(labsItems);
async function handleSave() {
try {
diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx
index 3da4ab36bce36..e3b9794300e85 100644
--- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx
+++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx
@@ -60,7 +60,7 @@ export const SourceConfigurationSettings = ({
formStateChanges,
getUnsavedChanges,
} = useSourceConfigurationFormState(source?.configuration);
- const infraUiSettings = useEditableSettings('infra_metrics', [
+ const infraUiSettings = useEditableSettings([
enableInfrastructureHostsView,
enableInfrastructureProfilingIntegration,
]);
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/advanced_settings.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/advanced_settings.tsx
new file mode 100644
index 0000000000000..27c1fc452c350
--- /dev/null
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant/common/utils/advanced_settings.tsx
@@ -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.
+ */
+
+// AI Assistant
+export const aiAssistantLogsIndexPattern = 'observability:aiAssistantLogsIndexPattern';
+export const aiAssistantResponseLanguage = 'observability:aiAssistantResponseLanguage';
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_chat.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_chat.test.ts
index d120bab32b1ca..838feb18330e7 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_chat.test.ts
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_chat.test.ts
@@ -37,6 +37,9 @@ const addErrorMock = jest.fn();
jest.spyOn(useKibanaModule, 'useKibana').mockReturnValue({
services: {
+ uiSettings: {
+ get: jest.fn(),
+ },
notifications: {
toasts: {
addError: addErrorMock,
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_user_preferred_language.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_user_preferred_language.ts
index 74262cd0fc552..83b9acf9a4bb0 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_user_preferred_language.ts
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/hooks/use_user_preferred_language.ts
@@ -7,19 +7,8 @@
import { i18n } from '@kbn/i18n';
import { useCallback } from 'react';
-import useLocalStorage from 'react-use/lib/useLocalStorage';
-
-const USE_KIBANA_LOCALE_SETTING = 'Use Kibana locale setting';
-const USE_BROWSER_LANGUAGE_SETTING = 'Use browser language setting';
-
-const SPECIAL_OPTIONS = [
- {
- label: USE_KIBANA_LOCALE_SETTING,
- },
- {
- label: USE_BROWSER_LANGUAGE_SETTING,
- },
-];
+import { aiAssistantResponseLanguage } from '../../common/utils/advanced_settings';
+import { useKibana } from './use_kibana';
/* eslint-disable @typescript-eslint/naming-convention */
// Data taken from https://github.com/L-P/native-language-list/blob/master/data/langs.json
@@ -163,39 +152,46 @@ const languages: { [key: string]: string } = {
zu: 'isiZulu',
};
-const LANGUAGE_OPTIONS = SPECIAL_OPTIONS.concat(
- Object.values(languages).map((language) => ({
- label: language,
- }))
-);
+const KIBANA_LOCALE_SETTING = {
+ value: 'locale_setting',
+ label: 'Kibana locale setting',
+};
-export type UseUserPreferredLanguageResult = ReturnType;
+const BROWSER_LANGUAGE_SETTING = {
+ value: 'browser_setting',
+ label: 'Browser language setting',
+};
-export const SELECTED_LANGUAGE_LOCAL_STORAGE_KEY =
- 'xpack.observabilityAiAssistant.responseLanguage';
+export const DEFAULT_LANGUAGE_OPTION = KIBANA_LOCALE_SETTING;
+export const LANGUAGE_OPTIONS = [
+ KIBANA_LOCALE_SETTING,
+ BROWSER_LANGUAGE_SETTING,
+ ...Object.entries(languages).map(([value, label]) => ({ value, label })),
+];
+
+export type UseUserPreferredLanguageResult = ReturnType;
export function useUserPreferredLanguage() {
- const [selectedLanguage, setSelectedLanguage] = useLocalStorage(
- SELECTED_LANGUAGE_LOCAL_STORAGE_KEY,
- USE_KIBANA_LOCALE_SETTING
+ const {
+ services: { uiSettings },
+ } = useKibana();
+
+ const selectedLanguage = uiSettings.get(
+ aiAssistantResponseLanguage,
+ DEFAULT_LANGUAGE_OPTION.value
);
const getPreferredLanguage = useCallback(() => {
- if (selectedLanguage === USE_KIBANA_LOCALE_SETTING) {
+ if (selectedLanguage === KIBANA_LOCALE_SETTING.value) {
return getLanguageFromKibanaSettings();
- } else if (selectedLanguage === USE_BROWSER_LANGUAGE_SETTING) {
+ } else if (selectedLanguage === BROWSER_LANGUAGE_SETTING.value) {
return getLanguageFromBrowserSetting();
} else {
- return selectedLanguage || 'English';
+ return languages[selectedLanguage] || 'English';
}
}, [selectedLanguage]);
- return {
- selectedLanguage,
- setSelectedLanguage,
- LANGUAGE_OPTIONS,
- getPreferredLanguage,
- };
+ return { selectedLanguage, getPreferredLanguage };
}
function getLanguageFromKibanaSettings() {
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts
index 80d01706700b8..ed6ace7b662cf 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts
@@ -73,6 +73,11 @@ export type {
} from './api';
export type { UseChatResult } from './hooks/use_chat';
+export { LANGUAGE_OPTIONS, DEFAULT_LANGUAGE_OPTION } from './hooks/use_user_preferred_language';
+export {
+ aiAssistantResponseLanguage,
+ aiAssistantLogsIndexPattern,
+} from '../common/utils/advanced_settings';
export const plugin: PluginInitializer<
ObservabilityAIAssistantPublicSetup,
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/index.ts
index dd263d29d59bb..f33b3b55df8c4 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/index.ts
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/index.ts
@@ -17,6 +17,11 @@ export type {
ObservabilityAIAssistantServerSetup,
} from './types';
+export {
+ aiAssistantResponseLanguage,
+ aiAssistantLogsIndexPattern,
+} from '../common/utils/advanced_settings';
+
export const config: PluginConfigDescriptor = {
deprecations: ({ unusedFromRoot }) => [
unusedFromRoot('xpack.observability.aiAssistant.enabled', {
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx
index e190cc0c34782..ffada4c212b2f 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_conversation.test.tsx
@@ -27,11 +27,11 @@ import {
type UseConversationProps,
type UseConversationResult,
} from './use_conversation';
-import * as useKibanaModule from './use_kibana';
import { ChatState } from '@kbn/observability-ai-assistant-plugin/public';
import { createMockChatService } from '../utils/create_mock_chat_service';
import { createUseChat } from '@kbn/observability-ai-assistant-plugin/public/hooks/use_chat';
import type { NotificationsStart } from '@kbn/core/public';
+import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
let hookResult: RenderHookResult;
@@ -61,23 +61,24 @@ const mockChatService = createMockChatService();
const addErrorMock = jest.fn();
-jest.spyOn(useKibanaModule, 'useKibana').mockReturnValue({
- services: {
- plugins: {
- start: {
- observabilityAIAssistant: {
- useChat: createUseChat({
- notifications: {
- toasts: {
- addError: addErrorMock,
- },
- } as unknown as NotificationsStart,
- }),
- },
+const useKibanaMockServices = {
+ uiSettings: {
+ get: jest.fn(),
+ },
+ plugins: {
+ start: {
+ observabilityAIAssistant: {
+ useChat: createUseChat({
+ notifications: {
+ toasts: {
+ addError: addErrorMock,
+ },
+ } as unknown as NotificationsStart,
+ }),
},
},
},
-} as any);
+};
describe('useConversation', () => {
let wrapper: WrapperComponent;
@@ -85,9 +86,11 @@ describe('useConversation', () => {
beforeEach(() => {
jest.clearAllMocks();
wrapper = ({ children }) => (
-
- {children}
-
+
+
+ {children}
+
+
);
});
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc
index 0d59b0202b4e5..7595eb93543b9 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/kibana.jsonc
@@ -7,14 +7,8 @@
"server": false,
"browser": true,
"configPath": ["xpack", "observabilityAiAssistantManagement"],
- "requiredPlugins": ["management"],
- "optionalPlugins": [
- "actions",
- "home",
- "observabilityAIAssistant",
- "serverless",
- "enterpriseSearch"
- ],
+ "requiredPlugins": ["management", "observabilityAIAssistant", "observabilityShared"],
+ "optionalPlugins": ["actions", "home", "serverless", "enterpriseSearch"],
"requiredBundles": ["kibanaReact"]
}
}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/app.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/app.tsx
index add303f3bc0ea..43be7afde4feb 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/app.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/app.tsx
@@ -51,6 +51,8 @@ export const mountManagementSection = async ({ core, mountParams }: MountParams)
http: coreStart.http,
notifications: coreStart.notifications,
uiSettings: coreStart.uiSettings,
+ settings: coreStart.settings,
+ docLinks: coreStart.docLinks,
setBreadcrumbs,
}}
>
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/context/app_context.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/context/app_context.tsx
index 5637bd4429c45..db39a8134999c 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/context/app_context.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/context/app_context.tsx
@@ -14,8 +14,10 @@ export interface ContextValue extends StartDependencies {
application: CoreStart['application'];
http: HttpSetup;
notifications: CoreStart['notifications'];
+ docLinks: CoreStart['docLinks'];
setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
uiSettings: CoreStart['uiSettings'];
+ settings: CoreStart['settings'];
}
export const AppContext = createContext(null as any);
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/test_helper.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/test_helper.tsx
index ee36d365ae950..be285f70f2b57 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/test_helper.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/helpers/test_helper.tsx
@@ -59,6 +59,8 @@ export const render = (component: React.ReactNode, params?: { show: boolean }) =
value={{
http: coreStart.http,
application: coreStart.application,
+ docLinks: coreStart.docLinks,
+ settings: coreStart.settings,
notifications: coreStart.notifications,
observabilityAIAssistant: observabilityAIAssistantPluginMock.createStartContract(),
uiSettings: coreStart.uiSettings,
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts
index 3f63235e82f24..53da619c7ad1c 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/plugin.ts
@@ -26,11 +26,11 @@ export interface AiAssistantManagementObservabilityPluginStart {}
export interface SetupDependencies {
management: ManagementSetup;
home?: HomePublicPluginSetup;
- observabilityAIAssistant?: ObservabilityAIAssistantPublicSetup;
+ observabilityAIAssistant: ObservabilityAIAssistantPublicSetup;
}
export interface StartDependencies {
- observabilityAIAssistant?: ObservabilityAIAssistantPublicStart;
+ observabilityAIAssistant: ObservabilityAIAssistantPublicStart;
serverless?: ServerlessPluginStart;
enterpriseSearch?: EnterpriseSearchPublicStart;
}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx
index 14f88dcba9092..eb71723ffd9b5 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.test.tsx
@@ -20,6 +20,12 @@ const navigateToApp = jest.fn();
describe('Settings Page', () => {
beforeEach(() => {
useAppContextMock.mockReturnValue({
+ uiSettings: {
+ get: jest.fn(),
+ },
+ docLinks: {
+ links: {},
+ },
observabilityAIAssistant: {
useGenAIConnectors: () => ({ connectors: [] }),
useUserPreferredLanguage: () => ({
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx
index 34e82942d7c0f..e0bb2cd45673b 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_page.tsx
@@ -9,7 +9,7 @@ import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui';
import { useAppContext } from '../../hooks/use_app_context';
-import { SettingsTab } from './settings_tab';
+import { SettingsTab } from './settings_tab/settings_tab';
import { KnowledgeBaseTab } from './knowledge_base_tab';
import { useObservabilityAIAssistantManagementRouterParams } from '../../hooks/use_observability_management_params';
import { useObservabilityAIAssistantManagementRouter } from '../../hooks/use_observability_management_router';
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.test.tsx
deleted file mode 100644
index 80ab608bd7dbd..0000000000000
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import { fireEvent } from '@testing-library/react';
-import { render } from '../../helpers/test_helper';
-import { useAppContext } from '../../hooks/use_app_context';
-import { SettingsTab } from './settings_tab';
-
-jest.mock('../../hooks/use_app_context');
-
-const useAppContextMock = useAppContext as jest.Mock;
-
-const navigateToAppMock = jest.fn(() => Promise.resolve());
-const selectConnectorMock = jest.fn();
-
-describe('SettingsTab', () => {
- beforeEach(() => {
- useAppContextMock.mockReturnValue({
- application: { navigateToApp: navigateToAppMock },
- observabilityAIAssistant: {
- useGenAIConnectors: () => ({
- connectors: [
- { name: 'openAi', id: 'openAi' },
- { name: 'azureOpenAi', id: 'azureOpenAi' },
- { name: 'bedrock', id: 'bedrock' },
- ],
- selectConnector: selectConnectorMock,
- }),
- useUserPreferredLanguage: () => ({
- LANGUAGE_OPTIONS: [{ label: 'English' }],
- selectedLanguage: 'English',
- setSelectedLanguage: () => {},
- getPreferredLanguage: () => 'English',
- }),
- },
- });
- });
-
- it('should offer a way to configure Observability AI Assistant visibility in apps', () => {
- const { getByTestId } = render( );
-
- fireEvent.click(getByTestId('settingsTabGoToSpacesButton'));
-
- expect(navigateToAppMock).toBeCalledWith('management', { path: '/kibana/spaces' });
- });
-
- it('should offer a way to configure Gen AI connectors', () => {
- const { getByTestId } = render( );
-
- fireEvent.click(getByTestId('settingsTabGoToConnectorsButton'));
-
- expect(navigateToAppMock).toBeCalledWith('management', {
- path: '/insightsAndAlerting/triggersActionsConnectors/connectors',
- });
- });
-
- it('should allow selection of a configured Observability AI Assistant connector', () => {
- const { getByTestId } = render( );
-
- fireEvent.change(getByTestId('settingsTabGenAIConnectorSelect'), {
- target: { value: 'bedrock' },
- });
-
- expect(selectConnectorMock).toBeCalledWith('bedrock');
- });
-});
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.tsx
deleted file mode 100644
index 5d6197e247714..0000000000000
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import {
- EuiButton,
- EuiComboBox,
- EuiDescribedFormGroup,
- EuiForm,
- EuiFormRow,
- EuiPanel,
- EuiSelect,
- EuiSpacer,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { useAppContext } from '../../hooks/use_app_context';
-
-export function SettingsTab() {
- const {
- application: { navigateToApp },
- observabilityAIAssistant,
- } = useAppContext();
-
- // If the AI Assistant is not available, don't render the settings tab
- if (!observabilityAIAssistant) {
- return null;
- }
-
- const {
- connectors = [],
- selectedConnector,
- selectConnector,
- } = observabilityAIAssistant.useGenAIConnectors();
-
- const selectorOptions = connectors.map((connector) => ({
- text: connector.name,
- value: connector.id,
- }));
-
- const handleNavigateToConnectors = () => {
- navigateToApp('management', {
- path: '/insightsAndAlerting/triggersActionsConnectors/connectors',
- });
- };
-
- const handleNavigateToSpacesConfiguration = () => {
- navigateToApp('management', {
- path: '/kibana/spaces',
- });
- };
-
- const { selectedLanguage, setSelectedLanguage, LANGUAGE_OPTIONS } =
- observabilityAIAssistant.useUserPreferredLanguage();
-
- return (
- <>
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantButtonLabel',
- {
- defaultMessage:
- 'Show AI Assistant button and Contextual Insights in Observability apps',
- }
- )}
-
- }
- description={
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantDescriptionLabel',
- {
- defaultMessage:
- 'Toggle the AI Assistant button and Contextual Insights on or off in Observability apps by checking or unchecking the AI Assistant feature in Spaces > > Features.',
- }
- )}
-
- }
- >
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.goToFeatureControlsButtonLabel',
- { defaultMessage: 'Go to Spaces' }
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.connectorSettingsLabel',
- {
- defaultMessage: 'Connector settings',
- }
- )}
-
- }
- description={i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.euiDescribedFormGroup.inOrderToUseLabel',
- {
- defaultMessage:
- 'In order to use the Observability AI Assistant you must set up a Generative AI connector.',
- }
- )}
- >
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.goToConnectorsButtonLabel',
- {
- defaultMessage: 'Manage connectors',
- }
- )}
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.h4.selectDefaultConnectorLabel',
- { defaultMessage: 'Default connector' }
- )}
-
- }
- description={i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.connectYourElasticAITextLabel',
- {
- defaultMessage:
- 'Select the Generative AI connector you want to use as the default for the Observability AI Assistant.',
- }
- )}
- >
-
- {
- selectConnector(e.target.value);
- }}
- aria-label={i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.euiSelect.generativeAIProviderLabel',
- { defaultMessage: 'Generative AI provider' }
- )}
- />
-
-
-
-
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.userPreferencesLabel',
- {
- defaultMessage: 'User preferences',
- }
- )}
-
- }
- description={i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.selectYourLanguageLabel',
- {
- defaultMessage:
- 'Select the language you wish the Assistant to use when generating responses.',
- }
- )}
- >
-
- {
- setSelectedLanguage(selected[0]?.label ?? '');
- }}
- aria-label={i18n.translate(
- 'xpack.observabilityAiAssistantManagement.settingsPage.userPreferences.responseLanguageLabel',
- { defaultMessage: 'Response language' }
- )}
- />
-
-
-
-
- >
- );
-}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/get_field_definitions.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/get_field_definitions.ts
new file mode 100644
index 0000000000000..aad2cfe323d7a
--- /dev/null
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/get_field_definitions.ts
@@ -0,0 +1,88 @@
+/*
+ * 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 {
+ LANGUAGE_OPTIONS,
+ DEFAULT_LANGUAGE_OPTION,
+} from '@kbn/observability-ai-assistant-plugin/public';
+import {
+ aiAssistantLogsIndexPattern,
+ aiAssistantResponseLanguage,
+} from '@kbn/observability-ai-assistant-plugin/public';
+import { FieldDefinition } from '@kbn/management-settings-types';
+import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
+
+export function getFieldDefinitions(
+ uiSettings: IUiSettingsClient
+): Record {
+ return [
+ {
+ id: aiAssistantResponseLanguage,
+ defaultValueDisplay: DEFAULT_LANGUAGE_OPTION.label,
+ defaultValue: DEFAULT_LANGUAGE_OPTION.value,
+ displayName: i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.userPreferencesLabel',
+ {
+ defaultMessage: 'Response language',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.selectYourLanguageLabel',
+ {
+ defaultMessage:
+ 'Select the language you wish the Assistant to use when generating responses.',
+ }
+ ),
+ type: 'select',
+ options: {
+ values: LANGUAGE_OPTIONS.map(({ value }) => value),
+ labels: LANGUAGE_OPTIONS.reduce(
+ (acc, { value, label }) => ({ ...acc, [value]: label }),
+ {}
+ ),
+ },
+ },
+ {
+ id: aiAssistantLogsIndexPattern,
+ defaultValue: 'logs-*',
+ displayName: i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsTab.h3.logIndexPatternLabel',
+ { defaultMessage: 'Logs index pattern' }
+ ),
+ type: 'string',
+ description: i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.logIndexPatternDescription',
+ {
+ defaultMessage:
+ 'Index pattern used by the AI Assistant when querying for logs. Logs are categorised and used for root cause analysis',
+ }
+ ),
+ },
+ ].reduce((acc, field) => {
+ const savedValue = uiSettings.get(field.id, field.defaultValue);
+
+ const fieldDef = {
+ // defaults
+ isCustom: false,
+ isDefaultValue: false,
+ isOverridden: false,
+ unsavedFieldId: field.id,
+ ariaAttributes: {
+ ariaLabel: field.displayName,
+ },
+ defaultValueDisplay: field.defaultValueDisplay ?? field.defaultValue,
+ ...field,
+ savedValue,
+ } as FieldDefinition;
+
+ return {
+ ...acc,
+ [field.id]: fieldDef,
+ };
+ }, {});
+}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/persisted_settings.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/persisted_settings.tsx
new file mode 100644
index 0000000000000..b662e6dc8b4ae
--- /dev/null
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/persisted_settings.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useMemo, useState } from 'react';
+import { omit, isEmpty } from 'lodash';
+import { EuiSpacer } from '@elastic/eui';
+import { withSuspense } from '@kbn/shared-ux-utility';
+import { FieldRowProvider } from '@kbn/management-settings-components-field-row';
+import { UnsavedFieldChange } from '@kbn/management-settings-types';
+import { BottomBarActions } from '@kbn/observability-shared-plugin/public';
+import { i18n } from '@kbn/i18n';
+import { getFieldDefinitions } from './get_field_definitions';
+import { useAppContext } from '../../../hooks/use_app_context';
+
+const LazyFieldRow = React.lazy(async () => ({
+ default: (await import('@kbn/management-settings-components-field-row')).FieldRow,
+}));
+
+const FieldRow = withSuspense(LazyFieldRow);
+
+export function PersistedSettings() {
+ const { uiSettings, docLinks, notifications, settings } = useAppContext();
+ const [unsavedChanges, setUnsavedChanges] = useState>({});
+ const [isSaving, setIsSaving] = useState(false);
+
+ async function onSave() {
+ if (!isEmpty(unsavedChanges)) {
+ try {
+ setIsSaving(true);
+ const promises = Object.entries(unsavedChanges).map(([key, { unsavedValue }]) => {
+ return settings.client.set(key, unsavedValue);
+ });
+ await Promise.all(promises);
+
+ window.location.reload();
+ } finally {
+ setIsSaving(false);
+ }
+ }
+ }
+
+ function onDiscardChanges() {
+ setUnsavedChanges({});
+ }
+
+ const fieldDefinitions = useMemo(() => getFieldDefinitions(uiSettings), [uiSettings]);
+
+ return (
+ <>
+
+
+ {Object.values(fieldDefinitions).map((field) => {
+ return (
+ notifications.toasts.addDanger(message),
+ validateChange: async (key: string, value: any) => {
+ return { successfulValidation: true };
+ },
+ }}
+ >
+ {
+ if (!change) {
+ setUnsavedChanges((changes) => omit(changes, id));
+ return;
+ }
+
+ setUnsavedChanges((changes) => ({ ...changes, [id]: change }));
+ }}
+ />
+
+ );
+ })}
+ {!isEmpty(unsavedChanges) && (
+
+ )}
+ >
+ );
+}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
new file mode 100644
index 0000000000000..bfe41163e6bc4
--- /dev/null
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.test.tsx
@@ -0,0 +1,120 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../../../helpers/test_helper';
+import { useAppContext } from '../../../hooks/use_app_context';
+import { SettingsTab } from './settings_tab';
+import {
+ aiAssistantLogsIndexPattern,
+ aiAssistantResponseLanguage,
+} from '@kbn/observability-ai-assistant-plugin/server';
+
+jest.mock('../../../hooks/use_app_context');
+
+const useAppContextMock = useAppContext as jest.Mock;
+
+const navigateToAppMock = jest.fn(() => Promise.resolve());
+const settingsClientSet = jest.fn();
+
+describe('SettingsTab', () => {
+ beforeEach(() => {
+ useAppContextMock.mockReturnValue({
+ settings: {
+ client: {
+ set: settingsClientSet,
+ },
+ },
+ uiSettings: {
+ get: jest.fn(),
+ },
+ docLinks: {
+ links: {},
+ },
+ application: { navigateToApp: navigateToAppMock },
+ observabilityAIAssistant: {
+ useGenAIConnectors: () => ({
+ connectors: [
+ { name: 'openAi', id: 'openAi' },
+ { name: 'azureOpenAi', id: 'azureOpenAi' },
+ { name: 'bedrock', id: 'bedrock' },
+ ],
+ }),
+ useUserPreferredLanguage: () => ({
+ LANGUAGE_OPTIONS: [{ label: 'English' }],
+ selectedLanguage: 'English',
+ setSelectedLanguage: () => {},
+ getPreferredLanguage: () => 'English',
+ }),
+ },
+ });
+ });
+
+ it('should offer a way to configure Observability AI Assistant visibility in apps', () => {
+ const { getByTestId } = render( );
+
+ fireEvent.click(getByTestId('settingsTabGoToSpacesButton'));
+
+ expect(navigateToAppMock).toBeCalledWith('management', { path: '/kibana/spaces' });
+ });
+
+ it('should offer a way to configure Gen AI connectors', () => {
+ const { getByTestId } = render( );
+
+ fireEvent.click(getByTestId('settingsTabGoToConnectorsButton'));
+
+ expect(navigateToAppMock).toBeCalledWith('management', {
+ path: '/insightsAndAlerting/triggersActionsConnectors/connectors',
+ });
+ });
+
+ describe('allows updating the AI Assistant settings', () => {
+ const windowLocationReloadMock = jest.fn();
+ const windowLocationOriginal = window.location;
+
+ beforeEach(async () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ reload: windowLocationReloadMock,
+ },
+ writable: true,
+ });
+
+ const { getByTestId, container } = render( );
+
+ await waitFor(() => expect(container.querySelector('.euiLoadingSpinner')).toBeNull());
+
+ fireEvent.input(getByTestId(`management-settings-editField-${aiAssistantLogsIndexPattern}`), {
+ target: { value: 'observability-ai-assistant-*' },
+ });
+
+ fireEvent.change(
+ getByTestId(`management-settings-editField-${aiAssistantResponseLanguage}`),
+ {
+ target: { value: 'da' },
+ }
+ );
+
+ fireEvent.click(getByTestId('apmBottomBarActionsButton'));
+
+ await waitFor(() => expect(windowLocationReloadMock).toHaveBeenCalledTimes(1));
+ });
+
+ afterEach(() => {
+ window.location = windowLocationOriginal;
+ });
+
+ it('calls the settings client with correct args', async () => {
+ expect(settingsClientSet).toBeCalledWith(
+ aiAssistantLogsIndexPattern,
+ 'observability-ai-assistant-*'
+ );
+ expect(settingsClientSet).toBeCalledWith(aiAssistantResponseLanguage, 'da');
+ });
+ });
+});
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx
new file mode 100644
index 0000000000000..2745b5a82bb42
--- /dev/null
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/settings_tab/settings_tab.tsx
@@ -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 React from 'react';
+import { EuiButton, EuiDescribedFormGroup, EuiFormRow, EuiPanel } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { useAppContext } from '../../../hooks/use_app_context';
+import { PersistedSettings } from './persisted_settings';
+
+export function SettingsTab() {
+ const {
+ application: { navigateToApp },
+ } = useAppContext();
+
+ const handleNavigateToConnectors = () => {
+ navigateToApp('management', {
+ path: '/insightsAndAlerting/triggersActionsConnectors/connectors',
+ });
+ };
+
+ const handleNavigateToSpacesConfiguration = () => {
+ navigateToApp('management', {
+ path: '/kibana/spaces',
+ });
+ };
+
+ return (
+
+
+ {i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantButtonLabel',
+ {
+ defaultMessage:
+ 'Show AI Assistant button and Contextual Insights in Observability apps',
+ }
+ )}
+
+ }
+ description={
+
+ {i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.showAIAssistantDescriptionLabel',
+ {
+ defaultMessage:
+ 'Toggle the AI Assistant button and Contextual Insights on or off in Observability apps by checking or unchecking the AI Assistant feature in Spaces > > Features.',
+ }
+ )}
+
+ }
+ >
+
+
+ {i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.goToFeatureControlsButtonLabel',
+ { defaultMessage: 'Go to Spaces' }
+ )}
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.connectorSettingsLabel',
+ {
+ defaultMessage: 'Connector settings',
+ }
+ )}
+
+ }
+ description={i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.euiDescribedFormGroup.inOrderToUseLabel',
+ {
+ defaultMessage:
+ 'In order to use the Observability AI Assistant you must set up a Generative AI connector.',
+ }
+ )}
+ >
+
+
+ {i18n.translate(
+ 'xpack.observabilityAiAssistantManagement.settingsPage.goToConnectorsButtonLabel',
+ {
+ defaultMessage: 'Manage connectors',
+ }
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json
index 4e83f07086a89..b437a3f50d422 100644
--- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json
+++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/tsconfig.json
@@ -16,7 +16,12 @@
"@kbn/observability-ai-assistant-plugin",
"@kbn/serverless",
"@kbn/translations-plugin",
- "@kbn/enterprise-search-plugin"
+ "@kbn/enterprise-search-plugin",
+ "@kbn/management-settings-types",
+ "@kbn/core-ui-settings-browser",
+ "@kbn/shared-ux-utility",
+ "@kbn/management-settings-components-field-row",
+ "@kbn/observability-shared-plugin"
],
"exclude": ["target/**/*"]
}
diff --git a/x-pack/plugins/observability_solution/observability_shared/public/hooks/use_editable_settings.tsx b/x-pack/plugins/observability_solution/observability_shared/public/hooks/use_editable_settings.tsx
index caf6658b7413a..c5bf376b8b26c 100644
--- a/x-pack/plugins/observability_solution/observability_shared/public/hooks/use_editable_settings.tsx
+++ b/x-pack/plugins/observability_solution/observability_shared/public/hooks/use_editable_settings.tsx
@@ -16,7 +16,6 @@ import type {
UnsavedFieldChange,
} from '@kbn/management-settings-types';
import { normalizeSettings } from '@kbn/management-settings-utilities';
-import { ObservabilityApp } from '../../typings/common';
function getSettingsFields({
settingsKeys,
@@ -46,7 +45,7 @@ function getSettingsFields({
return fields;
}
-export function useEditableSettings(app: ObservabilityApp, settingsKeys: string[]) {
+export function useEditableSettings(settingsKeys: string[]) {
const {
services: { settings },
} = useKibana();
diff --git a/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx b/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx
index 56f6ca99e91c3..a5656c4451aed 100644
--- a/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx
+++ b/x-pack/plugins/observability_solution/profiling/public/views/settings/index.tsx
@@ -64,7 +64,7 @@ export function Settings() {
} = useProfilingDependencies();
const { fields, handleFieldChange, unsavedChanges, saveAll, isSaving, cleanUnsavedChanges } =
- useEditableSettings('profiling', [...co2Settings, ...costSettings, ...miscSettings]);
+ useEditableSettings([...co2Settings, ...costSettings, ...miscSettings]);
async function handleSave() {
try {
From 8f11146f2977e36c66617cbd2914e4669718b3fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?=
Date: Tue, 2 Apr 2024 15:59:41 +0200
Subject: [PATCH 24/63] [Part of #176153] Rendering FTR core plugin (#179757)
---
test/plugin_functional/test_suites/core_plugins/rendering.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts
index 29552079a1083..ca06f8a6a731d 100644
--- a/test/plugin_functional/test_suites/core_plugins/rendering.ts
+++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts
@@ -18,7 +18,7 @@ declare global {
* We use this global variable to track page history changes to ensure that
* navigation is done without causing a full page reload.
*/
- __RENDERING_SESSION__: string[];
+ __RENDERING_SESSION__?: string[];
}
}
@@ -39,11 +39,10 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
await appsMenu.clickLink(title);
return browser.execute(() => {
if (!('__RENDERING_SESSION__' in window)) {
- // @ts-expect-error upgrade typescript v4.9.5
window.__RENDERING_SESSION__ = [];
}
- window.__RENDERING_SESSION__.push(window.location.pathname);
+ window.__RENDERING_SESSION__!.push(window.location.pathname);
});
};
From 507f09d0b69a99f03f0c0cb19f10f1ea8584cd47 Mon Sep 17 00:00:00 2001
From: Hannah Mudge
Date: Tue, 2 Apr 2024 08:10:53 -0600
Subject: [PATCH 25/63] [Dashboard] [Controls] Fix bug with drilldowns when
source dashboard has no controls (#179485)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes https://github.com/elastic/kibana/issues/179391
## Summary
We were previously only calling `setSavedState` for the control group on
dashboard navigation **when the control group was defined in the loaded
dashboard** - however, if either the source or destination dashboard had
zero controls, this caused problems on navigation:
- If the source dashboard had at least one control and the destination
dashboard had zero, the destination dashboard's control group
`lastSavedInput` would **not** get set in `navigateToDashboard` since it
is undefined - i.e. the `lastSavedInput` on the destination dashboard's
control group would **still be equal to** the `lastSavedInput` of the
source dashboard's control group after navigation. Therefore, hitting
"reset" would replace the destination dashboard's empty control group
with the source's control group.
- If the source dashboard had zero controls and the destination
dashboard had at least one, the first step in navigation would work as
expected - the `lastSavedInput` of the destination dashboard would be
set appropriately. However, upon hitting the browser back button and
triggering `navigateToDashboard` a second time, the source dashboard's
control group's `lastSavedInput` would **not** get set properly (since
it is undefined) and would therefore still be equal to the
`lastSavedInput` of the destination dashboard. Therefore, hitting
"reset" would replace the source's empty control group with the
destination dashboard's controls.
This fixes the above scenarios by calling `setSavedState` on the control
group **even if** the last saved control group state is undefined.
### Race Condition Fix
In my testing for this, I discovered **another** bug caused by a race
condition where, on dashboard navigation, the subscription to the
control group's `initialize$` subject was firing with the **wrong**
input, so the `lastSavedFilters` would be calculated incorrectly. This
caused the dashboard to get stuck in an unsaved changes state, like so:
https://github.com/elastic/kibana/assets/8698078/14c553a9-21b3-40d0-96ac-0282e9e66911
I fixed this by removing this subscription (it was messy anyway 🙈) and
replaced this logic with individual calls to
`calculateFiltersFromSelections` - this should be easier to follow, and
we ensure that we are **always** doing this calculation with the
expected input. Since we aren't `awaiting` these calculations, it also
shouldn't slow down the control group's initialization (which was the
original reason for using a subscription).
### Checklist
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
- [x] Flaky test runner -
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5583
![image](https://github.com/elastic/kibana/assets/8698078/cd8ce150-6104-4395-b70c-7309185cc04d)
### 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)
---
.../embeddable/control_group_container.tsx | 43 ++++++-------------
.../embeddable/dashboard_container.tsx | 6 +--
2 files changed, 16 insertions(+), 33 deletions(-)
diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx
index 4d1ecc6458ce0..26ddd1c3e0769 100644
--- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx
+++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx
@@ -12,7 +12,7 @@ import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch, Provider, TypedUseSelectorHook, useSelector } from 'react-redux';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
-import { debounceTime, distinctUntilChanged, filter, first, skip } from 'rxjs/operators';
+import { debounceTime, distinctUntilChanged, skip } from 'rxjs/operators';
import { OverlayRef } from '@kbn/core/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
@@ -173,6 +173,13 @@ export class ControlGroupContainer extends Container<
this.setupSubscriptions();
const { filters, timeslice } = this.recalculateFilters();
this.publishFilters({ filters, timeslice });
+
+ this.calculateFiltersFromSelections(initialComponentState?.lastSavedInput?.panels ?? {}).then(
+ (filterOutput) => {
+ this.dispatch.setLastSavedFilters(filterOutput);
+ }
+ );
+
this.initialized$.next(true);
});
@@ -206,29 +213,6 @@ export class ControlGroupContainer extends Container<
};
private setupSubscriptions = () => {
- /**
- * on initialization, in order for comparison to be performed, calculate the last saved filters based on the
- * selections from the last saved input and save them to component state. This is done as a subscription so that
- * it can be done async without actually slowing down the loading of the controls.
- */
- this.subscriptions.add(
- this.initialized$
- .pipe(
- filter((isInitialized) => isInitialized),
- first()
- )
- .subscribe(async () => {
- const {
- componentState: { lastSavedInput },
- explicitInput: { panels },
- } = this.getState();
- const filterOutput = await this.calculateFiltersFromSelections(
- lastSavedInput?.panels ?? panels
- );
- this.dispatch.setLastSavedFilters(filterOutput);
- })
- );
-
/**
* refresh control order cache and make all panels refreshInputFromParent whenever panel orders change
*/
@@ -289,11 +273,12 @@ export class ControlGroupContainer extends Container<
);
};
- public setSavedState(lastSavedInput: PersistableControlGroupInput): void {
- batch(() => {
- this.dispatch.setLastSavedInput(lastSavedInput);
- const { filters, timeslice } = this.getState().output;
- this.dispatch.setLastSavedFilters({ filters, timeslice });
+ public setSavedState(lastSavedInput: PersistableControlGroupInput | undefined): void {
+ this.calculateFiltersFromSelections(lastSavedInput?.panels ?? {}).then((filterOutput) => {
+ batch(() => {
+ this.dispatch.setLastSavedInput(lastSavedInput);
+ this.dispatch.setLastSavedFilters(filterOutput);
+ });
});
}
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 90b4fb3094274..1a8e10f3999e0 100644
--- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx
+++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx
@@ -600,10 +600,8 @@ export class DashboardContainer
omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput')
);
this.dispatch.setManaged(loadDashboardReturn?.managed);
- if (this.controlGroup && loadDashboardReturn?.dashboardInput.controlGroupInput) {
- this.controlGroup.dispatch.setLastSavedInput(
- loadDashboardReturn?.dashboardInput.controlGroupInput
- );
+ if (this.controlGroup) {
+ this.controlGroup.setSavedState(loadDashboardReturn.dashboardInput?.controlGroupInput);
}
this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate.
this.dispatch.setLastSavedId(newSavedObjectId);
From 961df454f1ca5878da6c9eb8e2307b6c67bdd17c Mon Sep 17 00:00:00 2001
From: Alexi Doak <109488926+doakalexi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 07:15:14 -0700
Subject: [PATCH 26/63] Onboard APM Anomaly rule type with FAAD (#179196)
towards: https://github.com/elastic/kibana/issues/169867
This PR onboards APM Anomaly rule type with FAAD.
I am having trouble getting this rule to create an alert. If there is
any easy way to verify pls let me know!
---
.../register_anomaly_rule_type.test.ts | 56 +-
.../anomaly/register_anomaly_rule_type.ts | 572 +++++++++---------
2 files changed, 335 insertions(+), 293 deletions(-)
diff --git a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts
index ec77562c1b07e..8867f7cd2db4c 100644
--- a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts
+++ b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts
@@ -39,7 +39,7 @@ describe('Transaction duration anomaly alert', () => {
services.scopedClusterClient.asCurrentUser.search
).not.toHaveBeenCalled();
- expect(services.alertFactory.create).not.toHaveBeenCalled();
+ expect(services.alertsClient.report).not.toHaveBeenCalled();
});
it('ml jobs are not available', async () => {
@@ -69,7 +69,7 @@ describe('Transaction duration anomaly alert', () => {
services.scopedClusterClient.asCurrentUser.search
).not.toHaveBeenCalled();
- expect(services.alertFactory.create).not.toHaveBeenCalled();
+ expect(services.alertsClient.report).not.toHaveBeenCalled();
});
it('anomaly is less than threshold', async () => {
@@ -135,7 +135,7 @@ describe('Transaction duration anomaly alert', () => {
expect(
services.scopedClusterClient.asCurrentUser.search
).not.toHaveBeenCalled();
- expect(services.alertFactory.create).not.toHaveBeenCalled();
+ expect(services.alertsClient.report).not.toHaveBeenCalled();
});
});
@@ -154,8 +154,9 @@ describe('Transaction duration anomaly alert', () => {
] as unknown as ApmMlJob[])
);
- const { services, dependencies, executor, scheduleActions } =
- createRuleTypeMocks();
+ const { services, dependencies, executor } = createRuleTypeMocks();
+
+ services.alertsClient.report.mockReturnValue({ uuid: 'test-uuid' });
const ml = {
mlSystemProvider: () => ({
@@ -221,23 +222,38 @@ describe('Transaction duration anomaly alert', () => {
await executor({ params });
- expect(services.alertFactory.create).toHaveBeenCalledTimes(1);
+ expect(services.alertsClient.report).toHaveBeenCalledTimes(1);
- expect(services.alertFactory.create).toHaveBeenCalledWith(
- 'apm.anomaly_foo_development_type-foo'
- );
+ expect(services.alertsClient.report).toHaveBeenCalledWith({
+ actionGroup: 'threshold_met',
+ id: 'apm.anomaly_foo_development_type-foo',
+ });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'foo',
- transactionType: 'type-foo',
- environment: 'development',
- threshold: 'minor',
- triggerValue: 'critical',
- reason:
- 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.',
- viewInAppUrl:
- 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development',
- alertDetailsUrl: 'mockedAlertsLocator > getLocation',
+ expect(services.alertsClient.setAlertData).toHaveBeenCalledWith({
+ context: {
+ alertDetailsUrl: 'mockedAlertsLocator > getLocation',
+ environment: 'development',
+ reason:
+ 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.',
+ serviceName: 'foo',
+ threshold: 'minor',
+ transactionType: 'type-foo',
+ triggerValue: 'critical',
+ viewInAppUrl:
+ 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development',
+ },
+ id: 'apm.anomaly_foo_development_type-foo',
+ payload: {
+ 'kibana.alert.evaluation.threshold': 25,
+ 'kibana.alert.evaluation.value': 80,
+ 'kibana.alert.reason':
+ 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.',
+ 'kibana.alert.severity': 'critical',
+ 'processor.event': 'transaction',
+ 'service.environment': 'development',
+ 'service.name': 'foo',
+ 'transaction.type': 'type-foo',
+ },
});
});
});
diff --git a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts
index 3165a72fbe134..e8dd969cdb30b 100644
--- a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts
+++ b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts
@@ -6,7 +6,16 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import { GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server';
+import {
+ GetViewInAppRelativeUrlFnOpts,
+ ActionGroupIdsOf,
+ AlertInstanceContext as AlertContext,
+ AlertInstanceState as AlertState,
+ RuleTypeState,
+ RuleExecutorOptions,
+ AlertsClientError,
+ IRuleTypeAlerts,
+} from '@kbn/alerting-plugin/server';
import { KibanaRequest, DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import datemath from '@kbn/datemath';
import type { ESSearchResponse } from '@kbn/es-types';
@@ -23,7 +32,7 @@ import {
ALERT_SEVERITY,
ApmRuleType,
} from '@kbn/rule-data-utils';
-import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server';
+import { ObservabilityApmAlert } from '@kbn/alerts-as-data-utils';
import { addSpaceIdToPath } from '@kbn/spaces-plugin/common';
import { asyncForEach } from '@kbn/std';
import { compact } from 'lodash';
@@ -43,6 +52,7 @@ import {
APM_SERVER_FEATURE_ID,
formatAnomalyReason,
RULE_TYPES_CONFIG,
+ THRESHOLD_MET_GROUP,
} from '../../../../../common/rules/apm_rule_types';
import { asMutableArray } from '../../../../../common/utils/as_mutable_array';
import { getAlertUrlTransaction } from '../../../../../common/utils/formatters';
@@ -53,7 +63,10 @@ import {
RegisterRuleDependencies,
} from '../../register_apm_rule_types';
import { getServiceGroupFieldsForAnomaly } from './get_service_group_fields_for_anomaly';
-import { anomalyParamsSchema } from '../../../../../common/rules/schema';
+import {
+ anomalyParamsSchema,
+ ApmRuleParamsType,
+} from '../../../../../common/rules/schema';
import {
getAnomalyDetectorIndex,
getAnomalyDetectorType,
@@ -61,6 +74,13 @@ import {
const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.Anomaly];
+type AnomalyRuleTypeParams = ApmRuleParamsType[ApmRuleType.Anomaly];
+type AnomalyActionGroups = ActionGroupIdsOf;
+type AnomalyRuleTypeState = RuleTypeState;
+type AnomalyAlertState = AlertState;
+type AnomalyAlertContext = AlertContext;
+type AnomalyAlert = ObservabilityApmAlert;
+
export function registerAnomalyRuleType({
alerting,
alertsLocator,
@@ -70,296 +90,302 @@ export function registerAnomalyRuleType({
ml,
ruleDataClient,
}: RegisterRuleDependencies) {
- const createLifecycleRuleType = createLifecycleRuleTypeFactory({
- logger,
- ruleDataClient,
- });
+ if (!alerting) {
+ throw new Error(
+ 'Cannot register anomaly rule type. The alerting plugin needs to be enabled.'
+ );
+ }
- alerting.registerType(
- createLifecycleRuleType({
- id: ApmRuleType.Anomaly,
- name: ruleTypeConfig.name,
- actionGroups: ruleTypeConfig.actionGroups,
- defaultActionGroupId: ruleTypeConfig.defaultActionGroupId,
- validate: { params: anomalyParamsSchema },
- schemas: {
- params: {
- type: 'config-schema',
- schema: anomalyParamsSchema,
- },
- },
- actionVariables: {
- context: [
- apmActionVariables.alertDetailsUrl,
- apmActionVariables.environment,
- apmActionVariables.reason,
- apmActionVariables.serviceName,
- apmActionVariables.threshold,
- apmActionVariables.transactionType,
- apmActionVariables.triggerValue,
- apmActionVariables.viewInAppUrl,
- ],
+ alerting.registerType({
+ id: ApmRuleType.Anomaly,
+ name: ruleTypeConfig.name,
+ actionGroups: ruleTypeConfig.actionGroups,
+ defaultActionGroupId: ruleTypeConfig.defaultActionGroupId,
+ validate: { params: anomalyParamsSchema },
+ schemas: {
+ params: {
+ type: 'config-schema',
+ schema: anomalyParamsSchema,
},
- category: DEFAULT_APP_CATEGORIES.observability.id,
- producer: APM_SERVER_FEATURE_ID,
- minimumLicenseRequired: 'basic',
- isExportable: true,
- executor: async ({
- params,
- services,
- spaceId,
- startedAt,
- getTimeRange,
- }) => {
- if (!ml) {
- return { state: {} };
- }
+ },
+ actionVariables: {
+ context: [
+ apmActionVariables.alertDetailsUrl,
+ apmActionVariables.environment,
+ apmActionVariables.reason,
+ apmActionVariables.serviceName,
+ apmActionVariables.threshold,
+ apmActionVariables.transactionType,
+ apmActionVariables.triggerValue,
+ apmActionVariables.viewInAppUrl,
+ ],
+ },
+ category: DEFAULT_APP_CATEGORIES.observability.id,
+ producer: APM_SERVER_FEATURE_ID,
+ minimumLicenseRequired: 'basic',
+ isExportable: true,
+ executor: async (
+ options: RuleExecutorOptions<
+ AnomalyRuleTypeParams,
+ AnomalyRuleTypeState,
+ AnomalyAlertState,
+ AnomalyAlertContext,
+ AnomalyActionGroups,
+ AnomalyAlert
+ >
+ ) => {
+ if (!ml) {
+ return { state: {} };
+ }
- const {
- getAlertUuid,
- getAlertStartedDate,
- savedObjectsClient,
- scopedClusterClient,
- } = services;
+ const { params, services, spaceId, startedAt, getTimeRange } = options;
+ const { alertsClient, savedObjectsClient, scopedClusterClient } =
+ services;
+ if (!alertsClient) {
+ throw new AlertsClientError();
+ }
- const apmIndices = await getApmIndices(savedObjectsClient);
+ const apmIndices = await getApmIndices(savedObjectsClient);
- const ruleParams = params;
- const request = {} as KibanaRequest;
- const { mlAnomalySearch } = ml.mlSystemProvider(
- request,
- savedObjectsClient
- );
- const anomalyDetectors = ml.anomalyDetectorsProvider(
- request,
- savedObjectsClient
- );
+ const ruleParams = params;
+ const request = {} as KibanaRequest;
+ const { mlAnomalySearch } = ml.mlSystemProvider(
+ request,
+ savedObjectsClient
+ );
+ const anomalyDetectors = ml.anomalyDetectorsProvider(
+ request,
+ savedObjectsClient
+ );
- const mlJobs = await getMLJobs(
- anomalyDetectors,
- ruleParams.environment
- );
+ const mlJobs = await getMLJobs(anomalyDetectors, ruleParams.environment);
+
+ const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find(
+ (option) => option.type === ruleParams.anomalySeverityType
+ );
- const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find(
- (option) => option.type === ruleParams.anomalySeverityType
+ if (!selectedOption) {
+ throw new Error(
+ `Anomaly alert severity type ${ruleParams.anomalySeverityType} is not supported.`
);
+ }
- if (!selectedOption) {
- throw new Error(
- `Anomaly alert severity type ${ruleParams.anomalySeverityType} is not supported.`
- );
- }
-
- const threshold = selectedOption.threshold;
-
- if (mlJobs.length === 0) {
- return { state: {} };
- }
-
- // Lookback window must be at least 30m to support rules created before this change where default was 15m
- const minimumWindow = '30m';
- const requestedWindow = `${ruleParams.windowSize}${ruleParams.windowUnit}`;
-
- const window =
- datemath.parse(`now-${minimumWindow}`)!.valueOf() <
- datemath.parse(`now-${requestedWindow}`)!.valueOf()
- ? minimumWindow
- : requestedWindow;
-
- const { dateStart } = getTimeRange(window);
-
- const jobIds = mlJobs.map((job) => job.jobId);
- const anomalySearchParams = {
- body: {
- track_total_hits: false,
- size: 0,
- query: {
- bool: {
- filter: [
- { term: { result_type: 'record' } },
- { terms: { job_id: jobIds } },
- { term: { is_interim: false } },
- {
- range: {
- timestamp: {
- gte: dateStart,
- },
+ const threshold = selectedOption.threshold;
+
+ if (mlJobs.length === 0) {
+ return { state: {} };
+ }
+
+ // Lookback window must be at least 30m to support rules created before this change where default was 15m
+ const minimumWindow = '30m';
+ const requestedWindow = `${ruleParams.windowSize}${ruleParams.windowUnit}`;
+
+ const window =
+ datemath.parse(`now-${minimumWindow}`)!.valueOf() <
+ datemath.parse(`now-${requestedWindow}`)!.valueOf()
+ ? minimumWindow
+ : requestedWindow;
+
+ const { dateStart } = getTimeRange(window);
+
+ const jobIds = mlJobs.map((job) => job.jobId);
+ const anomalySearchParams = {
+ body: {
+ track_total_hits: false,
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { term: { result_type: 'record' } },
+ { terms: { job_id: jobIds } },
+ { term: { is_interim: false } },
+ {
+ range: {
+ timestamp: {
+ gte: dateStart,
},
},
- ...termQuery(
- 'partition_field_value',
- ruleParams.serviceName,
- { queryEmptyString: false }
- ),
- ...termQuery('by_field_value', ruleParams.transactionType, {
- queryEmptyString: false,
- }),
- ...termsQuery(
- 'detector_index',
- ...(ruleParams.anomalyDetectorTypes?.map((type) =>
- getAnomalyDetectorIndex(type)
- ) ?? [])
- ),
- ] as QueryDslQueryContainer[],
- },
- },
- aggs: {
- anomaly_groups: {
- multi_terms: {
- terms: [
- { field: 'partition_field_value' },
- { field: 'by_field_value' },
- { field: 'job_id' },
- { field: 'detector_index' },
- ],
- size: 1000,
- order: { 'latest_score.record_score': 'desc' as const },
},
- aggs: {
- latest_score: {
- top_metrics: {
- metrics: asMutableArray([
- { field: 'record_score' },
- { field: 'partition_field_value' },
- { field: 'by_field_value' },
- { field: 'job_id' },
- { field: 'timestamp' },
- { field: 'bucket_span' },
- { field: 'detector_index' },
- ] as const),
- sort: {
- timestamp: 'desc' as const,
- },
+ ...termQuery('partition_field_value', ruleParams.serviceName, {
+ queryEmptyString: false,
+ }),
+ ...termQuery('by_field_value', ruleParams.transactionType, {
+ queryEmptyString: false,
+ }),
+ ...termsQuery(
+ 'detector_index',
+ ...(ruleParams.anomalyDetectorTypes?.map((type) =>
+ getAnomalyDetectorIndex(type)
+ ) ?? [])
+ ),
+ ] as QueryDslQueryContainer[],
+ },
+ },
+ aggs: {
+ anomaly_groups: {
+ multi_terms: {
+ terms: [
+ { field: 'partition_field_value' },
+ { field: 'by_field_value' },
+ { field: 'job_id' },
+ { field: 'detector_index' },
+ ],
+ size: 1000,
+ order: { 'latest_score.record_score': 'desc' as const },
+ },
+ aggs: {
+ latest_score: {
+ top_metrics: {
+ metrics: asMutableArray([
+ { field: 'record_score' },
+ { field: 'partition_field_value' },
+ { field: 'by_field_value' },
+ { field: 'job_id' },
+ { field: 'timestamp' },
+ { field: 'bucket_span' },
+ { field: 'detector_index' },
+ ] as const),
+ sort: {
+ timestamp: 'desc' as const,
},
},
},
},
},
},
+ },
+ };
+
+ const response: ESSearchResponse =
+ (await mlAnomalySearch(anomalySearchParams, [])) as any;
+
+ const anomalies =
+ response.aggregations?.anomaly_groups.buckets
+ .map((bucket) => {
+ const latest = bucket.latest_score.top[0].metrics;
+
+ const job = mlJobs.find((j) => j.jobId === latest.job_id);
+
+ if (!job) {
+ logger.warn(
+ `Could not find matching job for job id ${latest.job_id}`
+ );
+ return undefined;
+ }
+
+ return {
+ serviceName: latest.partition_field_value as string,
+ transactionType: latest.by_field_value as string,
+ environment: job.environment,
+ score: latest.record_score as number,
+ detectorType: getAnomalyDetectorType(
+ latest.detector_index as number
+ ),
+ timestamp: Date.parse(latest.timestamp as string),
+ bucketSpan: latest.bucket_span as number,
+ bucketKey: bucket.key,
+ };
+ })
+ .filter((anomaly) =>
+ anomaly ? anomaly.score >= threshold : false
+ ) ?? [];
+
+ await asyncForEach(compact(anomalies), async (anomaly) => {
+ const {
+ serviceName,
+ environment,
+ transactionType,
+ score,
+ detectorType,
+ timestamp,
+ bucketSpan,
+ bucketKey,
+ } = anomaly;
+
+ const eventSourceFields = await getServiceGroupFieldsForAnomaly({
+ apmIndices,
+ scopedClusterClient,
+ savedObjectsClient,
+ serviceName,
+ environment,
+ transactionType,
+ timestamp,
+ bucketSpan,
+ });
+
+ const severityLevel = getSeverity(score);
+ const reasonMessage = formatAnomalyReason({
+ anomalyScore: score,
+ serviceName,
+ severityLevel,
+ windowSize: params.windowSize,
+ windowUnit: params.windowUnit,
+ detectorType,
+ });
+
+ const alertId = bucketKey.join('_');
+
+ const { uuid, start } = alertsClient.report({
+ id: alertId,
+ actionGroup: ruleTypeConfig.defaultActionGroupId,
+ });
+ const indexedStartedAt = start ?? startedAt.toISOString();
+
+ const relativeViewInAppUrl = getAlertUrlTransaction(
+ serviceName,
+ getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT],
+ transactionType
+ );
+ const viewInAppUrl = addSpaceIdToPath(
+ basePath.publicBaseUrl,
+ spaceId,
+ relativeViewInAppUrl
+ );
+ const alertDetailsUrl = await getAlertUrl(
+ uuid,
+ spaceId,
+ indexedStartedAt,
+ alertsLocator,
+ basePath.publicBaseUrl
+ );
+
+ const payload = {
+ [SERVICE_NAME]: serviceName,
+ ...getEnvironmentEsField(environment),
+ [TRANSACTION_TYPE]: transactionType,
+ [PROCESSOR_EVENT]: ProcessorEvent.transaction,
+ [ALERT_SEVERITY]: severityLevel,
+ [ALERT_EVALUATION_VALUE]: score,
+ [ALERT_EVALUATION_THRESHOLD]: threshold,
+ [ALERT_REASON]: reasonMessage,
+ ...eventSourceFields,
};
- const response: ESSearchResponse =
- (await mlAnomalySearch(anomalySearchParams, [])) as any;
-
- const anomalies =
- response.aggregations?.anomaly_groups.buckets
- .map((bucket) => {
- const latest = bucket.latest_score.top[0].metrics;
-
- const job = mlJobs.find((j) => j.jobId === latest.job_id);
-
- if (!job) {
- logger.warn(
- `Could not find matching job for job id ${latest.job_id}`
- );
- return undefined;
- }
-
- return {
- serviceName: latest.partition_field_value as string,
- transactionType: latest.by_field_value as string,
- environment: job.environment,
- score: latest.record_score as number,
- detectorType: getAnomalyDetectorType(
- latest.detector_index as number
- ),
- timestamp: Date.parse(latest.timestamp as string),
- bucketSpan: latest.bucket_span as number,
- bucketKey: bucket.key,
- };
- })
- .filter((anomaly) =>
- anomaly ? anomaly.score >= threshold : false
- ) ?? [];
-
- await asyncForEach(compact(anomalies), async (anomaly) => {
- const {
- serviceName,
- environment,
- transactionType,
- score,
- detectorType,
- timestamp,
- bucketSpan,
- bucketKey,
- } = anomaly;
-
- const eventSourceFields = await getServiceGroupFieldsForAnomaly({
- apmIndices,
- scopedClusterClient,
- savedObjectsClient,
- serviceName,
- environment,
- transactionType,
- timestamp,
- bucketSpan,
- });
-
- const severityLevel = getSeverity(score);
- const reasonMessage = formatAnomalyReason({
- anomalyScore: score,
- serviceName,
- severityLevel,
- windowSize: params.windowSize,
- windowUnit: params.windowUnit,
- detectorType,
- });
-
- const alertId = bucketKey.join('_');
-
- const alert = services.alertWithLifecycle({
- id: alertId,
- fields: {
- [SERVICE_NAME]: serviceName,
- ...getEnvironmentEsField(environment),
- [TRANSACTION_TYPE]: transactionType,
- [PROCESSOR_EVENT]: ProcessorEvent.transaction,
- [ALERT_SEVERITY]: severityLevel,
- [ALERT_EVALUATION_VALUE]: score,
- [ALERT_EVALUATION_THRESHOLD]: threshold,
- [ALERT_REASON]: reasonMessage,
- ...eventSourceFields,
- },
- });
-
- const relativeViewInAppUrl = getAlertUrlTransaction(
- serviceName,
- getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT],
- transactionType
- );
- const viewInAppUrl = addSpaceIdToPath(
- basePath.publicBaseUrl,
- spaceId,
- relativeViewInAppUrl
- );
- const indexedStartedAt =
- getAlertStartedDate(alertId) ?? startedAt.toISOString();
- const alertUuid = getAlertUuid(alertId);
- const alertDetailsUrl = await getAlertUrl(
- alertUuid,
- spaceId,
- indexedStartedAt,
- alertsLocator,
- basePath.publicBaseUrl
- );
-
- alert.scheduleActions(ruleTypeConfig.defaultActionGroupId, {
- alertDetailsUrl,
- environment: getEnvironmentLabel(environment),
- reason: reasonMessage,
- serviceName,
- threshold: selectedOption?.label,
- transactionType,
- triggerValue: severityLevel,
- viewInAppUrl,
- });
+ const context = {
+ alertDetailsUrl,
+ environment: getEnvironmentLabel(environment),
+ reason: reasonMessage,
+ serviceName,
+ threshold: selectedOption?.label,
+ transactionType,
+ triggerValue: severityLevel,
+ viewInAppUrl,
+ };
+
+ alertsClient.setAlertData({
+ id: alertId,
+ payload,
+ context,
});
+ });
- return { state: {} };
- },
- alerts: ApmRuleTypeAlertDefinition,
- getViewInAppRelativeUrl: ({ rule }: GetViewInAppRelativeUrlFnOpts<{}>) =>
- observabilityPaths.ruleDetails(rule.id),
- })
- );
+ return { state: {} };
+ },
+ alerts: {
+ ...ApmRuleTypeAlertDefinition,
+ shouldWrite: true,
+ } as IRuleTypeAlerts,
+ getViewInAppRelativeUrl: ({ rule }: GetViewInAppRelativeUrlFnOpts<{}>) =>
+ observabilityPaths.ruleDetails(rule.id),
+ });
}
From 6ecbe53736919419b87635cfc603e7cbaeb1a3f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?=
Date: Tue, 2 Apr 2024 17:00:33 +0200
Subject: [PATCH 27/63] [EDR Workflows] Add possibility to disable malware
filescan on write (#179176)
## Summary
- adds new field to Package Policy: `on_write_scan` for `malware`
protections for every OS
- adds new switch to Malware card to enable/disable this feature, but
this is hidden behind FF called `malwareOnWriteScanOptionAvailable`
- adds hint in order to indicate to the user, that disabling on-write
scan is only effective on Agent versions 8.13 and older
- adds new migration to backfill this property with default `true`
values, as it has been always enabled on previous agents. note: after
migration, policies are not re-deployed, so Endpoint must assume that
on-write scan is enabled when property is missing
### 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
- [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: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.../check_registered_types.test.ts | 2 +-
.../fleet/server/saved_objects/index.ts | 9 ++
.../migrations/security_solution/index.ts | 1 +
.../security_solution/to_v8_14_0.test.ts | 137 ++++++++++++++++
.../security_solution/to_v8_14_0.ts | 37 +++++
x-pack/plugins/fleet/tsconfig.json | 1 +
.../common/endpoint/models/policy_config.ts | 3 +
.../models/policy_config_helpers.test.ts | 6 +-
.../endpoint/models/policy_config_helpers.ts | 6 +-
.../common/endpoint/types/index.ts | 10 +-
.../common/experimental_features.ts | 5 +
.../policy/store/policy_details/index.test.ts | 10 +-
.../cards/malware_protections_card.test.tsx | 121 +++++++++-----
.../cards/malware_protections_card.tsx | 152 ++++++++++++++----
.../policy/view/policy_settings_form/mocks.ts | 21 ++-
.../policy_settings_layout.test.tsx | 16 +-
16 files changed, 429 insertions(+), 108 deletions(-)
create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts
create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts
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 fbc0d5b2ef269..097a85aea54df 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
@@ -109,7 +109,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"ingest-agent-policies": "7633e578f60c074f8267bc50ec4763845e431437",
"ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d",
"ingest-outputs": "daafff49255ab700e07491376fe89f04fc998b91",
- "ingest-package-policies": "f4c2767e852b700a8b82678925b86bac08958b43",
+ "ingest-package-policies": "8a99e165aab00c6c365540427a3abeb7bea03f31",
"ingest_manager_settings": "91445219e7115ff0c45d1dabd5d614a80b421797",
"inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83",
"kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad",
diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts
index 0e44db492b2c1..80665f381e871 100644
--- a/x-pack/plugins/fleet/server/saved_objects/index.ts
+++ b/x-pack/plugins/fleet/server/saved_objects/index.ts
@@ -73,6 +73,7 @@ import {
} from './migrations/to_v8_6_0';
import {
migratePackagePolicyToV8100,
+ migratePackagePolicyToV8140,
migratePackagePolicyToV870,
} from './migrations/security_solution';
import { migratePackagePolicyToV880 } from './migrations/to_v8_8_0';
@@ -473,6 +474,14 @@ export const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
},
],
},
+ '6': {
+ changes: [
+ {
+ type: 'data_backfill',
+ backfillFn: migratePackagePolicyToV8140,
+ },
+ ],
+ },
},
migrations: {
'7.10.0': migratePackagePolicyToV7100,
diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts
index 8248c181405e5..cd37ed56b5b58 100644
--- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts
+++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts
@@ -19,3 +19,4 @@ export { migratePackagePolicyToV860 } from './to_v8_6_0';
export { migratePackagePolicyToV870 } from './to_v8_7_0';
export { migratePackagePolicyToV880 } from './to_v8_8_0';
export { migratePackagePolicyToV8100 } from './to_v8_10_0';
+export { migratePackagePolicyToV8140 } from './to_v8_14_0';
diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts
new file mode 100644
index 0000000000000..e724c039179dc
--- /dev/null
+++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts
@@ -0,0 +1,137 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import {
+ createModelVersionTestMigrator,
+ type ModelVersionTestMigrator,
+} from '@kbn/core-test-helpers-model-versions';
+
+import { cloneDeep } from 'lodash';
+
+import type { SavedObject } from '@kbn/core-saved-objects-server';
+
+import type { PackagePolicy } from '../../../../common';
+import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../common';
+import { getSavedObjectTypes } from '../..';
+
+const policyDoc: SavedObject = {
+ id: 'mock-saved-object-id',
+ attributes: {
+ name: 'Some Policy Name',
+ package: {
+ name: 'endpoint',
+ title: '',
+ version: '',
+ },
+ id: 'endpoint',
+ policy_id: '',
+ enabled: true,
+ namespace: '',
+ revision: 0,
+ updated_at: '',
+ updated_by: '',
+ created_at: '',
+ created_by: '',
+ inputs: [
+ {
+ type: 'endpoint',
+ enabled: true,
+ streams: [],
+ config: {
+ policy: {
+ value: {
+ windows: {
+ malware: {
+ mode: 'detect',
+ blocklist: true,
+ },
+ },
+ mac: {
+ malware: {
+ mode: 'detect',
+ blocklist: true,
+ },
+ },
+ linux: {
+ malware: {
+ mode: 'detect',
+ blocklist: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
+ references: [],
+};
+
+describe('8.14.0 Endpoint Package Policy migration', () => {
+ let migrator: ModelVersionTestMigrator;
+
+ beforeEach(() => {
+ migrator = createModelVersionTestMigrator({
+ type: getSavedObjectTypes()[PACKAGE_POLICY_SAVED_OBJECT_TYPE],
+ });
+ });
+
+ it('should backfill `on_write_scan` field to malware protections on Kibana update', () => {
+ const originalPolicyConfigSO = cloneDeep(policyDoc);
+
+ const migratedPolicyConfigSO = migrator.migrate({
+ document: originalPolicyConfigSO,
+ fromVersion: 5,
+ toVersion: 6,
+ });
+
+ const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value;
+ expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(true);
+ expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true);
+ expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(true);
+ });
+
+ it('should not backfill `on_write_scan` field if already present due to user edit before migration is performed on serverless', () => {
+ const originalPolicyConfigSO = cloneDeep(policyDoc);
+ const originalPolicyConfig = originalPolicyConfigSO.attributes.inputs[0].config?.policy.value;
+ originalPolicyConfig.windows.malware.on_write_scan = false;
+ originalPolicyConfig.mac.malware.on_write_scan = true;
+ originalPolicyConfig.linux.malware.on_write_scan = false;
+
+ const migratedPolicyConfigSO = migrator.migrate({
+ document: originalPolicyConfigSO,
+ fromVersion: 5,
+ toVersion: 6,
+ });
+
+ const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value;
+ expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(false);
+ expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true);
+ expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(false);
+ });
+
+ // no reason for removing `on_write_scan` for a lower version Kibana - the field will just sit silently in the package config
+ it('should not strip `on_write_scan` in regards of forward compatibility', () => {
+ const originalPolicyConfigSO = cloneDeep(policyDoc);
+ const originalPolicyConfig = originalPolicyConfigSO.attributes.inputs[0].config?.policy.value;
+ originalPolicyConfig.windows.malware.on_write_scan = false;
+ originalPolicyConfig.mac.malware.on_write_scan = true;
+ originalPolicyConfig.linux.malware.on_write_scan = false;
+
+ const migratedPolicyConfigSO = migrator.migrate({
+ document: originalPolicyConfigSO,
+ fromVersion: 6,
+ toVersion: 5,
+ });
+
+ const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value;
+ expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(false);
+ expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true);
+ expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(false);
+ });
+});
diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts
new file mode 100644
index 0000000000000..cc97dafe72180
--- /dev/null
+++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { SavedObjectUnsanitizedDoc } from '@kbn/core/server';
+
+import type { SavedObjectModelDataBackfillFn } from '@kbn/core-saved-objects-server';
+
+import type { PackagePolicy } from '../../../../common';
+
+const ON_WRITE_SCAN_DEFAULT_VALUE = true;
+
+export const migratePackagePolicyToV8140: SavedObjectModelDataBackfillFn<
+ PackagePolicy,
+ PackagePolicy
+> = (packagePolicyDoc) => {
+ if (packagePolicyDoc.attributes.package?.name !== 'endpoint') {
+ return { attributes: packagePolicyDoc.attributes };
+ }
+
+ const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc = packagePolicyDoc;
+
+ const input = updatedPackagePolicyDoc.attributes.inputs[0];
+
+ if (input && input.config) {
+ const policy = input.config.policy.value;
+
+ policy.windows.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE;
+ policy.mac.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE;
+ policy.linux.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE;
+ }
+
+ return { attributes: updatedPackagePolicyDoc.attributes };
+};
diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json
index 5b16316d9baaa..b99fb0cce0985 100644
--- a/x-pack/plugins/fleet/tsconfig.json
+++ b/x-pack/plugins/fleet/tsconfig.json
@@ -104,5 +104,6 @@
"@kbn/config",
"@kbn/core-http-server-mocks",
"@kbn/code-editor",
+ "@kbn/core-test-helpers-model-versions",
]
}
diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts
index 91585c9b16fa1..779a309e03d32 100644
--- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts
@@ -43,6 +43,7 @@ export const policyFactory = (
malware: {
mode: ProtectionModes.prevent,
blocklist: true,
+ on_write_scan: true,
},
ransomware: {
mode: ProtectionModes.prevent,
@@ -96,6 +97,7 @@ export const policyFactory = (
malware: {
mode: ProtectionModes.prevent,
blocklist: true,
+ on_write_scan: true,
},
behavior_protection: {
mode: ProtectionModes.prevent,
@@ -138,6 +140,7 @@ export const policyFactory = (
malware: {
mode: ProtectionModes.prevent,
blocklist: true,
+ on_write_scan: true,
},
behavior_protection: {
mode: ProtectionModes.prevent,
diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts
index 8e1c4e087c827..662c47f9c8999 100644
--- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts
@@ -212,7 +212,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({
registry: true,
security: true,
},
- malware: { mode: ProtectionModes.off, blocklist: false },
+ malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false },
ransomware: { mode: ProtectionModes.off, supported: true },
memory_protection: { mode: ProtectionModes.off, supported: true },
behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false },
@@ -228,7 +228,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({
},
mac: {
events: { process: true, file: true, network: true },
- malware: { mode: ProtectionModes.off, blocklist: false },
+ malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false },
behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false },
memory_protection: { mode: ProtectionModes.off, supported: true },
popup: {
@@ -249,7 +249,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({
session_data: false,
tty_io: false,
},
- malware: { mode: ProtectionModes.off, blocklist: false },
+ malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false },
behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false },
memory_protection: { mode: ProtectionModes.off, supported: true },
popup: {
diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts
index 472fcfdfd825a..c6ffc43928bd6 100644
--- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts
@@ -103,7 +103,10 @@ const disableCommonProtections = (policy: PolicyConfig) => {
}, policy);
};
-const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: PolicyOperatingSystem) => ({
+const getDisabledCommonProtectionsForOS = (
+ policy: PolicyConfig,
+ os: PolicyOperatingSystem
+): Partial => ({
behavior_protection: {
...policy[os].behavior_protection,
mode: ProtectionModes.off,
@@ -115,6 +118,7 @@ const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: PolicyOpera
malware: {
...policy[os].malware,
blocklist: false,
+ on_write_scan: false,
mode: ProtectionModes.off,
},
});
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 f525c03ce17f7..68867e92d7294 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts
@@ -973,7 +973,7 @@ export interface PolicyConfig {
registry: boolean;
security: boolean;
};
- malware: ProtectionFields & BlocklistFields;
+ malware: ProtectionFields & BlocklistFields & OnWriteScanFields;
memory_protection: ProtectionFields & SupportedFields;
behavior_protection: BehaviorProtectionFields & SupportedFields;
ransomware: ProtectionFields & SupportedFields;
@@ -1014,7 +1014,7 @@ export interface PolicyConfig {
process: boolean;
network: boolean;
};
- malware: ProtectionFields & BlocklistFields;
+ malware: ProtectionFields & BlocklistFields & OnWriteScanFields;
behavior_protection: BehaviorProtectionFields & SupportedFields;
memory_protection: ProtectionFields & SupportedFields;
popup: {
@@ -1044,7 +1044,7 @@ export interface PolicyConfig {
session_data: boolean;
tty_io: boolean;
};
- malware: ProtectionFields & BlocklistFields;
+ malware: ProtectionFields & BlocklistFields & OnWriteScanFields;
behavior_protection: BehaviorProtectionFields & SupportedFields;
memory_protection: ProtectionFields & SupportedFields;
popup: {
@@ -1120,6 +1120,10 @@ export interface BlocklistFields {
blocklist: boolean;
}
+export interface OnWriteScanFields {
+ on_write_scan?: boolean;
+}
+
/** Policy protection mode options */
export enum ProtectionModes {
detect = 'detect',
diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts
index a6eba0197b490..72ea5b723758c 100644
--- a/x-pack/plugins/security_solution/common/experimental_features.ts
+++ b/x-pack/plugins/security_solution/common/experimental_features.ts
@@ -228,6 +228,11 @@ export const allowedExperimentalValues = Object.freeze({
* Expires: on Apr 23, 2024
*/
perFieldPrebuiltRulesDiffingEnabled: true,
+
+ /**
+ * Makes Elastic Defend integration's Malware On-Write Scan option available to edit.
+ */
+ malwareOnWriteScanOptionAvailable: false,
});
type ExperimentalConfigKeys = Array;
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts
index 1c7a8e0b18a61..d93b2aa6a1e39 100644
--- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts
@@ -12,7 +12,7 @@ import type { PolicyDetailsAction } from '.';
import { policyDetailsReducer, policyDetailsMiddlewareFactory } from '.';
import { policyConfig } from './selectors';
import { policyFactory } from '../../../../../../common/endpoint/models/policy_config';
-import type { PolicyData } from '../../../../../../common/endpoint/types';
+import type { PolicyConfig, PolicyData } from '../../../../../../common/endpoint/types';
import type { MiddlewareActionSpyHelper } from '../../../../../common/store/test_utils';
import { createSpyMiddleware } from '../../../../../common/store/test_utils';
import type { AppContextTestRender } from '../../../../../common/mock/endpoint';
@@ -289,7 +289,7 @@ describe('policy details: ', () => {
registry: true,
security: true,
},
- malware: { mode: 'prevent', blocklist: true },
+ malware: { mode: 'prevent', blocklist: true, on_write_scan: true },
memory_protection: { mode: 'off', supported: false },
behavior_protection: {
mode: 'off',
@@ -327,7 +327,7 @@ describe('policy details: ', () => {
},
mac: {
events: { process: true, file: true, network: true },
- malware: { mode: 'prevent', blocklist: true },
+ malware: { mode: 'prevent', blocklist: true, on_write_scan: true },
behavior_protection: {
mode: 'off',
supported: false,
@@ -363,7 +363,7 @@ describe('policy details: ', () => {
tty_io: false,
},
logging: { file: 'info' },
- malware: { mode: 'prevent', blocklist: true },
+ malware: { mode: 'prevent', blocklist: true, on_write_scan: true },
behavior_protection: {
mode: 'off',
supported: false,
@@ -388,7 +388,7 @@ describe('policy details: ', () => {
capture_env_vars: 'LD_PRELOAD,LD_LIBRARY_PATH',
},
},
- },
+ } as PolicyConfig,
},
},
},
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx
index f300622231c05..516b5dc835644 100644
--- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx
@@ -17,6 +17,7 @@ import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endp
import React from 'react';
import type { MalwareProtectionsProps } from './malware_protections_card';
import { MalwareProtectionsCard } from './malware_protections_card';
+import type { PolicyConfig } from '../../../../../../../../common/endpoint/types';
import { ProtectionModes } from '../../../../../../../../common/endpoint/types';
import { cloneDeep, set } from 'lodash';
import userEvent from '@testing-library/user-event';
@@ -27,11 +28,12 @@ describe('Policy Malware Protections Card', () => {
const testSubj = getPolicySettingsFormTestSubjects('test').malware;
let formProps: MalwareProtectionsProps;
- let render: () => ReturnType;
+ let render: (policyConfig?: PolicyConfig) => ReturnType;
let renderResult: ReturnType;
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
+ mockedContext.setExperimentalFlag({ malwareOnWriteScanOptionAvailable: true });
formProps = {
policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0]
@@ -41,7 +43,10 @@ describe('Policy Malware Protections Card', () => {
'data-test-subj': testSubj.card,
};
- render = () => (renderResult = mockedContext.render( ));
+ render = (policyConfig = formProps.policy) =>
+ (renderResult = mockedContext.render(
+
+ ));
});
it('should render the card with expected components', () => {
@@ -60,48 +65,72 @@ describe('Policy Malware Protections Card', () => {
);
});
- it('should set Blocklist to disabled if malware is turned off', () => {
- const expectedUpdatedPolicy = cloneDeep(formProps.policy);
- setMalwareMode(expectedUpdatedPolicy, true);
- render();
- userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch));
-
- expect(formProps.onChange).toHaveBeenCalledWith({
- isValid: true,
- updatedPolicy: expectedUpdatedPolicy,
- });
- });
-
- it('should allow blocklist to be disabled', () => {
- const expectedUpdatedPolicy = cloneDeep(formProps.policy);
- set(expectedUpdatedPolicy, 'windows.malware.blocklist', false);
- set(expectedUpdatedPolicy, 'mac.malware.blocklist', false);
- set(expectedUpdatedPolicy, 'linux.malware.blocklist', false);
- render();
- userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch));
-
- expect(formProps.onChange).toHaveBeenCalledWith({
- isValid: true,
- updatedPolicy: expectedUpdatedPolicy,
- });
- });
-
- it('should allow blocklist to be enabled', () => {
- set(formProps.policy, 'windows.malware.blocklist', false);
- set(formProps.policy, 'mac.malware.blocklist', false);
- set(formProps.policy, 'linux.malware.blocklist', false);
- const expectedUpdatedPolicy = cloneDeep(formProps.policy);
- set(expectedUpdatedPolicy, 'windows.malware.blocklist', true);
- set(expectedUpdatedPolicy, 'mac.malware.blocklist', true);
- set(expectedUpdatedPolicy, 'linux.malware.blocklist', true);
- render();
- userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch));
-
- expect(formProps.onChange).toHaveBeenCalledWith({
- isValid: true,
- updatedPolicy: expectedUpdatedPolicy,
- });
- });
+ describe.each`
+ name | config | default
+ ${'blocklist'} | ${'blocklist'} | ${true}
+ ${'onWriteScan'} | ${'on_write_scan'} | ${true}
+ `(
+ '$name subfeature',
+ (feature: { name: 'blocklist' | 'onWriteScan'; config: string; deafult: boolean }) => {
+ it(`should set ${feature.name} to disabled if malware is turned off`, () => {
+ const expectedUpdatedPolicy = cloneDeep(formProps.policy);
+ setMalwareMode(expectedUpdatedPolicy, true);
+ render();
+ userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch));
+
+ expect(formProps.onChange).toHaveBeenCalledWith({
+ isValid: true,
+ updatedPolicy: expectedUpdatedPolicy,
+ });
+ });
+
+ it(`should set ${feature.name} to enabled if malware is turned on`, () => {
+ const expectedUpdatedPolicy = cloneDeep(formProps.policy);
+ setMalwareMode(expectedUpdatedPolicy);
+ const initialPolicy = cloneDeep(formProps.policy);
+ setMalwareMode(initialPolicy, true);
+ render(initialPolicy);
+
+ userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch));
+
+ expect(formProps.onChange).toHaveBeenCalledWith({
+ isValid: true,
+ updatedPolicy: expectedUpdatedPolicy,
+ });
+ });
+
+ it(`should allow ${feature.name} to be disabled`, () => {
+ const expectedUpdatedPolicy = cloneDeep(formProps.policy);
+ set(expectedUpdatedPolicy, `windows.malware.${feature.config}`, false);
+ set(expectedUpdatedPolicy, `mac.malware.${feature.config}`, false);
+ set(expectedUpdatedPolicy, `linux.malware.${feature.config}`, false);
+ render();
+ userEvent.click(renderResult.getByTestId(testSubj[`${feature.name}EnableDisableSwitch`]));
+
+ expect(formProps.onChange).toHaveBeenCalledWith({
+ isValid: true,
+ updatedPolicy: expectedUpdatedPolicy,
+ });
+ });
+
+ it(`should allow ${feature.name} to be enabled`, () => {
+ set(formProps.policy, `windows.malware.${feature.config}`, false);
+ set(formProps.policy, `mac.malware.${feature.config}`, false);
+ set(formProps.policy, `linux.malware.${feature.config}`, false);
+ const expectedUpdatedPolicy = cloneDeep(formProps.policy);
+ set(expectedUpdatedPolicy, `windows.malware.${feature.config}`, true);
+ set(expectedUpdatedPolicy, `mac.malware.${feature.config}`, true);
+ set(expectedUpdatedPolicy, `linux.malware.${feature.config}`, true);
+ render();
+ userEvent.click(renderResult.getByTestId(testSubj[`${feature.name}EnableDisableSwitch`]));
+
+ expect(formProps.onChange).toHaveBeenCalledWith({
+ isValid: true,
+ updatedPolicy: expectedUpdatedPolicy,
+ });
+ });
+ }
+ );
describe('and displayed in View mode', () => {
beforeEach(() => {
@@ -124,6 +153,8 @@ describe('Policy Malware Protections Card', () => {
'Prevent' +
'Blocklist enabled' +
'Info' +
+ 'Scan files upon modification' +
+ 'Info' +
'User notification' +
'Agent version 7.11+' +
'Notify user' +
@@ -168,6 +199,8 @@ describe('Policy Malware Protections Card', () => {
'Prevent' +
'Blocklist enabled' +
'Info' +
+ 'Scan files upon modification' +
+ 'Info' +
'User notification' +
'Agent version 7.11+' +
'Notify user'
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx
index 0e5bec1d110c2..3b0f01e1b8d2d 100644
--- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx
+++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx
@@ -7,10 +7,10 @@
import React, { memo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiSwitch, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui';
import { OperatingSystem } from '@kbn/securitysolution-utils';
import { cloneDeep } from 'lodash';
+import { useIsExperimentalFeatureEnabled } from '../../../../../../../common/hooks/use_experimental_features';
import { useGetProtectionsUnavailableComponent } from '../../hooks/use_get_protections_unavailable_component';
import { NotifyUserOption } from '../notify_user_option';
import { SettingCard } from '../setting_card';
@@ -26,29 +26,83 @@ import { ProtectionSettingCardSwitch } from '../protection_setting_card_switch';
import { DetectPreventProtectionLevel } from '../detect_prevent_protection_level';
import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator';
-const BLOCKLIST_ENABLED_LABEL = i18n.translate(
- 'xpack.securitySolution.endpoint.policy.protections.blocklistEnabled',
- {
+const BLOCKLIST_LABELS = {
+ enabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.blocklistEnabled', {
defaultMessage: 'Blocklist enabled',
+ }),
+ disabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.blocklistDisabled', {
+ defaultMessage: 'Blocklist disabled',
+ }),
+ hint: i18n.translate('xpack.securitySolution.endpoint.policyDetailsConfig.blocklistTooltip', {
+ defaultMessage:
+ 'Enables or disables the blocklist associated with this policy. The blocklist is a collection hashes, paths, or signers which extends the list of processes the endpoint considers malicious. See the blocklist tab for entry details.',
+ }),
+};
+
+const ON_WRITE_SCAN_LABELS = {
+ enabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.onWriteScanEnabled', {
+ defaultMessage: 'Scan files upon modification',
+ }),
+ disabled: i18n.translate(
+ 'xpack.securitySolution.endpoint.policy.protections.onWriteScanDisabled',
+ {
+ defaultMessage: 'Files are not scanned upon modification',
+ }
+ ),
+ hint: i18n.translate('xpack.securitySolution.endpoint.policyDetailsConfig.onWriteScanTooltip', {
+ defaultMessage:
+ "Enables or disables scanning files when they're modified. Disabling this feature improves Endpoint performance.",
+ }),
+ versionCompatibilityHint: i18n.translate(
+ 'xpack.securitySolution.endpoint.policy.protections.onWriteVersionCompatibilityHint',
+ {
+ defaultMessage: 'Always enabled on Agent versions 8.13 and older.',
+ }
+ ),
+};
+
+type AdjustSubfeatureOnProtectionSwitch = NonNullable<
+ ProtectionSettingCardSwitchProps['additionalOnSwitchChange']
+>;
+
+// NOTE: it mutates `policyConfigData` passed on input
+const adjustBlocklistSettingsOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({
+ value,
+ policyConfigData,
+ protectionOsList,
+}) => {
+ for (const os of protectionOsList) {
+ policyConfigData[os].malware.blocklist = value;
}
-);
-const BLOCKLIST_DISABLED_LABEL = i18n.translate(
- 'xpack.securitySolution.endpoint.policy.protections.blocklistDisabled',
- {
- defaultMessage: 'Blocklist disabled',
+ return policyConfigData;
+};
+
+const adjustOnWriteSettingsOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({
+ value,
+ policyConfigData,
+ protectionOsList,
+}) => {
+ for (const os of protectionOsList) {
+ policyConfigData[os].malware.on_write_scan = value;
}
-);
-// NOTE: it mutates `policyConfigData` passed on input
-const adjustBlocklistSettingsOnProtectionSwitch: ProtectionSettingCardSwitchProps['additionalOnSwitchChange'] =
- ({ value, policyConfigData, protectionOsList }) => {
- for (const os of protectionOsList) {
- policyConfigData[os].malware.blocklist = value;
- }
+ return policyConfigData;
+};
- return policyConfigData;
- };
+const adjustAllSubfeaturesOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({
+ policyConfigData,
+ ...rest
+}) => {
+ const modifiedPolicy = adjustBlocklistSettingsOnProtectionSwitch({
+ policyConfigData,
+ ...rest,
+ });
+ return adjustOnWriteSettingsOnProtectionSwitch({
+ policyConfigData: modifiedPolicy,
+ ...rest,
+ });
+};
const MALWARE_OS_VALUES: Immutable = [
PolicyOperatingSystem.windows,
@@ -64,6 +118,9 @@ export type MalwareProtectionsProps = PolicyFormComponentCommonProps;
*/
export const MalwareProtectionsCard = React.memo(
({ policy, onChange, mode = 'edit', 'data-test-subj': dataTestSubj }) => {
+ const isMalwareOnwriteScanOptionAvailable = useIsExperimentalFeatureEnabled(
+ 'malwareOnWriteScanOptionAvailable'
+ );
const getTestId = useTestIdGenerator(dataTestSubj);
const isProtectionsAllowed = !useGetProtectionsUnavailableComponent();
const protection = 'malware';
@@ -95,7 +152,7 @@ export const MalwareProtectionsCard = React.memo(
protection={protection}
protectionLabel={protectionLabel}
osList={MALWARE_OS_VALUES}
- additionalOnSwitchChange={adjustBlocklistSettingsOnProtectionSwitch}
+ additionalOnSwitchChange={adjustAllSubfeaturesOnProtectionSwitch}
policy={policy}
onChange={onChange}
mode={mode}
@@ -113,13 +170,31 @@ export const MalwareProtectionsCard = React.memo(
/>
-
+ {isMalwareOnwriteScanOptionAvailable && (
+ <>
+
+
+ >
+ )}
+
(
MalwareProtectionsCard.displayName = 'MalwareProtectionsCard';
-type EnableDisableBlocklistProps = PolicyFormComponentCommonProps;
+type SubfeatureSwitchProps = PolicyFormComponentCommonProps & {
+ labels: { enabled: string; disabled: string; versionCompatibilityHint?: string; hint: string };
+ adjustSubfeatureOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch;
+ checked: boolean;
+};
-const EnableDisableBlocklist = memo(
- ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => {
+const SubfeatureSwitch = memo(
+ ({
+ policy,
+ onChange,
+ mode,
+ 'data-test-subj': dataTestSubj,
+ labels,
+ adjustSubfeatureOnProtectionSwitch,
+ checked,
+ }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
- const checked = policy.windows.malware.blocklist;
+
const isDisabled = policy.windows.malware.mode === 'off';
const isEditMode = mode === 'edit';
- const label = checked ? BLOCKLIST_ENABLED_LABEL : BLOCKLIST_DISABLED_LABEL;
+ const label = checked ? labels.enabled : labels.disabled;
const handleBlocklistSwitchChange = useCallback(
(event) => {
const value = event.target.checked;
const newPayload = cloneDeep(policy);
- adjustBlocklistSettingsOnProtectionSwitch({
+ adjustSubfeatureOnProtectionSwitch({
value,
policyConfigData: newPayload,
protectionOsList: MALWARE_OS_VALUES,
@@ -159,7 +246,7 @@ const EnableDisableBlocklist = memo