diff --git a/docs/api-generated/cases/case-apis-passthru.asciidoc b/docs/api-generated/cases/case-apis-passthru.asciidoc index d983ab1d2a099..f007882af237c 100644 --- a/docs/api-generated/cases/case-apis-passthru.asciidoc +++ b/docs/api-generated/cases/case-apis-passthru.asciidoc @@ -556,7 +556,7 @@ Any modifications made to this file will be overwritten.
Query Parameter — The page number to return. default: 1
perPage (optional)
-
Query Parameter — The number of items to return. default: 20
sortOrder (optional)
+
Query Parameter — The number of items to return. Limited to 100 items. default: 20
sortOrder (optional)
Query Parameter — Determines the sort order. default: desc
@@ -724,7 +724,9 @@ Any modifications made to this file will be overwritten.
assignees (optional)
-
Query Parameter — Filters the returned cases by assignees. Valid values are none or unique identifiers for the user profiles. These identifiers can be found by using the suggest user profile API. default: null
defaultSearchOperator (optional)
+
Query Parameter — Filters the returned cases by assignees. Valid values are none or unique identifiers for the user profiles. These identifiers can be found by using the suggest user profile API. default: null
category (optional)
+ +
Query Parameter — Filters the returned cases by category. Limited to 100 categories. default: null
defaultSearchOperator (optional)
Query Parameter — The default operator to use for the simple_query_string. default: OR
from (optional)
diff --git a/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx b/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx index d3cb3789138f1..783e666bbc9e2 100644 --- a/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx +++ b/packages/kbn-chart-icons/src/assets/annotation_icons/circle.tsx @@ -8,8 +8,6 @@ import React from 'react'; import { EuiIconProps } from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { noFill } from '../common_styles'; export const IconCircle = ({ title, titleId, ...props }: Omit) => ( {title ? {title} : null} - + ); diff --git a/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx b/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx index 227dc84335821..fda92d5906fcd 100644 --- a/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx +++ b/packages/kbn-chart-icons/src/assets/annotation_icons/triangle.tsx @@ -8,8 +8,6 @@ import React from 'react'; import { EuiIconProps } from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { noFill } from '../common_styles'; export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( {title ? {title} : null} - + ); diff --git a/packages/kbn-chart-icons/src/assets/common_styles.tsx b/packages/kbn-chart-icons/src/assets/common_styles.tsx index 21409b5cb6670..79cb54e7f2839 100644 --- a/packages/kbn-chart-icons/src/assets/common_styles.tsx +++ b/packages/kbn-chart-icons/src/assets/common_styles.tsx @@ -17,7 +17,3 @@ export const colors = { fill: ${euiThemeVars.euiColorVis0}; `, }; - -export const noFill = css` - fill: none; -`; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss b/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss index 06566258c3c39..644eec1749e97 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss +++ b/src/plugins/chart_expressions/expression_xy/public/components/annotations.scss @@ -13,7 +13,7 @@ } .xyAnnotationIcon_rotate90 { - transform: rotate(45deg); + transform: rotate(90deg) !important; transform-origin: center; } diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx index 93cf570e86bc1..4e58cb315242f 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/annotations.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Position } from '@elastic/charts'; import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; -import classnames from 'classnames'; import type { IconPosition, ReferenceLineDecorationConfig, @@ -180,12 +179,7 @@ export const AnnotationIcon = ({ {...rest} data-test-subj="xyVisAnnotationIcon" type={iconConfig.icon || type} - className={classnames( - { [rotateClassName]: iconConfig.shouldRotate }, - { - lensAnnotationIconFill: renderedInChart && iconConfig.canFill, - } - )} + className={iconConfig.shouldRotate ? rotateClassName : undefined} /> ); }; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts index 652dbe168f58b..daf53ccc53fde 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/icon.ts @@ -57,7 +57,6 @@ export const iconSet = [ defaultMessage: 'Circle', }), icon: IconCircle, - canFill: true, }, { @@ -113,6 +112,5 @@ export const iconSet = [ }), icon: IconTriangle, shouldRotate: true, - canFill: true, }, ]; diff --git a/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts b/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts index e4ea40acb48ed..36f8d4de9e7d4 100644 --- a/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts +++ b/src/plugins/event_annotation/public/components/annotation_editor_controls/icon_set.ts @@ -48,7 +48,6 @@ export const annotationsIconSet: IconSet = [ defaultMessage: 'Circle', }), icon: IconCircle, - canFill: true, }, { @@ -106,6 +105,5 @@ export const annotationsIconSet: IconSet = [ }), icon: IconTriangle, shouldRotate: true, - canFill: true, }, ]; diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts index 040019c13616c..8532db45672de 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.test.ts @@ -32,66 +32,183 @@ describe('getBaseUrl', () => { }); describe('getChannel', () => { - it('throws on unknown channel', () => { - expect(() => - // @ts-expect-error - getChannel('ANY') - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); - }); + describe('Classic offering', () => { + it('throws on unknown channel', () => { + expect(() => + // @ts-expect-error + getChannel('ANY', false) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); - it('returns correct snapshot channel name', () => { - const channelName = getChannel('snapshot'); - expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot"`); + it('returns correct snapshot channel name', () => { + const channelName = getChannel('snapshot', false); + expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot"`); + }); + + it('returns correct optInStatus channel name', () => { + const channelName = getChannel('optInStatus', false); + expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports"`); + }); }); - it('returns correct optInStatus channel name', () => { - const channelName = getChannel('optInStatus'); - expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports"`); + describe('Serverless offering', () => { + it('throws on unknown channel', () => { + expect(() => + // @ts-expect-error + getChannel('ANY', true) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); + + it('returns correct snapshot channel name', () => { + const channelName = getChannel('snapshot', true); + expect(channelName).toMatchInlineSnapshot(`"kibana-snapshot-serverless"`); + }); + + it('returns correct optInStatus channel name', () => { + const channelName = getChannel('optInStatus', true); + expect(channelName).toMatchInlineSnapshot(`"kibana-opt-in-reports-serverless"`); + }); }); }); describe('getTelemetryChannelEndpoint', () => { - it('throws on unknown env', () => { - expect(() => - // @ts-expect-error - getTelemetryChannelEndpoint({ env: 'ANY', channelName: 'snapshot' }) - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); - }); + describe('Classic offering', () => { + it('throws on unknown env', () => { + expect(() => + getTelemetryChannelEndpoint({ + // @ts-expect-error + env: 'ANY', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); + }); - it('throws on unknown channelName', () => { - expect(() => - // @ts-expect-error - getTelemetryChannelEndpoint({ env: 'prod', channelName: 'ANY' }) - ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); - }); + it('throws on unknown channelName', () => { + expect(() => + getTelemetryChannelEndpoint({ + env: 'prod', + // @ts-expect-error + channelName: 'ANY', + appendServerlessChannelsSuffix: false, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); + }); - describe('snapshot channel', () => { - it('returns correct prod endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'prod', channelName: 'snapshot' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry.elastic.co/v3/send/kibana-snapshot"` - ); + describe('snapshot channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-snapshot"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'snapshot', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot"` + ); + }); }); - it('returns correct staging endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'staging', channelName: 'snapshot' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot"` - ); + + describe('optInStatus channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: false, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports"` + ); + }); }); }); - describe('optInStatus channel', () => { - it('returns correct prod endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'prod', channelName: 'optInStatus' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports"` - ); + describe('Serverless offering', () => { + it('throws on unknown env', () => { + expect(() => + getTelemetryChannelEndpoint({ + // @ts-expect-error + env: 'ANY', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry endpoint env ANY."`); + }); + + it('throws on unknown channelName', () => { + expect(() => + getTelemetryChannelEndpoint({ + env: 'prod', + // @ts-expect-error + channelName: 'ANY', + appendServerlessChannelsSuffix: true, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unknown telemetry channel ANY."`); }); - it('returns correct staging endpoint', () => { - const endpoint = getTelemetryChannelEndpoint({ env: 'staging', channelName: 'optInStatus' }); - expect(endpoint).toMatchInlineSnapshot( - `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports"` - ); + + describe('snapshot channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-snapshot-serverless"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'snapshot', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-snapshot-serverless"` + ); + }); + }); + + describe('optInStatus channel', () => { + it('returns correct prod endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'prod', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry.elastic.co/v3/send/kibana-opt-in-reports-serverless"` + ); + }); + it('returns correct staging endpoint', () => { + const endpoint = getTelemetryChannelEndpoint({ + env: 'staging', + channelName: 'optInStatus', + appendServerlessChannelsSuffix: true, + }); + expect(endpoint).toMatchInlineSnapshot( + `"https://telemetry-staging.elastic.co/v3/send/kibana-opt-in-reports-serverless"` + ); + }); }); }); }); diff --git a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts index 75d83611b8c8d..ae86603356129 100644 --- a/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_channel_endpoint.ts @@ -18,17 +18,26 @@ export type TelemetryEnv = 'staging' | 'prod'; export interface GetTelemetryChannelEndpointConfig { channelName: ChannelName; env: TelemetryEnv; + appendServerlessChannelsSuffix: boolean; } -export function getChannel(channelName: ChannelName): string { +export function getChannel( + channelName: ChannelName, + appendServerlessChannelsSuffix: boolean +): string { + let channel: string; switch (channelName) { case 'snapshot': - return TELEMETRY_CHANNELS.SNAPSHOT_CHANNEL; + channel = TELEMETRY_CHANNELS.SNAPSHOT_CHANNEL; + break; case 'optInStatus': - return TELEMETRY_CHANNELS.OPT_IN_STATUS_CHANNEL; + channel = TELEMETRY_CHANNELS.OPT_IN_STATUS_CHANNEL; + break; default: throw new Error(`Unknown telemetry channel ${channelName}.`); } + + return appendServerlessChannelsSuffix ? `${channel}-serverless` : channel; } export function getBaseUrl(env: TelemetryEnv): string { @@ -45,9 +54,10 @@ export function getBaseUrl(env: TelemetryEnv): string { export function getTelemetryChannelEndpoint({ channelName, env, + appendServerlessChannelsSuffix, }: GetTelemetryChannelEndpointConfig): string { const baseUrl = getBaseUrl(env); - const channelPath = getChannel(channelName); + const channelPath = getChannel(channelName, appendServerlessChannelsSuffix); return `${baseUrl}${ENDPOINT_VERSION}/send/${channelPath}`; } diff --git a/src/plugins/telemetry/public/mocks.ts b/src/plugins/telemetry/public/mocks.ts index afe8f7037dca5..c4ecd11426b90 100644 --- a/src/plugins/telemetry/public/mocks.ts +++ b/src/plugins/telemetry/public/mocks.ts @@ -38,6 +38,7 @@ export function mockTelemetryService({ optIn: true, banner: true, allowChangingOptInStatus: true, + appendServerlessChannelsSuffix: false, telemetryNotifyUserAboutOptInDefault: true, userCanChangeSettings: true, labels: {}, diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 9411212abebe9..4447be3f6640e 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -103,6 +103,8 @@ export interface TelemetryPluginConfig { hidePrivacyStatement?: boolean; /** Extra labels to add to the telemetry context */ labels: Record; + /** Whether to use Serverless-specific channels when reporting Snapshot Telemetry */ + appendServerlessChannelsSuffix: boolean; } function getTelemetryConstants(docLinks: DocLinksStart): TelemetryConstants { diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index 58e60d0db1f30..0629630fea48a 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -99,14 +99,22 @@ export class TelemetryService { /** Retrieve the opt-in/out notification URL **/ public getOptInStatusUrl = () => { - const { sendUsageTo } = this.config; - return getTelemetryChannelEndpoint({ channelName: 'optInStatus', env: sendUsageTo }); + const { appendServerlessChannelsSuffix, sendUsageTo } = this.config; + return getTelemetryChannelEndpoint({ + channelName: 'optInStatus', + env: sendUsageTo, + appendServerlessChannelsSuffix, + }); }; /** Retrieve the URL to report telemetry **/ public getTelemetryUrl = () => { - const { sendUsageTo } = this.config; - return getTelemetryChannelEndpoint({ channelName: 'snapshot', env: sendUsageTo }); + const { appendServerlessChannelsSuffix, sendUsageTo } = this.config; + return getTelemetryChannelEndpoint({ + channelName: 'snapshot', + env: sendUsageTo, + appendServerlessChannelsSuffix, + }); }; /** diff --git a/src/plugins/telemetry/server/config/config.test.ts b/src/plugins/telemetry/server/config/config.test.ts new file mode 100644 index 0000000000000..b5640695abe41 --- /dev/null +++ b/src/plugins/telemetry/server/config/config.test.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { config } from './config'; + +describe('config', () => { + const baseContext = { + dist: true, + serverless: false, + }; + + describe('expect different defaults', () => { + test('on non-distributable', () => { + expect(config.schema.validate({}, { ...baseContext, dist: false })).toEqual( + expect.objectContaining({ + sendUsageTo: 'staging', + }) + ); + }); + + test('on distributable', () => { + expect(config.schema.validate({}, { ...baseContext, dist: true })).toEqual( + expect.objectContaining({ + sendUsageTo: 'prod', + }) + ); + }); + + test('on non-serverless', () => { + expect(config.schema.validate({}, { ...baseContext, serverless: false })).toEqual( + expect.objectContaining({ + appendServerlessChannelsSuffix: false, + }) + ); + }); + + test('on serverless', () => { + expect(config.schema.validate({}, { ...baseContext, serverless: true })).toEqual( + expect.objectContaining({ + appendServerlessChannelsSuffix: true, + }) + ); + }); + }); + + describe('appendServerlessChannelsSuffix', () => { + test.each([true, false])( + 'do not allow changing the default value (serverless: %p)', + (serverless) => { + expect(() => + config.schema.validate( + { appendServerlessChannelsSuffix: !serverless }, + { ...baseContext, serverless } + ) + ).toThrow(); + } + ); + }); +}); diff --git a/src/plugins/telemetry/server/config/config.ts b/src/plugins/telemetry/server/config/config.ts index 2a085c5bc239e..96c195864af7a 100644 --- a/src/plugins/telemetry/server/config/config.ts +++ b/src/plugins/telemetry/server/config/config.ts @@ -33,6 +33,13 @@ const configSchema = schema.object({ sendUsageFrom: schema.oneOf([schema.literal('server'), schema.literal('browser')], { defaultValue: 'server', }), + appendServerlessChannelsSuffix: schema.conditional( + schema.contextRef('serverless'), + true, + schema.literal(true), + schema.literal(false), + { defaultValue: schema.contextRef('serverless') } + ), // Used for extra enrichment of telemetry labels: labelsSchema, }); @@ -44,6 +51,7 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { banner: true, allowChangingOptInStatus: true, + appendServerlessChannelsSuffix: true, optIn: true, sendUsageFrom: true, sendUsageTo: true, diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 66f75f8582ec3..a79e043551775 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -202,6 +202,7 @@ export class FetcherTask { const allowChangingOptInStatus = config.allowChangingOptInStatus; const configTelemetryOptIn = typeof config.optIn === 'undefined' ? null : config.optIn; const telemetryUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix: config.appendServerlessChannelsSuffix, channelName: 'snapshot', env: config.sendUsageTo, }); diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index 49ec1fdf27756..ddbf7704d3838 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -205,8 +205,9 @@ export class TelemetryPlugin implements Plugin { - const { sendUsageTo } = await firstValueFrom(config$); + const { appendServerlessChannelsSuffix, sendUsageTo } = await firstValueFrom(config$); const telemetryUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix, env: sendUsageTo, channelName: 'snapshot', }); diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts index cc477c4f23198..f523031e181ed 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -90,10 +90,10 @@ export function registerTelemetryOptInRoutes({ ); if (config.sendUsageFrom === 'server') { - const { sendUsageTo } = config; + const { appendServerlessChannelsSuffix, sendUsageTo } = config; sendTelemetryOptInStatus( telemetryCollectionManager, - { sendUsageTo, newOptInStatus, currentKibanaVersion }, + { appendServerlessChannelsSuffix, sendUsageTo, newOptInStatus, currentKibanaVersion }, statsGetterConfig ).catch((err) => { // The server is likely behind a firewall and can't reach the remote service diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts index 3989e4c750567..bc26f0d2f46a7 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.test.ts @@ -26,6 +26,7 @@ describe('sendTelemetryOptInStatus', () => { it('calls fetch with the opt in status returned from the telemetryCollectionManager', async () => { const mockConfig = { + appendServerlessChannelsSuffix: false, sendUsageTo: 'prod' as const, newOptInStatus: true, currentKibanaVersion: 'mock_kibana_version', @@ -57,6 +58,7 @@ describe('sendTelemetryOptInStatus', () => { it('sends to staging endpoint on "sendUsageTo: staging"', async () => { const mockConfig = { + appendServerlessChannelsSuffix: false, sendUsageTo: 'staging' as const, newOptInStatus: true, currentKibanaVersion: 'mock_kibana_version', diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts index 8c9c85172ced6..f715c84fc9341 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts @@ -20,6 +20,7 @@ import { getTelemetryChannelEndpoint } from '../../common/telemetry_config'; import { PAYLOAD_CONTENT_ENCODING } from '../../common/constants'; interface SendTelemetryOptInStatusConfig { + appendServerlessChannelsSuffix: boolean; sendUsageTo: 'staging' | 'prod'; newOptInStatus: boolean; currentKibanaVersion: string; @@ -30,8 +31,10 @@ export async function sendTelemetryOptInStatus( config: SendTelemetryOptInStatusConfig, statsGetterConfig: StatsGetterConfig ): Promise { - const { sendUsageTo, newOptInStatus, currentKibanaVersion } = config; + const { appendServerlessChannelsSuffix, sendUsageTo, newOptInStatus, currentKibanaVersion } = + config; const optInStatusUrl = getTelemetryChannelEndpoint({ + appendServerlessChannelsSuffix, env: sendUsageTo, channelName: 'optInStatus', }); diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index d713986321f2d..57fa0b68affc8 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -261,6 +261,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "currentKibanaVersion": "mock_kibana_version", "defaultConfig": Object { "allowChangingOptInStatus": false, + "appendServerlessChannelsSuffix": false, "banner": true, "labels": Object {}, "optIn": true, diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx index 7576c0f35b6a9..a635f5668f52b 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.test.tsx @@ -25,6 +25,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, sendUsageTo: 'staging', banner: true, allowChangingOptInStatus: true, @@ -57,6 +58,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -109,6 +111,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -155,6 +158,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: false, optIn: true, @@ -192,6 +196,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -233,6 +238,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: true, optIn: false, @@ -281,6 +287,7 @@ describe('TelemetryManagementSectionComponent', () => { const onQueryMatchChange = jest.fn(); const telemetryService = new TelemetryService({ config: { + appendServerlessChannelsSuffix: false, banner: true, allowChangingOptInStatus: false, optIn: false, diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts index 79dacc2ed1fd0..a0efbaf8de869 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts @@ -51,7 +51,7 @@ const action: Action = { execute: () => Promise.resolve(), }; -const dataViewMock = { id: '1', toSpec: () => ({}) } as DataView; +const dataViewMock = { id: '1', toSpec: () => ({}), isTimeBased: () => true } as DataView; describe('categorize_trigger_utils', () => { afterEach(() => { diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts index 560671c10d1c1..371425d7e9d14 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts @@ -46,7 +46,12 @@ export async function canCategorize( field: DataViewField, dataView: DataView | undefined ): Promise { - if (field.name === '_id' || !dataView?.id || !field.esTypes?.includes('text')) { + if ( + field.name === '_id' || + !dataView?.id || + !dataView.isTimeBased() || + !field.esTypes?.includes('text') + ) { return false; } diff --git a/src/plugins/visualization_ui_components/public/components/icon_select.tsx b/src/plugins/visualization_ui_components/public/components/icon_select.tsx index 5b9d30c6cfb77..e515bf9c4d62c 100644 --- a/src/plugins/visualization_ui_components/public/components/icon_select.tsx +++ b/src/plugins/visualization_ui_components/public/components/icon_select.tsx @@ -26,7 +26,6 @@ export type IconSet = Array<{ label: string; icon?: T | IconType; shouldRotate?: boolean; - canFill?: boolean; }>; const IconView = (props: { value?: string; label: string; icon?: IconType }) => { diff --git a/test/functional/apps/visualize/group2/_metric_chart.ts b/test/functional/apps/visualize/group2/_metric_chart.ts index 16faf916acba0..b70bde00f9d13 100644 --- a/test/functional/apps/visualize/group2/_metric_chart.ts +++ b/test/functional/apps/visualize/group2/_metric_chart.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); - describe('metric chart', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/160164 + describe.skip('metric chart', function () { before(async function () { await PageObjects.visualize.initTests(); log.debug('navigateToApp visualize'); diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index d2f053d378c18..ca79d4de25172 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -141,6 +141,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'newsfeed.service.pathTemplate (string)', 'newsfeed.service.urlRoot (string)', 'telemetry.allowChangingOptInStatus (boolean)', + 'telemetry.appendServerlessChannelsSuffix (any)', // It's a boolean (any because schema.conditional) 'telemetry.banner (boolean)', 'telemetry.labels.branch (string)', 'telemetry.labels.ciBuildId (string)', @@ -263,7 +264,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.upgrade_assistant.featureSet.mlSnapshots (boolean)', 'xpack.upgrade_assistant.featureSet.reindexCorrectiveActions (boolean)', 'xpack.upgrade_assistant.ui.enabled (boolean)', - 'xpack.observability.coPilot.enabled (boolean)', + 'xpack.observability.aiAssistant.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)', 'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)', diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 93b77dc7c4378..d14c082f96905 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -30,6 +30,7 @@ const createSetupMock = () => { getSubActionConnectorClass: jest.fn(), getCaseConnectorClass: jest.fn(), getActionsHealth: jest.fn(), + getActionsConfigurationUtilities: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index b044babaa22e1..8e44714129557 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -67,7 +67,7 @@ import { ActionsRequestHandlerContext, } from './types'; -import { getActionsConfigurationUtilities } from './actions_config'; +import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from './actions_config'; import { defineRoutes } from './routes'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; @@ -129,6 +129,7 @@ export interface PluginSetupContract { getSubActionConnectorClass: () => IServiceAbstract; getCaseConnectorClass: () => IServiceAbstract; getActionsHealth: () => { hasPermanentEncryptionKey: boolean }; + getActionsConfigurationUtilities: () => ActionsConfigurationUtilities; } export interface PluginStartContract { @@ -370,6 +371,7 @@ export class ActionsPlugin implements Plugin actionsConfigUtils, }; } diff --git a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx index 7604bd6781329..a39c86be9de12 100644 --- a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { LazyField } from '@kbn/advanced-settings-plugin/public'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { apmLabsButton, apmServiceGroupMaxNumberOfServices, @@ -19,8 +18,6 @@ import { apmAWSLambdaRequestCostPerMillion, apmEnableServiceMetrics, apmEnableContinuousRollups, - enableInfrastructureHostsView, - enableAwsLambdaMetrics, enableAgentExplorerView, } from '@kbn/observability-plugin/common'; import { isEmpty } from 'lodash'; @@ -39,13 +36,11 @@ const apmSettingsKeys = [ apmAWSLambdaRequestCostPerMillion, apmEnableServiceMetrics, apmEnableContinuousRollups, - enableInfrastructureHostsView, - enableAwsLambdaMetrics, enableAgentExplorerView, ]; export function GeneralSettings() { - const { docLinks, notifications, application } = useApmPluginContext().core; + const { docLinks, notifications } = useApmPluginContext().core; const { handleFieldChange, settingsEditableConfig, @@ -77,33 +72,6 @@ export function GeneralSettings() { return ( <> - -

- - {i18n.translate('xpack.apm.apmSettings.kibanaLink.label', { - defaultMessage: 'Kibana advanced settings.', - })} - - ), - }} - /> -

-
{apmSettingsKeys.map((settingKey) => { const editableConfig = settingsEditableConfig[settingKey]; diff --git a/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx b/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx index fa74a2a361bcb..b9d609eee0772 100644 --- a/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/slo_callout/index.tsx @@ -17,6 +17,7 @@ import { encode } from '@kbn/rison'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; interface Props { dismissCallout: () => void; @@ -48,7 +49,7 @@ export function SloCallout({ type: 'sli.apm.transactionErrorRate', params: { service: serviceName, - environment, + environment: environment === ENVIRONMENT_ALL.value ? '*' : environment, transactionName: transactionName ?? '', transactionType: transactionType ?? '', }, diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 8d9bd960c42e6..8f1e7a1264166 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -258,7 +258,6 @@ export const CasesFindRequestRt = rt.exact( /** * The field to use for sorting the found objects. * - * This only supports, `create_at`, `closed_at`, and `status` */ sortField: rt.string, /** diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 0407315328dfb..1a49de004e73f 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -104,6 +104,8 @@ export const MAX_DOCS_PER_PAGE = 10000 as const; export const MAX_BULK_GET_ATTACHMENTS = MAX_DOCS_PER_PAGE; export const MAX_CONCURRENT_SEARCHES = 10 as const; export const MAX_BULK_GET_CASES = 1000 as const; +export const MAX_COMMENTS_PER_PAGE = 100 as const; +export const MAX_CATEGORY_FILTER_LENGTH = 100 as const; /** * Validation diff --git a/x-pack/plugins/cases/common/utils/validators.test.ts b/x-pack/plugins/cases/common/utils/validators.test.ts index f196d0120affe..78675a18422ff 100644 --- a/x-pack/plugins/cases/common/utils/validators.test.ts +++ b/x-pack/plugins/cases/common/utils/validators.test.ts @@ -72,6 +72,10 @@ describe('validators', () => { it('returns true if the category is an empty string', () => { expect(isCategoryFieldInvalidString('')).toBe(true); }); + + it('returns true if the string contains only spaces', () => { + expect(isCategoryFieldInvalidString(' ')).toBe(true); + }); }); describe('isCategoryFieldTooLong', () => { diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index aee1e1aa751a5..78ba5444f40aa 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -236,6 +236,25 @@ ] } }, + { + "name": "category", + "in": "query", + "description": "Filters the returned cases by category. Limited to 100 categories.", + "schema": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "example": "my-category" + }, { "name": "defaultSearchOperator", "in": "query", @@ -1833,7 +1852,15 @@ "$ref": "#/components/parameters/page_index" }, { - "$ref": "#/components/parameters/page_size" + "name": "perPage", + "in": "query", + "description": "The number of items to return. Limited to 100 items.", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } }, { "$ref": "#/components/parameters/sort_order" diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 8b93529b76b7e..405cf4fb689f0 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -140,6 +140,16 @@ paths: - type: array items: type: string + - name: category + in: query + description: Filters the returned cases by category. Limited to 100 categories. + schema: + oneOf: + - type: string + - type: array + items: + type: string + example: my-category - name: defaultSearchOperator in: query description: The default operator to use for the simple_query_string. @@ -1146,7 +1156,14 @@ paths: parameters: - $ref: '#/components/parameters/case_id' - $ref: '#/components/parameters/page_index' - - $ref: '#/components/parameters/page_size' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '#/components/parameters/sort_order' - $ref: '#/components/parameters/space_id' responses: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml index 4ddd52db231f2..e04fbfe020c9f 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@_find.yaml @@ -18,6 +18,16 @@ get: - type: array items: type: string + - name: category + in: query + description: Filters the returned cases by category. Limited to 100 categories. + schema: + oneOf: + - type: string + - type: array + items: + type: string + example: my-category - name: defaultSearchOperator in: query description: The default operator to use for the simple_query_string. diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml index b1dd32a659515..bb43f4dcc0b26 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml @@ -10,7 +10,14 @@ get: parameters: - $ref: '../components/parameters/case_id.yaml' - $ref: '../components/parameters/page_index.yaml' - - $ref: '../components/parameters/page_size.yaml' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '../components/parameters/sort_order.yaml' - $ref: '../components/parameters/space_id.yaml' responses: diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 985857d349e58..7dfed5d7188de 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -13,7 +13,6 @@ import { ThemeProvider } from 'styled-components'; import type { RenderOptions, RenderResult } from '@testing-library/react'; import type { ILicense } from '@kbn/licensing-plugin/public'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { ScopedFilesClient } from '@kbn/files-plugin/public'; import { euiDarkVars } from '@kbn/ui-theme'; @@ -220,27 +219,3 @@ export const createAppMockRenderer = ({ getFilesClient, }; }; - -export const useFormFieldMock = (options?: Partial>): FieldHook => ({ - path: 'path', - type: 'type', - value: 'mockedValue' as unknown as T, - isPristine: false, - isDirty: false, - isModified: false, - isValidating: false, - isValidated: false, - isChangingValue: false, - errors: [], - isValid: true, - getErrorsMessages: jest.fn(), - onChange: jest.fn(), - setValue: jest.fn(), - setErrors: jest.fn(), - clearErrors: jest.fn(), - validate: jest.fn(), - reset: jest.fn(), - __isIncludedInOutput: true, - __serializeValue: jest.fn(), - ...options, -}); diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.tsx similarity index 68% rename from x-pack/plugins/cases/public/common/test_utils.ts rename to x-pack/plugins/cases/public/common/test_utils.tsx index db6dfaaf11c31..66e1bd6bd8656 100644 --- a/x-pack/plugins/cases/public/common/test_utils.ts +++ b/x-pack/plugins/cases/public/common/test_utils.tsx @@ -5,9 +5,14 @@ * 2.0. */ +/* eslint-disable react/display-name */ + +import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import type { MatcherFunction } from '@testing-library/react'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { EuiButton } from '@elastic/eui'; /** * Convenience utility to remove text appended to links by EUI @@ -39,3 +44,23 @@ export const createQueryWithMarkup = ); return hasText(node) && childrenDontHaveText; }); + +interface FormTestComponentProps { + formDefaultValue?: Record; + onSubmit?: jest.Mock; +} + +export const FormTestComponent: React.FC = ({ + children, + onSubmit, + formDefaultValue, +}) => { + const { form } = useForm({ onSubmit, defaultValue: formDefaultValue }); + + return ( +
+ {children} + form.submit()}>{'Submit'} +
+ ); +}; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 0aa5b9de5cb3e..f427789332610 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -235,7 +235,7 @@ export const ADD_CATEGORY = i18n.translate('xpack.cases.caseView.addCategory', { defaultMessage: 'added the category', }); -export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.removeCategory', { +export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.userAction.removeCategory', { defaultMessage: 'removed the category', }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 4fc84b7cf68e0..0df062cad52b7 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -38,7 +38,11 @@ import { useGetSupportedActionConnectors } from '../../containers/configure/use_ import { useGetTags } from '../../containers/use_get_tags'; import { useGetCategories } from '../../containers/use_get_categories'; import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetCases, DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; +import { + useGetCases, + DEFAULT_QUERY_PARAMS, + DEFAULT_FILTER_OPTIONS, +} from '../../containers/use_get_cases'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; @@ -82,7 +86,6 @@ const mockKibana = () => { }; describe('AllCasesListGeneric', () => { - const refetchCases = jest.fn(); const onRowClick = jest.fn(); const updateCaseProperty = jest.fn(); @@ -90,7 +93,6 @@ describe('AllCasesListGeneric', () => { const defaultGetCases = { ...useGetCasesMockState, - refetch: refetchCases, }; const defaultColumnArgs = { @@ -151,8 +153,8 @@ describe('AllCasesListGeneric', () => { jest.clearAllMocks(); appMockRenderer = createAppMockRenderer(); useGetCasesMock.mockReturnValue(defaultGetCases); - useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); - useGetCategoriesMock.mockReturnValue({ data: ['twix', 'snickers'], refetch: jest.fn() }); + useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], isLoading: false }); + useGetCategoriesMock.mockReturnValue({ data: ['twix', 'snickers'], isLoading: false }); useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); @@ -294,34 +296,22 @@ describe('AllCasesListGeneric', () => { }); }); - it('renders the title column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_title_0')).toBeInTheDocument(); - }); - - it('renders the category column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_category_4')).toBeInTheDocument(); - }); - - it('renders the updated on column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_updatedAt_6')).toBeInTheDocument(); - }); - - it('renders the status column', async () => { - appMockRenderer.render(); - - expect(screen.getByTestId('tableHeaderCell_status_8')).toBeInTheDocument(); - }); - - it('renders the severity column', async () => { - appMockRenderer.render(); + it('renders the columns correctly', async () => { + appMockRenderer.render(); - expect(screen.getByTestId('tableHeaderCell_severity_9')).toBeInTheDocument(); + const casesTable = within(screen.getByTestId('cases-table')); + + expect(casesTable.getByTitle('Name')).toBeInTheDocument(); + expect(casesTable.getByTitle('Category')).toBeInTheDocument(); + expect(casesTable.getByTitle('Created on')).toBeInTheDocument(); + expect(casesTable.getByTitle('Updated on')).toBeInTheDocument(); + expect(casesTable.getByTitle('Status')).toBeInTheDocument(); + expect(casesTable.getByTitle('Severity')).toBeInTheDocument(); + expect(casesTable.getByTitle('Tags')).toBeInTheDocument(); + expect(casesTable.getByTitle('Alerts')).toBeInTheDocument(); + expect(casesTable.getByTitle('Comments')).toBeInTheDocument(); + expect(casesTable.getByTitle('External incident')).toBeInTheDocument(); + expect(casesTable.getByTitle('Actions')).toBeInTheDocument(); }); it('should not render table utility bar when isSelectorView=true', async () => { @@ -403,9 +393,7 @@ describe('AllCasesListGeneric', () => { it('should sort by status', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_status_8')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Status')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -423,20 +411,17 @@ describe('AllCasesListGeneric', () => { it('should render Name, Category, CreatedOn and Severity columns when isSelectorView=true', async () => { appMockRenderer.render(); await waitFor(() => { - expect(screen.getByTestId('tableHeaderCell_title_0')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_category_1')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_createdAt_2')).toBeInTheDocument(); - expect(screen.getByTestId('tableHeaderCell_severity_3')).toBeInTheDocument(); - expect(screen.queryByTestId('tableHeaderCell_assignees_1')).not.toBeInTheDocument(); + expect(screen.getByTitle('Name')).toBeInTheDocument(); + expect(screen.getByTitle('Category')).toBeInTheDocument(); + expect(screen.getByTitle('Created on')).toBeInTheDocument(); + expect(screen.getByTitle('Severity')).toBeInTheDocument(); }); }); it('should sort by severity', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_severity_9')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Severity')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -454,9 +439,7 @@ describe('AllCasesListGeneric', () => { it('should sort by title', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_title_0')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Name')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -474,9 +457,7 @@ describe('AllCasesListGeneric', () => { it('should sort by updatedOn', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_updatedAt_6')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Updated on')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -494,9 +475,7 @@ describe('AllCasesListGeneric', () => { it('should sort by category', async () => { appMockRenderer.render(); - userEvent.click( - within(screen.getByTestId('tableHeaderCell_category_4')).getByTestId('tableHeaderSortButton') - ); + userEvent.click(screen.getByTitle('Category')); await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( @@ -511,6 +490,26 @@ describe('AllCasesListGeneric', () => { }); }); + it('should filter by category', async () => { + appMockRenderer.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('options-filter-popover-item-twix')); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + searchFields: [], + owner: ['securitySolution'], + category: ['twix'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + }); + }); + }); + it('should filter by status: closed', async () => { appMockRenderer.render(); userEvent.click(screen.getByTestId('case-status-filter')); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 6431ce23f5ec0..c08febcaaff91 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -32,7 +32,6 @@ jest.mock('../../containers/use_get_categories'); jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); const onFilterChanged = jest.fn(); -const refetch = jest.fn(); const setFilterRefetch = jest.fn(); const props = { @@ -53,8 +52,11 @@ describe('CasesTableFilters ', () => { beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); - (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch }); - (useGetCategories as jest.Mock).mockReturnValue({ data: ['twix', 'snickers'], refetch }); + (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], isLoading: false }); + (useGetCategories as jest.Mock).mockReturnValue({ + data: ['twix', 'snickers'], + isLoading: false, + }); (useSuggestUserProfiles as jest.Mock).mockReturnValue({ data: userProfiles, isLoading: false }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx index 2f90aff69c60e..36384b9b4cd6f 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx @@ -188,10 +188,12 @@ describe('AssignUsers', () => { appMockRender.render(); fireEvent.mouseEnter( - screen.getByTestId(`user-profile-assigned-user-group-${userProfiles[0].user.username}`) + screen.getByTestId(`user-profile-assigned-user-${userProfiles[0].user.username}-remove-group`) ); fireEvent.click( - screen.getByTestId(`user-profile-assigned-user-cross-${userProfiles[0].user.username}`) + screen.getByTestId( + `user-profile-assigned-user-${userProfiles[0].user.username}-remove-button` + ) ); await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); @@ -268,8 +270,8 @@ describe('AssignUsers', () => { }; appMockRender.render(); - fireEvent.mouseEnter(screen.getByTestId(`user-profile-assigned-user-group-unknownId1`)); - fireEvent.click(screen.getByTestId(`user-profile-assigned-user-cross-unknownId1`)); + fireEvent.mouseEnter(screen.getByTestId(`user-profile-assigned-user-unknownId1-remove-group`)); + fireEvent.click(screen.getByTestId(`user-profile-assigned-user-unknownId1-remove-button`)); await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); @@ -291,8 +293,12 @@ describe('AssignUsers', () => { appMockRender.render(); expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); - expect(screen.getByTestId('user-profile-assigned-user-group-unknownId1')).toBeInTheDocument(); - expect(screen.getByTestId('user-profile-assigned-user-group-unknownId2')).toBeInTheDocument(); + expect( + screen.getByTestId('user-profile-assigned-user-unknownId1-remove-group') + ).toBeInTheDocument(); + expect( + screen.getByTestId('user-profile-assigned-user-unknownId2-remove-group') + ).toBeInTheDocument(); }); it('calls onAssigneesChanged with both users with profiles and without', async () => { diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx index 0f5846fcd610d..9e5db12f98ff1 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_category.test.tsx @@ -104,6 +104,11 @@ describe('EditCategory ', () => { }); userEvent.type(screen.getByRole('combobox'), `${categories[0]}{enter}`); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith(categories[0])); @@ -119,6 +124,11 @@ describe('EditCategory ', () => { }); userEvent.type(screen.getByRole('combobox'), 'new{enter}'); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith('new')); @@ -159,11 +169,63 @@ describe('EditCategory ', () => { }); userEvent.click(screen.getByTestId('comboBoxClearButton')); + + await waitFor(() => { + expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled(); + }); + userEvent.click(screen.getByTestId('edit-category-submit')); await waitFor(() => expect(onSubmit).toBeCalledWith(null)); }); + it('should disabled the save button on error', async () => { + const bigCategory = 'a'.repeat(51); + + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + }); + + userEvent.type(screen.getByRole('combobox'), `${bigCategory}{enter}`); + + await waitFor(() => { + expect( + screen.getByText('The length of the category is too long. The maximum length is 50.') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + + it('should disabled the save button on empty state', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + + it('should disabled the save button when not changing category', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('edit-category-submit')).toBeDisabled(); + }); + it('does not show edit button when the user does not have update permissions', () => { const newAppMockRenderer = createAppMockRenderer({ permissions: readCasesPermissions() }); @@ -171,4 +233,40 @@ describe('EditCategory ', () => { expect(screen.queryByTestId('category-edit-button')).not.toBeInTheDocument(); }); + + it('should set the category correctly if it is updated', async () => { + const { rerender } = appMockRender.render( + + ); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('edit-category-cancel')); + + rerender(); + + userEvent.click(screen.getByTestId('category-edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('categories-list')).toBeInTheDocument(); + expect(screen.getByText('category from the API')).toBeInTheDocument(); + }); + }); + + it('removes the category correctly using the cross button', async () => { + appMockRender.render(); + + await waitFor(() => { + expect(screen.getByText('My category')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('category-remove-button')); + + await waitFor(() => expect(onSubmit).toBeCalledWith(null)); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx index b7055c7a3b857..df3c3e538e71a 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_category.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiText, EuiHorizontalRule, @@ -16,26 +16,66 @@ import { EuiButtonIcon, EuiLoadingSpinner, } from '@elastic/eui'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useGetCategories } from '../../../containers/use_get_categories'; import * as i18n from '../../category/translations'; import { CategoryViewer } from '../../category/category_viewer_component'; import { useCasesContext } from '../../cases_context/use_cases_context'; import { CategoryFormField } from '../../category/category_form_field'; +import { RemovableItem } from '../../removable_item/removable_item'; export interface EditCategoryProps { isLoading: boolean; - onSubmit: (category: string | null | undefined) => void; - category: string | null | undefined; + onSubmit: (category?: string | null) => void; + category?: string | null; } +interface CategoryFormState { + isValid: boolean | undefined; + submit: FormHook<{ category?: EditCategoryProps['category'] }>['submit']; +} + +type CategoryFormWrapper = Pick & { + availableCategories: string[]; + onChange?: (state: CategoryFormState) => void; +}; + +const CategoryFormWrapper: React.FC = ({ + category, + availableCategories, + isLoading, + onChange, +}) => { + const { form } = useForm({ + defaultValue: { category }, + }); + + const { submit, isValid: isFormValid } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid: isFormValid, submit }); + } + }, [isFormValid, onChange, submit]); + + return ( +
+ + + ); +}; + +CategoryFormWrapper.displayName = 'CategoryFormWrapper'; + export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditCategoryProps) => { const { permissions } = useCasesContext(); const [isEditCategory, setIsEditCategory] = useState(false); const { data: categories = [], isLoading: isLoadingCategories } = useGetCategories(); - const { form } = useForm({ - defaultValue: { category }, + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: {} }), }); const onEdit = () => { @@ -46,20 +86,25 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC setIsEditCategory(false); }; + const removeCategory = () => { + onSubmit(null); + setIsEditCategory(false); + }; + const onSubmitCategory = async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await formState.submit(); if (isValid) { const newCategory = data.category != null ? data.category : null; onSubmit(newCategory); - form.reset({ defaultValue: data }); } setIsEditCategory(false); }; const isLoadingAll = isLoading || isLoadingCategories; + const isCategoryValid = formState.isValid; return ( @@ -90,7 +135,14 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC {!isEditCategory && ( {category ? ( - + + + ) : ( {i18n.NO_CATEGORIES} @@ -100,9 +152,12 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC )} {isEditCategory && ( -
- - + @@ -113,6 +168,7 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC iconType="save" onClick={onSubmitCategory} size="s" + disabled={!isCategoryValid || isLoadingAll} > {i18n.SAVE} diff --git a/x-pack/plugins/cases/public/components/category/category_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_component.test.tsx index e9d67948f949e..281051f59f599 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.test.tsx @@ -87,4 +87,20 @@ describe('Category ', () => { expect(screen.getByTestId('comboBoxInput')).toHaveTextContent('hi'); }); }); + + it('should add case sensitive text', async () => { + render(); + + userEvent.type(screen.getByRole('combobox'), 'hi{enter}'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('hi'); + }); + + userEvent.type(screen.getByRole('combobox'), 'Hi{enter}'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('Hi'); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/category/category_component.tsx b/x-pack/plugins/cases/public/components/category/category_component.tsx index 960141cf82747..ee6f84a244062 100644 --- a/x-pack/plugins/cases/public/components/category/category_component.tsx +++ b/x-pack/plugins/cases/public/components/category/category_component.tsx @@ -65,6 +65,7 @@ export const CategoryComponent: React.FC = React.memo( aria-label="categories-list" isClearable customOptionText={ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX} + isCaseSensitive /> ); } diff --git a/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx b/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx index 03f6bf9444591..3e6eb6472bf9a 100644 --- a/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_form_field.test.tsx @@ -9,31 +9,17 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { CategoryFormField } from './category_form_field'; import { categories } from '../../containers/mock'; -import { EuiButton } from '@elastic/eui'; import { MAX_CATEGORY_LENGTH } from '../../../common/constants'; +import { FormTestComponent } from '../../common/test_utils'; describe('Category', () => { let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); - const FormComponent: React.FC<{ category?: string | null }> = ({ children, category }) => { - const defaultValue = category !== undefined ? { defaultValue: { category } } : {}; - - const { form } = useForm({ onSubmit, ...defaultValue }); - - return ( -
- {children} - form.submit()}>{'Submit'} -
- ); - }; - beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); @@ -41,9 +27,9 @@ describe('Category', () => { it('renders the category field correctly', () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -51,9 +37,9 @@ describe('Category', () => { it('can submit without setting a category', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -67,9 +53,9 @@ describe('Category', () => { it('can submit with category a string as default value', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -83,9 +69,9 @@ describe('Category', () => { it('can submit with category with null as default value', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -99,9 +85,9 @@ describe('Category', () => { it('cannot submit if the category is an empty string', async () => { appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -120,9 +106,9 @@ describe('Category', () => { const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); appMockRender.render( - + - + ); expect(screen.getByTestId('categories-list')).toBeInTheDocument(); @@ -139,9 +125,9 @@ describe('Category', () => { it('can set a category from existing ones', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), `${categories[1]}{enter}`); @@ -155,9 +141,9 @@ describe('Category', () => { it('can set a new category', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), 'my new category{enter}'); @@ -171,9 +157,9 @@ describe('Category', () => { it('cannot set an empty category', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), ' {enter}'); @@ -186,11 +172,11 @@ describe('Category', () => { }); }); - it('setting an empty and clear it do not produce an error', async () => { + it('setting an empty category and clear it do not produce an error', async () => { appMockRender.render( - + - + ); userEvent.type(screen.getByRole('combobox'), ' {enter}'); @@ -212,9 +198,9 @@ describe('Category', () => { it('disables the component correctly when it is loading', () => { appMockRender.render( - + - + ); expect(screen.getByRole('combobox')).toBeDisabled(); diff --git a/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx b/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx index 2a57409131555..9d93ddd3eae92 100644 --- a/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx +++ b/x-pack/plugins/cases/public/components/category/category_viewer_component.test.tsx @@ -12,9 +12,6 @@ import { CategoryViewer } from './category_viewer_component'; describe('Category viewer ', () => { const sampleCategory = 'foobar'; - beforeEach(() => { - jest.resetAllMocks(); - }); it('renders category', () => { render(); diff --git a/x-pack/plugins/cases/public/components/category/translations.ts b/x-pack/plugins/cases/public/components/category/translations.ts index 492f5ab8233da..5629795103d8b 100644 --- a/x-pack/plugins/cases/public/components/category/translations.ts +++ b/x-pack/plugins/cases/public/components/category/translations.ts @@ -19,3 +19,14 @@ export const EMPTY_CATEGORY_VALIDATION_MSG = i18n.translate( defaultMessage: 'Empty category is not allowed', } ); + +export const REMOVE_CATEGORY = i18n.translate('xpack.cases.caseView.removeCategory', { + defaultMessage: 'Remove category', +}); + +export const REMOVE_CATEGORY_ARIA_LABEL = i18n.translate( + 'xpack.cases.caseView.removeCategoryAriaLabel', + { + defaultMessage: 'click to remove category', + } +); diff --git a/x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx b/x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx new file mode 100644 index 0000000000000..6408c8c962023 --- /dev/null +++ b/x-pack/plugins/cases/public/components/removable_item/removable_item.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { AppMockRenderer } from '../../common/mock'; +import { noUpdateCasesPermissions, createAppMockRenderer } from '../../common/mock'; +import { fireEvent, screen } from '@testing-library/react'; +import { RemovableItem } from './removable_item'; +import userEvent from '@testing-library/user-event'; + +const MockComponent = () => { + return {'My component'}; +}; + +describe('UserRepresentation', () => { + let appMockRender: AppMockRenderer; + + const onRemoveItem = jest.fn(); + + const defaultProps = { + tooltipContent: 'Remove item', + buttonAriaLabel: 'Remove item', + onRemoveItem, + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('does not show the cross button when the user is not hovering over the row', () => { + appMockRender.render( + + + + ); + + expect(screen.queryByTestId('remove-button')).toHaveStyle('opacity: 0'); + }); + + it('show the cross button when the user is hovering over the row', () => { + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + + expect(screen.getByTestId('remove-button')).toHaveStyle('opacity: 1'); + }); + + it('shows and then removes the cross button when the user hovers and removes the mouse from over the row', () => { + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + expect(screen.getByTestId('remove-button')).toHaveStyle('opacity: 1'); + + fireEvent.mouseLeave(screen.getByTestId('remove-group')); + expect(screen.queryByTestId('remove-button')).toHaveStyle('opacity: 0'); + }); + + it('does not show the cross button when the user is hovering over the row and does not have update permissions', () => { + appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + appMockRender.render( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('remove-group')); + + expect(screen.queryByTestId('remove-button')).not.toBeInTheDocument(); + }); + + it('call onRemoveItem correctly', () => { + appMockRender.render( + + + + ); + + userEvent.click(screen.getByTestId('remove-button')); + + expect(onRemoveItem).toBeCalled(); + }); + + it('sets the dataTestSubjPrefix correctly', () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('my-prefix-remove-group')).toBeInTheDocument(); + expect(screen.getByTestId('my-prefix-remove-button')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/removable_item/removable_item.tsx b/x-pack/plugins/cases/public/components/removable_item/removable_item.tsx new file mode 100644 index 0000000000000..8cc9138511086 --- /dev/null +++ b/x-pack/plugins/cases/public/components/removable_item/removable_item.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import React, { useState } from 'react'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface Props { + tooltipContent: string; + buttonAriaLabel: string; + onRemoveItem: () => void; + children: React.ReactNode; + dataTestSubjPrefix?: string; +} + +const RemovableItemComponent: React.FC = ({ + children, + tooltipContent, + buttonAriaLabel, + onRemoveItem, + dataTestSubjPrefix = '', +}) => { + const { permissions } = useCasesContext(); + const [isHovering, setIsHovering] = useState(false); + + const onFocus = () => setIsHovering(true); + const onFocusLeave = () => setIsHovering(false); + + const dataTestSubj = dataTestSubjPrefix.length > 0 ? `${dataTestSubjPrefix}-remove-` : 'remove-'; + + return ( + + {children} + {permissions.update && ( + + + + + + )} + + ); +}; + +RemovableItemComponent.displayName = 'RemovableItem'; + +export const RemovableItem = React.memo(RemovableItemComponent); diff --git a/x-pack/plugins/cases/public/components/user_actions/category.test.tsx b/x-pack/plugins/cases/public/components/user_actions/category.test.tsx index 04e3f3b82f452..edcf7620243b2 100644 --- a/x-pack/plugins/cases/public/components/user_actions/category.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/category.test.tsx @@ -41,7 +41,7 @@ describe('createCategoryUserActionBuilder ', () => { ); - expect(screen.getByTestId('category-update-user-action')).toBeInTheDocument(); + expect(screen.getByTestId('category-update-category-user-action-title')).toBeInTheDocument(); expect(screen.getByText('added the category "fantasy"')).toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe('createCategoryUserActionBuilder ', () => { ); - expect(screen.getByTestId('category-delete-user-action')).toBeInTheDocument(); + expect(screen.getByTestId('category-delete-category-user-action-title')).toBeInTheDocument(); expect(screen.getByText('removed the category')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/category.tsx b/x-pack/plugins/cases/public/components/user_actions/category.tsx index b69ff0321851e..b050c5a9e84f7 100644 --- a/x-pack/plugins/cases/public/components/user_actions/category.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/category.tsx @@ -21,7 +21,7 @@ const getLabelTitle = (userAction: UserActionResponse) => { {userAction.action === Actions.update ? ( diff --git a/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx index e94a78fdbaab3..4e892af864e96 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/removable_user.test.tsx @@ -14,10 +14,10 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, noUpdateCasesPermissions } from '../../common/mock'; describe('UserRepresentation', () => { - const dataTestSubjGroup = `user-profile-assigned-user-group-${userProfiles[0].user.username}`; - const dataTestSubjCross = `user-profile-assigned-user-cross-${userProfiles[0].user.username}`; - const dataTestSubjGroupUnknown = `user-profile-assigned-user-group-unknownId`; - const dataTestSubjCrossUnknown = `user-profile-assigned-user-cross-unknownId`; + const dataTestSubjGroup = `user-profile-assigned-user-${userProfiles[0].user.username}-remove-group`; + const dataTestSubjCross = `user-profile-assigned-user-${userProfiles[0].user.username}-remove-button`; + const dataTestSubjGroupUnknown = `user-profile-assigned-user-unknownId-remove-group`; + const dataTestSubjCrossUnknown = `user-profile-assigned-user-unknownId-remove-button`; let defaultProps: UserRepresentationProps; let appMockRender: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx b/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx index 72ee046e8936e..d98718d92626f 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/removable_user.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import React, { useCallback } from 'react'; import * as i18n from './translations'; import type { Assignee } from './types'; import { HoverableUserWithAvatar } from './hoverable_user_with_avatar'; -import { useCasesContext } from '../cases_context/use_cases_context'; +import { RemovableItem } from '../removable_item/removable_item'; export interface UserRepresentationProps { assignee: Assignee; @@ -21,55 +20,22 @@ const RemovableUserComponent: React.FC = ({ assignee, onRemoveAssignee, }) => { - const { permissions } = useCasesContext(); - const [isHovering, setIsHovering] = useState(false); - const removeAssigneeCallback = useCallback( () => onRemoveAssignee(assignee.uid), [onRemoveAssignee, assignee.uid] ); - const onFocus = useCallback(() => setIsHovering(true), []); - const onFocusLeave = useCallback(() => setIsHovering(false), []); - const usernameDataTestSubj = assignee.profile?.user.username ?? assignee.uid; return ( - - - - - {permissions.update && ( - - - - - - )} - + + ); }; diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index b8df4432d2dfa..8bc2284759980 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -438,6 +438,26 @@ describe('Cases API', () => { }); expect(resp).toEqual({ ...allCases }); }); + + it('should not send the category field if it is an empty array', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + category: [], + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + }, + signal: abortCtrl.signal, + }); + }); }); describe('getCasesStatus', () => { diff --git a/x-pack/plugins/cases/public/containers/use_get_categories.tsx b/x-pack/plugins/cases/public/containers/use_get_categories.tsx index 21f2ecc15cf50..893104991846f 100644 --- a/x-pack/plugins/cases/public/containers/use_get_categories.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_categories.tsx @@ -16,6 +16,7 @@ import * as i18n from './translations'; export const useGetCategories = () => { const { showErrorToast } = useCasesToast(); const { owner } = useCasesContext(); + return useQuery( casesQueriesKeys.categories(), () => { @@ -24,11 +25,7 @@ export const useGetCategories = () => { }, { onError: (error: ServerError) => { - if (error.name !== 'AbortError') { - showErrorToast(error.body && error.body.message ? new Error(error.body.message) : error, { - title: i18n.CATEGORIES_ERROR_TITLE, - }); - } + showErrorToast(error, { title: i18n.CATEGORIES_ERROR_TITLE }); }, staleTime: 60 * 1000, // one minute } diff --git a/x-pack/plugins/cases/server/client/attachments/get.test.ts b/x-pack/plugins/cases/server/client/attachments/get.test.ts index 250cfa9f0b252..c2c3423b2388b 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/get.test.ts @@ -18,12 +18,20 @@ describe('get', () => { it('Invalid total items results in error', async () => { await expect(() => - findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + findComment({ caseID: 'mock-id', findQueryParams: { page: 209, perPage: 100 } }, clientArgs) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to find comments case id: mock-id: Error: The number of documents is too high. Paginating through more than 10,000 documents is not possible."` ); }); + it('Invalid perPage items results in error', async () => { + await expect(() => + findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find comments case id: mock-id: Error: The provided perPage value was too high. The maximum allowed perPage value is 100."` + ); + }); + it('throws with excess fields', async () => { await expect( findComment( diff --git a/x-pack/plugins/cases/server/client/attachments/validators.test.ts b/x-pack/plugins/cases/server/client/attachments/validators.test.ts index 4626fd13fba35..ce6c43a665a10 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.test.ts @@ -6,10 +6,13 @@ */ import { validateFindCommentsPagination } from './validators'; +import { MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; const ERROR_MSG = 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.'; +const ERROR_MSG_PER_PAGE = `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.`; + describe('validators', () => { describe('validateFindCommentsPagination', () => { it('does not throw if only page is undefined', () => { @@ -20,20 +23,30 @@ describe('validators', () => { expect(() => validateFindCommentsPagination({ page: 100 })).not.toThrowError(); }); + it('does not throw if page and perPage are defined and valid', () => { + expect(() => validateFindCommentsPagination({ page: 2, perPage: 100 })).not.toThrowError(); + }); + it('returns if page and perPage are undefined', () => { expect(() => validateFindCommentsPagination({})).not.toThrowError(); }); + it('returns if perPage < 0', () => { + expect(() => validateFindCommentsPagination({ perPage: -1 })).not.toThrowError(); + }); + it('throws if page > 10k', () => { expect(() => validateFindCommentsPagination({ page: 10001 })).toThrow(ERROR_MSG); }); - it('throws if perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ perPage: 10001 })).toThrowError(ERROR_MSG); + it('throws if perPage > 100', () => { + expect(() => + validateFindCommentsPagination({ perPage: MAX_COMMENTS_PER_PAGE + 1 }) + ).toThrowError(ERROR_MSG_PER_PAGE); }); it('throws if page * perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ page: 10, perPage: 1001 })).toThrow(ERROR_MSG); + expect(() => validateFindCommentsPagination({ page: 101, perPage: 100 })).toThrow(ERROR_MSG); }); }); }); diff --git a/x-pack/plugins/cases/server/client/attachments/validators.ts b/x-pack/plugins/cases/server/client/attachments/validators.ts index 770d9d8e88c0f..ea38fa71e702a 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { MAX_DOCS_PER_PAGE } from '../../../common/constants'; +import { MAX_DOCS_PER_PAGE, MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; import { isCommentRequestTypeExternalReference, isCommentRequestTypePersistableState, @@ -51,7 +51,13 @@ export const validateFindCommentsPagination = (params?: FindCommentsQueryParams) const pageAsNumber = params.page ?? 0; const perPageAsNumber = params.perPage ?? 0; - if (Math.max(pageAsNumber, perPageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { + if (perPageAsNumber > MAX_COMMENTS_PER_PAGE) { + throw Boom.badRequest( + `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.` + ); + } + + if (Math.max(pageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { throw Boom.badRequest( 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.' ); diff --git a/x-pack/plugins/cases/server/client/cases/find.test.ts b/x-pack/plugins/cases/server/client/cases/find.test.ts index 1e5afd2c87b36..968f111b58516 100644 --- a/x-pack/plugins/cases/server/client/cases/find.test.ts +++ b/x-pack/plugins/cases/server/client/cases/find.test.ts @@ -8,6 +8,7 @@ import { v1 as uuidv1 } from 'uuid'; import type { Case } from '../../../common/api'; +import { MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import { flattenCaseSavedObject } from '../../common/utils'; import { mockCases } from '../../mocks'; import { createCasesClientMockArgs, createCasesClientMockFindRequest } from '../mocks'; @@ -103,5 +104,15 @@ describe('find', () => { 'Error: Invalid value "foobar" supplied to "searchFields"' ); }); + + it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); + + const findRequest = createCasesClientMockFindRequest({ category }); + + await expect(find(findRequest, clientArgs)).rejects.toThrow( + `Error: Too many categories provided. The maximum allowed is ${MAX_CATEGORY_FILTER_LENGTH}` + ); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index 8e9e68f79d66b..aa2bb36768207 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -8,6 +8,7 @@ import { isEmpty } from 'lodash'; import Boom from '@hapi/boom'; +import { MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import type { CasesFindResponse, CasesFindRequest } from '../../../common/api'; import { CasesFindRequestRt, @@ -24,6 +25,16 @@ import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { CasesFindQueryParams } from '../types'; import { decodeOrThrow } from '../../../common/api/runtime_types'; +/** + * Throws an error if the user tries to filter by more than MAX_CATEGORY_FILTER_LENGTH categories. + */ +function throwIfCategoryParamTooLong(category?: string[] | string) { + if (Array.isArray(category) && category.length > MAX_CATEGORY_FILTER_LENGTH) + throw Boom.badRequest( + `Too many categories provided. The maximum allowed is ${MAX_CATEGORY_FILTER_LENGTH}` + ); +} + /** * Retrieves a case and optionally its comments. * @@ -44,8 +55,7 @@ export const find = async ( try { const queryParams = decodeWithExcessOrThrow(CasesFindRequestRt)(params); - const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = - await authorization.getAuthorizationFilter(Operations.findCases); + throwIfCategoryParamTooLong(queryParams.category); /** * Assign users to a case is only available to Platinum+ @@ -63,6 +73,9 @@ export const find = async ( licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); } + const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = + await authorization.getAuthorizationFilter(Operations.findCases); + const queryArgs: CasesFindQueryParams = { tags: queryParams.tags, reporters: queryParams.reporters, diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index b630d940ac6b8..749d961766289 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -456,6 +456,15 @@ export const getCaseToUpdate = ( { id: queryCase.id, version: queryCase.version } ); +/** + * TODO: Backend is not connected with the + * frontend in x-pack/plugins/cases/common/ui/types.ts. + * It is easy to forget to update a sort field. + * We should fix it and make it common. + * Also the sortField in x-pack/plugins/cases/common/api/cases/case.ts + * is set to string. We should narrow it to the + * acceptable values + */ enum SortFieldCase { closedAt = 'closed_at', createdAt = 'created_at', @@ -463,6 +472,7 @@ enum SortFieldCase { title = 'title.keyword', severity = 'severity', updatedAt = 'updated_at', + category = 'category', } export const convertSortField = (sortField: string | undefined): SortFieldCase => { @@ -482,6 +492,8 @@ export const convertSortField = (sortField: string | undefined): SortFieldCase = case 'updatedAt': case 'updated_at': return SortFieldCase.updatedAt; + case 'category': + return SortFieldCase.category; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index c0e6286b594c0..4f3fd3434617c 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -131,13 +131,26 @@ export interface GetCspRuleTemplateResponse { // CNVM DASHBOARD -export interface VulnScoreTrend { +interface AccountVulnStats { + cloudAccountId: string; + cloudAccountName: string; + critical: number; + high: number; + medium: number; + low: number; +} + +export interface VulnStatsTrend { '@timestamp': string; policy_template: 'vuln_mgmt'; critical: number; high: number; medium: number; low: number; + vulnerabilities_stats_by_cloud_account?: Record< + AccountVulnStats['cloudAccountId'], + AccountVulnStats + >; } export interface CnvmStatistics { @@ -150,7 +163,7 @@ export interface CnvmStatistics { export interface CnvmDashboardData { cnvmStatistics: CnvmStatistics; - vulnTrends: VulnScoreTrend[]; + vulnTrends: VulnStatsTrend[]; topVulnerableResources: VulnerableResourceStat[]; topPatchableVulnerabilities: PatchableVulnerabilityStat[]; topVulnerabilities: VulnerabilityStat[]; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index e0ec83af0d3ba..b6ffc9f0157b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -74,7 +74,9 @@ export const ChartPanel: React.FC = ({ )}
- {rightSideItems} + + {rightSideItems} +
{renderChart()} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts index 05b73d74e6cec..6d7f909a82e53 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/_mocks_/vulnerability_dashboard.mock.ts @@ -9,115 +9,81 @@ import { CnvmDashboardData } from '../../../../common/types'; export const mockCnvmDashboardData: CnvmDashboardData = { cnvmStatistics: { - criticalCount: 84, - highCount: 4595, - mediumCount: 12125, + criticalCount: 101, + highCount: 4715, + mediumCount: 10537, resourcesScanned: 81, cloudRegions: 1, }, vulnTrends: [ { - high: 4634, + high: 4754, policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-15T23:49:46.563935Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-16T23:50:14.640737Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-17T23:53:51.851500Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-18T23:53:50.831704Z', - critical: 84, - low: 4147, - }, - { - high: 4634, - policy_template: 'vuln_mgmt', - medium: 12522, - '@timestamp': '2023-06-19T08:28:22.830830Z', - critical: 84, - low: 4147, + '@timestamp': '2023-06-21T12:22:57.515212Z', + critical: 101, + low: 3657, + vulnerabilities_stats_by_cloud_account: { + '704479110758': { + cloudAccountName: 'elastic-security-cloud-security-dev', + high: 4754, + critical: 101, + low: 3657, + cloudAccountId: '704479110758', + medium: 10931, + }, + }, + medium: 10931, }, ], topVulnerableResources: [ { - resource: { id: '01dfb5ad43724b411', name: 'dima-sanity-FQj' }, - vulnerabilityCount: 2030, + resource: { id: '0f7888a8ef7fdcbb4', name: 'kfir-June8-8-8-0-3mF' }, + vulnerabilityCount: 1544, cloudRegion: 'eu-west-1', }, { - resource: { id: '09aa0a3d51dae9820', name: 'dima-sanity-FQj' }, - vulnerabilityCount: 2030, + resource: { id: '042985d76403bab90', name: 'kfir3-june12-8-8-0-41B' }, + vulnerabilityCount: 1531, cloudRegion: 'eu-west-1', }, { - resource: { id: '042985d76403bab90', name: 'kfir3-june12-8-8-0-41B' }, - vulnerabilityCount: 1551, + resource: { id: '03dd0d62f0f8a11c6', name: 'dg-6-wl5' }, + vulnerabilityCount: 1508, cloudRegion: 'eu-west-1', }, { resource: { id: '0ba9cce1f0984b413', name: 'daily-env-8.7-vanilla' }, - vulnerabilityCount: 894, + vulnerabilityCount: 880, cloudRegion: 'eu-west-1', }, { resource: { id: '0a2dc4a316cdefd0a', name: 'TrackLiveEnvironment' }, - vulnerabilityCount: 679, + vulnerabilityCount: 588, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0bcb81dda72d6d86a', - name: 'elastic-agent-instance-36c7f8d0-fd41-11ed-b093-02daf9c5eb3d', - }, - vulnerabilityCount: 383, + resource: { id: '089a865e3fe1fe29b', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 316, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0413fc81a8f190eda', - name: 'elastic-agent-instance-19770570-e7b9-11ed-a680-02315045ab2d', - }, - vulnerabilityCount: 340, + resource: { id: '05b9d80b6e99ae260', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 312, cloudRegion: 'eu-west-1', }, { - resource: { - id: '006ea5239813449ee', - name: 'elastic-agent-instance-b32cdc60-091c-11ee-818e-06cae4cbdef5', - }, - vulnerabilityCount: 334, + resource: { id: '0e025869ddc1fbc7c', name: 'cloudbeat-tf-5jA-1' }, + vulnerabilityCount: 304, cloudRegion: 'eu-west-1', }, { - resource: { - id: '0f2dec779a9b6df40', - name: 'elastic-agent-instance-9fb90e20-fb1a-11ed-ac6c-02d320ffd109', - }, - vulnerabilityCount: 321, + resource: { id: '0407769a810e06483', name: 'cloudbeat-tf-5jA-1' }, + vulnerabilityCount: 302, cloudRegion: 'eu-west-1', }, { - resource: { id: '089a865e3fe1fe29b', name: 'cloudbeat-tf-5jA-2' }, - vulnerabilityCount: 314, + resource: { id: '0bb44b16a366c7ad9', name: 'cloudbeat-tf-5jA-2' }, + vulnerabilityCount: 302, cloudRegion: 'eu-west-1', }, ], @@ -126,19 +92,19 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2022-41723', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '0.7.0', - vulnerabilityCount: 384, + vulnerabilityCount: 380, }, { cve: 'CVE-2022-41717', cvss: { score: 5.300000190734863, version: '3.1' }, packageFixVersion: '0.4.0', - vulnerabilityCount: 335, + vulnerabilityCount: 331, }, { cve: 'CVE-2022-27664', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '0.0.0-20220906165146-f3363e06e74c', - vulnerabilityCount: 237, + vulnerabilityCount: 232, }, { cve: 'CVE-2021-37600', @@ -150,37 +116,37 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2023-2253', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '2.8.2-beta.1', - vulnerabilityCount: 157, - }, - { - cve: 'CVE-2023-2609', - cvss: { score: 7.800000190734863, version: '3.1' }, - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - vulnerabilityCount: 154, - }, - { - cve: 'CVE-2023-2610', - cvss: { score: 7.800000190734863, version: '3.1' }, - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - vulnerabilityCount: 154, + vulnerabilityCount: 160, }, { cve: 'CVE-2023-28840', cvss: { score: 7.5, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, }, { cve: 'CVE-2023-28841', cvss: { score: 6.800000190734863, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, }, { cve: 'CVE-2023-28842', cvss: { score: 6.800000190734863, version: '3.1' }, packageFixVersion: '23.0.3, 20.10.24', - vulnerabilityCount: 152, + vulnerabilityCount: 150, + }, + { + cve: 'CVE-2023-2609', + cvss: { score: 7.800000190734863, version: '3.1' }, + packageFixVersion: '2:9.0.1592-1.amzn2.0.1', + vulnerabilityCount: 140, + }, + { + cve: 'CVE-2023-2610', + cvss: { score: 7.800000190734863, version: '3.1' }, + packageFixVersion: '2:9.0.1592-1.amzn2.0.1', + vulnerabilityCount: 140, }, ], topVulnerabilities: [ @@ -188,27 +154,18 @@ export const mockCnvmDashboardData: CnvmDashboardData = { cve: 'CVE-2022-41723', packageFixVersion: '0.7.0', packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', severity: 'HIGH', - vulnerabilityCount: 384, + vulnerabilityCount: 380, cvss: { score: 7.5, version: '3.1' }, }, - { - cve: 'CVE-2022-41717', - packageFixVersion: '0.4.0', - packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', - severity: 'MEDIUM', - vulnerabilityCount: 335, - cvss: { score: 5.300000190734863, version: '3.1' }, - }, { cve: 'CVE-2020-8911', packageFixVersion: '', packageName: 'github.com/aws/aws-sdk-go', packageVersion: 'v1.38.60', severity: 'MEDIUM', - vulnerabilityCount: 334, + vulnerabilityCount: 340, cvss: { score: 5.599999904632568, version: '3.1' }, }, { @@ -217,35 +174,35 @@ export const mockCnvmDashboardData: CnvmDashboardData = { packageName: 'github.com/aws/aws-sdk-go', packageVersion: 'v1.38.60', severity: 'LOW', - vulnerabilityCount: 334, + vulnerabilityCount: 340, cvss: { score: 2.5, version: '3.1' }, }, + { + cve: 'CVE-2022-41717', + packageFixVersion: '0.4.0', + packageName: 'golang.org/x/net', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', + severity: 'MEDIUM', + vulnerabilityCount: 331, + cvss: { score: 5.300000190734863, version: '3.1' }, + }, { cve: 'CVE-2022-27664', packageFixVersion: '0.0.0-20220906165146-f3363e06e74c', packageName: 'golang.org/x/net', - packageVersion: 'v0.0.0-20220809184613-07c6da5e1ced', + packageVersion: 'v0.0.0-20220127200216-cd36cc0744dd', severity: 'HIGH', - vulnerabilityCount: 237, + vulnerabilityCount: 232, cvss: { score: 7.5, version: '3.1' }, }, { - cve: 'CVE-2023-2609', - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - packageName: 'vim-data', - packageVersion: '2:8.2.3995-1ubuntu2.7', - severity: 'HIGH', - vulnerabilityCount: 199, - cvss: { score: 7.800000190734863, version: '3.1' }, - }, - { - cve: 'CVE-2023-2610', - packageFixVersion: '2:9.0.1592-1.amzn2.0.1', - packageName: 'vim-data', - packageVersion: '2:8.2.3995-1ubuntu2.7', - severity: 'HIGH', - vulnerabilityCount: 199, - cvss: { score: 7.800000190734863, version: '3.1' }, + cve: 'CVE-2021-37600', + packageFixVersion: '2.30.2-2.amzn2.0.11', + packageName: 'libblkid', + packageVersion: '2.30.2-2.amzn2.0.10', + severity: 'LOW', + vulnerabilityCount: 168, + cvss: { score: 5.5, version: '3.1' }, }, { cve: 'CVE-2022-3219', @@ -253,26 +210,35 @@ export const mockCnvmDashboardData: CnvmDashboardData = { packageName: 'dirmngr', packageVersion: '2.2.27-3ubuntu2.1', severity: 'LOW', - vulnerabilityCount: 176, + vulnerabilityCount: 165, cvss: { score: 3.299999952316284, version: '3.1' }, }, - { - cve: 'CVE-2021-37600', - packageFixVersion: '2.30.2-2.amzn2.0.11', - packageName: 'libblkid', - packageVersion: '2.30.2-2.amzn2.0.10', - severity: 'LOW', - vulnerabilityCount: 168, - cvss: { score: 5.5, version: '3.1' }, - }, { cve: 'CVE-2023-2253', packageFixVersion: '2.8.2-beta.1', packageName: 'github.com/docker/distribution', packageVersion: 'v2.8.1+incompatible', severity: 'HIGH', - vulnerabilityCount: 157, + vulnerabilityCount: 160, cvss: { score: 7.5, version: '3.1' }, }, + { + cve: 'CVE-2023-28840', + packageFixVersion: '23.0.3, 20.10.24', + packageName: 'github.com/docker/docker', + packageVersion: 'v1.13.1', + severity: 'HIGH', + vulnerabilityCount: 150, + cvss: { score: 7.5, version: '3.1' }, + }, + { + cve: 'CVE-2023-28841', + packageFixVersion: '23.0.3, 20.10.24', + packageName: 'github.com/docker/docker', + packageVersion: 'v1.13.1', + severity: 'MEDIUM', + vulnerabilityCount: 150, + cvss: { score: 6.800000190734863, version: '3.1' }, + }, ], }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx index 68538ea9210c7..c1fdccef0a086 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_trend_graph.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Chart, Settings, @@ -15,11 +15,12 @@ import { niceTimeFormatByDay, PartialTheme, } from '@elastic/charts'; -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { truthy } from '../../../common/utils/helpers'; import { useNavigateVulnerabilities } from '../../common/hooks/use_navigate_findings'; -import { VulnSeverity } from '../../../common/types'; +import { VulnStatsTrend, VulnSeverity } from '../../../common/types'; import { useVulnerabilityDashboardApi } from '../../common/api/use_vulnerability_dashboard_api'; import { getSeverityStatusColor } from '../../common/utils/get_vulnerability_colors'; import { ChartPanel } from '../../components/chart_panel'; @@ -32,6 +33,8 @@ const stackAccessors: VulnSeverity[] = [ VULNERABILITIES_SEVERITY.LOW, ]; +const DEFAULT_ACCOUNT = 'all'; + const chartStyle = { width: '100%', height: 300 }; const theme: PartialTheme = { @@ -56,9 +59,102 @@ const ViewAllButton = () => { ); }; +const AccountDropDown = ({ + selectedAccount, + setSelectedAccount, + options = [], +}: { + selectedAccount: string; + setSelectedAccount: (value: string) => void; + options: Array<{ value: string; label: string }>; +}) => ( + o.value === selectedAccount)} + onChange={(selectedOption) => { + setSelectedAccount(selectedOption[0].value || DEFAULT_ACCOUNT); + }} + /> +); + +const getUniqueCloudAccountsOptions = ( + vulnTrends: VulnStatsTrend[] +): Array<{ value: string; label: string }> => { + const uniqueCloudAccounts: Array<{ label: string; value: string }> = []; + + vulnTrends.forEach((trendTimepoint) => { + const accountStatss = Object.values( + trendTimepoint.vulnerabilities_stats_by_cloud_account || {} + ); + + accountStatss.forEach((accountStats) => { + // Check if the entry already exists based on the cloudAccountId + const isDuplicate = uniqueCloudAccounts.find( + (account) => account.value === accountStats.cloudAccountId + ); + + // If no duplicate is found, add the account to the uniqueCloudAccounts array + if (!isDuplicate) { + uniqueCloudAccounts.push({ + label: accountStats.cloudAccountName, + value: accountStats.cloudAccountId, + }); + } + }); + }); + + uniqueCloudAccounts.unshift({ + label: i18n.translate( + 'xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.option.allTitle', + { defaultMessage: 'All' } + ), + value: DEFAULT_ACCOUNT, + }); + + return uniqueCloudAccounts; +}; + +const getTrendData = (vulnTrends: VulnStatsTrend[], selectedAccount: string) => { + if (selectedAccount === DEFAULT_ACCOUNT) { + const cleanAllTrend = vulnTrends.map((trendTimepoint) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { vulnerabilities_stats_by_cloud_account, policy_template, ...allAccountsTrendData } = + trendTimepoint; + return allAccountsTrendData; + }); + return cleanAllTrend; + } + + const accountTrend = vulnTrends + .map((trendTimepoint) => { + const selectedAccountStats = + trendTimepoint.vulnerabilities_stats_by_cloud_account?.[selectedAccount]; + + if (selectedAccountStats) + return { + '@timestamp': trendTimepoint['@timestamp'], + ...selectedAccountStats, + }; + }) + .filter(truthy); + + return accountTrend; +}; + export const VulnerabilityTrendGraph = () => { const getVulnerabilityDashboard = useVulnerabilityDashboardApi(); - const trendData = getVulnerabilityDashboard.data?.vulnTrends || []; + const vulnTrends = getVulnerabilityDashboard.data?.vulnTrends || []; + const [selectedAccount, setSelectedAccount] = useState(DEFAULT_ACCOUNT); + + const trendData = getTrendData(vulnTrends, selectedAccount); const bars: Array<{ yAccessors: string[]; @@ -98,7 +194,15 @@ export const VulnerabilityTrendGraph = () => { defaultMessage: 'Trend by severity', } )} - rightSideItems={[]} + rightSideItems={[ + , + , + ]} >
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts index 95f52676d09f8..a3e335734e74c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/vulnerabilities_dashboard/get_vulnerabilities_trend.ts @@ -7,20 +7,20 @@ import { ElasticsearchClient } from '@kbn/core/server'; import { BENCHMARK_SCORE_INDEX_DEFAULT_NS } from '../../../common/constants'; -import { VulnScoreTrend } from '../../../common/types'; +import { VulnStatsTrend } from '../../../common/types'; interface LastDocBucket { key_as_string: string; last_doc: { hits: { hits: Array<{ - _source: VulnScoreTrend; + _source: VulnStatsTrend; }>; }; }; } -interface VulnScoreTrendResponse { +interface VulnStatsTrendResponse { vuln_severity_per_day: { buckets: LastDocBucket[]; }; @@ -78,17 +78,17 @@ export const getVulnTrendsQuery = () => ({ export const getVulnerabilitiesTrends = async ( esClient: ElasticsearchClient -): Promise => { - const vulnTrendsQueryResult = await esClient.search( +): Promise => { + const vulnTrendsQueryResult = await esClient.search( getVulnTrendsQuery() ); if (!vulnTrendsQueryResult.hits.hits) { throw new Error('Missing trend results from score index'); } - const vulnScoreTrendDocs = vulnTrendsQueryResult.aggregations?.vuln_severity_per_day.buckets?.map( + const vulnStatsTrendDocs = vulnTrendsQueryResult.aggregations?.vuln_severity_per_day.buckets?.map( (bucket) => bucket.last_doc.hits.hits[0]._source ); - return vulnScoreTrendDocs || []; + return vulnStatsTrendDocs || []; }; diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts index 65b4b0d7572c5..756cf739711d4 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts @@ -178,7 +178,7 @@ const getScoreQuery = (): SearchRequest => ({ }, }); -const getVulnScoreQuery = (): SearchRequest => ({ +const getVulnStatsTrendQuery = (): SearchRequest => ({ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, size: 0, query: { @@ -197,6 +197,37 @@ const getVulnScoreQuery = (): SearchRequest => ({ low: { filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.LOW } }, }, + vulnerabilities_stats_by_cloud_account: { + terms: { + field: 'cloud.account.id', + }, + aggs: { + cloud_account_id: { + terms: { + field: 'cloud.account.id', + size: 1, + }, + }, + cloud_account_name: { + terms: { + field: 'cloud.account.name', + size: 1, + }, + }, + critical: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.CRITICAL } }, + }, + high: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.HIGH } }, + }, + medium: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.MEDIUM } }, + }, + low: { + filter: { term: { 'vulnerability.severity': VULNERABILITIES_SEVERITY.LOW } }, + }, + }, + }, }, }); @@ -234,20 +265,39 @@ const getFindingsScoresDocIndexingPromises = ( }); }); -const getVulnScoresDocIndexingPromises = ( +const getVulnStatsTrendDocIndexingPromises = ( esClient: ElasticsearchClient, - vulnScoreAggs?: VulnSeverityAggs + vulnStatsAggs?: VulnSeverityAggs ) => { - if (!vulnScoreAggs) return; + if (!vulnStatsAggs) return; + + const scoreByCloudAccount = Object.fromEntries( + vulnStatsAggs.vulnerabilities_stats_by_cloud_account.buckets.map((accountScore) => { + const cloudAccountId = accountScore.key; + + return [ + cloudAccountId, + { + cloudAccountId: accountScore.key, + cloudAccountName: accountScore.cloud_account_name.buckets[0].key, + critical: accountScore.critical.doc_count, + high: accountScore.high.doc_count, + medium: accountScore.medium.doc_count, + low: accountScore.low.doc_count, + }, + ]; + }) + ); return esClient.index({ index: BENCHMARK_SCORE_INDEX_DEFAULT_NS, document: { policy_template: VULN_MGMT_POLICY_TEMPLATE, - critical: vulnScoreAggs.critical.doc_count, - high: vulnScoreAggs.high.doc_count, - medium: vulnScoreAggs.medium.doc_count, - low: vulnScoreAggs.low.doc_count, + critical: vulnStatsAggs.critical.doc_count, + high: vulnStatsAggs.high.doc_count, + medium: vulnStatsAggs.medium.doc_count, + low: vulnStatsAggs.low.doc_count, + vulnerabilities_stats_by_cloud_account: scoreByCloudAccount, }, }); }; @@ -262,11 +312,11 @@ export const aggregateLatestFindings = async ( const scoreIndexQueryResult = await esClient.search( getScoreQuery() ); - const vulnScoreIndexQueryResult = await esClient.search( - getVulnScoreQuery() + const vulnStatsTrendIndexQueryResult = await esClient.search( + getVulnStatsTrendQuery() ); - if (!scoreIndexQueryResult.aggregations && !vulnScoreIndexQueryResult.aggregations) { + if (!scoreIndexQueryResult.aggregations && !vulnStatsTrendIndexQueryResult.aggregations) { logger.warn(`No data found in latest findings index`); return 'warning'; } @@ -288,16 +338,16 @@ export const aggregateLatestFindings = async ( scoresByPolicyTemplatesBuckets ); - const vulnScoresDocIndexingPromises = getVulnScoresDocIndexingPromises( + const vulnStatsTrendDocIndexingPromises = getVulnStatsTrendDocIndexingPromises( esClient, - vulnScoreIndexQueryResult.aggregations + vulnStatsTrendIndexQueryResult.aggregations ); const startIndexTime = performance.now(); // executing indexing commands await Promise.all( - [...findingsScoresDocIndexingPromises, vulnScoresDocIndexingPromises].filter(Boolean) + [...findingsScoresDocIndexingPromises, vulnStatsTrendDocIndexingPromises].filter(Boolean) ); const totalIndexTime = Number(performance.now() - startIndexTime).toFixed(2); diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/types.ts b/x-pack/plugins/cloud_security_posture/server/tasks/types.ts index abf5a3b2ac4b7..c67f682c7c016 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/types.ts @@ -38,6 +38,28 @@ export interface VulnSeverityAggs { low: { doc_count: number; }; + vulnerabilities_stats_by_cloud_account: { + buckets: Array<{ + key: string; // cloud account id + critical: { + doc_count: number; + }; + high: { + doc_count: number; + }; + medium: { + doc_count: number; + }; + low: { + doc_count: number; + }; + cloud_account_name: { + buckets: Array<{ + key: string; // cloud account name + }>; + }; + }>; + }; } export type TaskHealthStatus = 'ok' | 'warning' | 'error'; diff --git a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts index ba366cec67a03..67c0398458bf6 100644 --- a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts +++ b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts @@ -137,7 +137,7 @@ export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.sharepoint.name', { defaultMessage: 'Sharepoint Server', }), - serviceType: 'sharepoint', + serviceType: 'sharepoint_server', }, { iconPath: 'sharepoint_online.svg', diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx index 05a1f23031c20..8d1c896f6ac54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/engine_connect.tsx @@ -36,9 +36,9 @@ const pageTitle = i18n.translate( } ); const SAFE_SEARCH_API_TAB_TITLE = i18n.translate( - 'xpack.enterpriseSearch.content.searchApplications.connect.safeSearchAPITabTitle', + 'xpack.enterpriseSearch.content.searchApplications.connect.searchAPITabTitle', { - defaultMessage: 'Safe Search API', + defaultMessage: 'Search API', } ); const DOCUMENTATION_TAB_TITLE = i18n.translate( @@ -50,7 +50,7 @@ const DOCUMENTATION_TAB_TITLE = i18n.translate( const ConnectTabs: string[] = Object.values(SearchApplicationConnectTabs); const getTabBreadCrumb = (tabId: string) => { switch (tabId) { - case SearchApplicationConnectTabs.SAFESEARCHAPI: + case SearchApplicationConnectTabs.SEARCHAPI: return SAFE_SEARCH_API_TAB_TITLE; case SearchApplicationConnectTabs.DOCUMENTATION: return DOCUMENTATION_TAB_TITLE; @@ -61,7 +61,7 @@ const getTabBreadCrumb = (tabId: string) => { export const EngineConnect: React.FC = () => { const { engineName, isLoadingEngine, hasSchemaConflicts } = useValues(EngineViewLogic); - const { connectTabId = SearchApplicationConnectTabs.SAFESEARCHAPI } = useParams<{ + const { connectTabId = SearchApplicationConnectTabs.SEARCHAPI } = useParams<{ connectTabId?: string; }>(); @@ -106,9 +106,9 @@ export const EngineConnect: React.FC = () => { rightSideItems: [], tabs: [ { - isSelected: connectTabId === SearchApplicationConnectTabs.SAFESEARCHAPI, + isSelected: connectTabId === SearchApplicationConnectTabs.SEARCHAPI, label: SAFE_SEARCH_API_TAB_TITLE, - onClick: onTabClick(SearchApplicationConnectTabs.SAFESEARCHAPI), + onClick: onTabClick(SearchApplicationConnectTabs.SEARCHAPI), }, { isSelected: connectTabId === SearchApplicationConnectTabs.DOCUMENTATION, @@ -120,7 +120,7 @@ export const EngineConnect: React.FC = () => { engineName={engineName} hasSchemaConflicts={hasSchemaConflicts} > - {connectTabId === SearchApplicationConnectTabs.SAFESEARCHAPI && } + {connectTabId === SearchApplicationConnectTabs.SEARCHAPI && } {connectTabId === SearchApplicationConnectTabs.DOCUMENTATION && ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx index 216fe729d2939..d615cd12c72e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_connect/search_application_api.tsx @@ -52,13 +52,13 @@ export const SearchApplicationAPI = () => {

@@ -80,13 +80,13 @@ export const SearchApplicationAPI = () => {

@@ -98,7 +98,7 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step1.setUpSearchtemplate.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step1.setUpSearchtemplate.title', { defaultMessage: 'Set up your search template', } @@ -110,13 +110,13 @@ export const SearchApplicationAPI = () => {

@@ -132,10 +132,10 @@ export const SearchApplicationAPI = () => { iconSide="left" iconType="plusInCircleFilled" onClick={openGenerateModal} - data-telemetry-id="entSearchApplications-safeSearchApi-step2-createApiKeyButton" + data-telemetry-id="entSearchApplications-searchApi-step2-createApiKeyButton" > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.createAPIKeyButton', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.createAPIKeyButton', { defaultMessage: 'Create API Key', } @@ -146,7 +146,7 @@ export const SearchApplicationAPI = () => { KibanaLogic.values.navigateToUrl('/app/management/security/api_keys', { shouldNotCreateHref: true, @@ -154,7 +154,7 @@ export const SearchApplicationAPI = () => { } > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.viewKeysButton', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.viewKeysButton', { defaultMessage: 'View Keys', } @@ -165,7 +165,7 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step2.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step2.title', { defaultMessage: 'Generate and save API key', } @@ -177,7 +177,7 @@ export const SearchApplicationAPI = () => {

{i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step3.copyEndpointDescription', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step3.copyEndpointDescription', { defaultMessage: "Here's the URL for your endpoint:", } @@ -197,16 +197,16 @@ export const SearchApplicationAPI = () => { ), title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step3.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step3.title', { - defaultMessage: 'Copy your Safe Search endpoint', + defaultMessage: 'Copy your Search endpoint', } ), }, { children: , title: i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.step4.title', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.step4.title', { defaultMessage: 'Learn how to call your endpoint', } @@ -226,25 +226,25 @@ export const SearchApplicationAPI = () => { iconType="iInCircle" title={ } > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.safeSearchDocLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchApiDocLink', { - defaultMessage: 'Safe Search API', + defaultMessage: 'Search API', } )} @@ -256,7 +256,7 @@ export const SearchApplicationAPI = () => { data-telemetry-id="entSearchApplications-searchTemplate-documentation-viewDocumentaion" > {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.searchTemplateDocLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchTemplateDocLink', { defaultMessage: 'search template', } @@ -267,20 +267,20 @@ export const SearchApplicationAPI = () => { /> {i18n.translate( - 'xpack.enterpriseSearch.content.searchApplication.safeSearchApi.safeSearchCallout.body.safeSearchDocumentationLink', + 'xpack.enterpriseSearch.content.searchApplication.searchApi.searchApiCallout.body.searchApiDocumentationLink', { - defaultMessage: 'Learn more about the Safe Search API', + defaultMessage: 'Learn more about the Search API', } )} diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx index 428283df3cd86..bfec3086c5e87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx @@ -9,6 +9,8 @@ import React, { useState, useMemo } from 'react'; import { useActions, useValues } from 'kea'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + import { EuiButtonEmpty, EuiContextMenuItem, @@ -24,6 +26,7 @@ import { EuiText, EuiTextColor, EuiTitle, + EuiTourStep, } from '@elastic/eui'; import { PagingInfo, @@ -128,6 +131,11 @@ const ConfigurationPopover: React.FC = ({ const { engineData } = useValues(EngineViewLogic); const { openDeleteEngineModal } = useActions(EngineViewLogic); const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + const [isTourClosed, setTourClosed] = useLocalStorage( + 'search-application-tour-closed', + false + ); + return ( <> = ({ closePopover={setCloseConfiguration} button={ - {hasSchemaConflicts && } - - {i18n.translate( - 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', - { - defaultMessage: 'Configuration', + {hasSchemaConflicts && ( + <> + + + + {!isTourClosed && } + + )} + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourContent', + { + defaultMessage: + 'Create your API key, learn about using language clients and find more resources in Connect.', + } + )} +

+
} - )} - + isStepOpen={!isTourClosed} + maxWidth={360} + hasArrow + step={1} + onFinish={() => { + setTourClosed(true); + }} + stepsTotal={1} + anchorPosition="downCenter" + title={i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourTitle', + { + defaultMessage: 'Review our API page to start using your search application', + } + )} + > + <> + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} + + } > @@ -235,7 +289,7 @@ const ConfigurationPopover: React.FC = ({ onClick={() => navigateToUrl( generateEncodedPath(SEARCH_APPLICATION_CONNECT_PATH, { - connectTabId: SearchApplicationConnectTabs.SAFESEARCHAPI, + connectTabId: SearchApplicationConnectTabs.SEARCHAPI, engineName, }) ) diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx index 419b8a9d21e82..6b66c94f5ecf6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_view.tsx @@ -97,7 +97,7 @@ export const EngineView: React.FC = () => { { cluster: [], index: [ { - names: [indexName], + names: [indexName, `.search-acl-filter-my-index`], + privileges: ['all'], + }, + ], + }, + }, + }); + }); + + it('works with search-* prefixed indices', async () => { + await createApiKey(request, security, 'search-test', keyName); + expect(security.authc.apiKeys.create).toHaveBeenCalledWith(request, { + name: keyName, + role_descriptors: { + ['search-test-key-role']: { + cluster: [], + index: [ + { + names: ['search-test', `.search-acl-filter-test`], privileges: ['all'], }, ], diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts index 0c1a62c4d30db..13d6aa142e50c 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/create_api_key.ts @@ -17,6 +17,9 @@ export const createApiKey = async ( indexName: string, keyName: string ) => { + // removes the "search-" prefix if present, and applies the new prefix + const aclIndexName = indexName.replace(/^(?:search-)?(.*)$/, '.search-acl-filter-$1'); + return await security.authc.apiKeys.create(request, { name: keyName, role_descriptors: { @@ -24,7 +27,7 @@ export const createApiKey = async ( cluster: [], index: [ { - names: [indexName], + names: [indexName, aclIndexName], privileges: ['all'], }, ], diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 16a27abb0b330..588f686a6eaf7 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -82,6 +82,12 @@ function getMockedSoClient( type: 'elasticsearch', }); } + case outputIdToUuid('existing-default-and-default-monitoring-output'): { + return mockOutputSO('existing-default-and-default-monitoring-output', { + is_default: true, + is_default_monitoring: true, + }); + } case outputIdToUuid('existing-preconfigured-default-output'): { return mockOutputSO('existing-preconfigured-default-output', { is_default: true, @@ -569,6 +575,46 @@ describe('Output Service', () => { ); }); + it('should not set default output to false when the output is already the default one', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-default-and-default-monitoring-output', + }); + + await expect( + outputService.update( + soClient, + esClientMock, + 'existing-default-and-default-monitoring-output', + { + is_default: false, + name: 'Test', + } + ) + ).rejects.toThrow( + `Default output existing-default-and-default-monitoring-output cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); + }); + + it('should not set default monitoring output to false when the output is already the default one', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-default-and-default-monitoring-output', + }); + + await expect( + outputService.update( + soClient, + esClientMock, + 'existing-default-and-default-monitoring-output', + { + is_default_monitoring: false, + name: 'Test', + } + ) + ).rejects.toThrow( + `Default output existing-default-and-default-monitoring-output cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); + }); + it('should update existing default monitoring output when updating an output to become the default monitoring output', async () => { const soClient = getMockedSoClient({ defaultOutputMonitoringId: 'existing-default-monitoring-output', diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 50fb7b54a5346..afa92127dae97 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -9,27 +9,27 @@ import { omit } from 'lodash'; import { safeLoad } from 'js-yaml'; import deepEqual from 'fast-deep-equal'; -import { SavedObjectsUtils } from '@kbn/core/server'; import type { + ElasticsearchClient, KibanaRequest, SavedObject, SavedObjectsClientContract, - ElasticsearchClient, } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; -import type { NewOutput, Output, OutputSOAttributes, AgentPolicy } from '../types'; +import type { AgentPolicy, NewOutput, Output, OutputSOAttributes } from '../types'; import { + AGENT_POLICY_SAVED_OBJECT_TYPE, DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE, - AGENT_POLICY_SAVED_OBJECT_TYPE, } from '../constants'; -import { SO_SEARCH_LIMIT, outputType } from '../../common/constants'; +import { outputType, SO_SEARCH_LIMIT } from '../../common/constants'; import { normalizeHostsForAgents } from '../../common/services'; import { - OutputUnauthorizedError, - OutputInvalidError, FleetEncryptedSavedObjectEncryptionKeyRequired, + OutputInvalidError, + OutputUnauthorizedError, } from '../errors'; import { agentPolicyService } from './agent_policy'; @@ -261,6 +261,59 @@ class OutputService { return outputs; } + private async _updateDefaultOutput( + soClient: SavedObjectsClientContract, + defaultDataOutputId: string, + updateData: { is_default: boolean } | { is_default_monitoring: boolean }, + fromPreconfiguration: boolean + ) { + const originalOutput = await this.get(soClient, defaultDataOutputId); + this._validateFieldsAreEditable( + originalOutput, + updateData, + defaultDataOutputId, + fromPreconfiguration + ); + + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: outputIdToUuid(defaultDataOutputId), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + + return await this.encryptedSoClient.update>( + SAVED_OBJECT_TYPE, + outputIdToUuid(defaultDataOutputId), + updateData + ); + } + + private _validateFieldsAreEditable( + originalOutput: Output, + data: Partial, + id: string, + fromPreconfiguration: boolean + ) { + if (originalOutput.is_preconfigured) { + if (!fromPreconfiguration) { + const allowEditFields = originalOutput.allow_edit ?? []; + + const allKeys = Array.from(new Set([...Object.keys(data)])) as Array; + for (const key of allKeys) { + if ( + (!!originalOutput[key] || !!data[key]) && + !allowEditFields.includes(key) && + !deepEqual(originalOutput[key], data[key]) + ) { + throw new OutputUnauthorizedError( + `Preconfigured output ${id} ${key} cannot be updated outside of kibana config file.` + ); + } + } + } + } + } + public async ensureDefaultOutput( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient @@ -351,24 +404,22 @@ class OutputService { // ensure only default output exists if (data.is_default) { if (defaultDataOutputId) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultDataOutputId, { is_default: false }, - { fromPreconfiguration: options?.fromPreconfiguration ?? false } + options?.fromPreconfiguration ?? false ); } } if (data.is_default_monitoring) { const defaultMonitoringOutputId = await this.getDefaultMonitoringOutputId(soClient); if (defaultMonitoringOutputId) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultMonitoringOutputId, { is_default_monitoring: false }, - { fromPreconfiguration: options?.fromPreconfiguration ?? false } + options?.fromPreconfiguration ?? false ); } } @@ -554,29 +605,20 @@ class OutputService { } ) { const originalOutput = await this.get(soClient, id); - if (originalOutput.is_preconfigured) { - if (!fromPreconfiguration) { - const allowEditFields = originalOutput.allow_edit ?? []; - const allKeys = Array.from(new Set([...Object.keys(data)])) as Array; - for (const key of allKeys) { - if ( - (!!originalOutput[key] || !!data[key]) && - !allowEditFields.includes(key) && - !deepEqual(originalOutput[key], data[key]) - ) { - throw new OutputUnauthorizedError( - `Preconfigured output ${id} ${key} cannot be updated outside of kibana config file.` - ); - } - } - } + this._validateFieldsAreEditable(originalOutput, data, id, fromPreconfiguration); + if ( + (originalOutput.is_default && data.is_default === false) || + (data.is_default_monitoring === false && originalOutput.is_default_monitoring) + ) { + throw new OutputUnauthorizedError( + `Default output ${id} cannot be set to is_default=false or is_default_monitoring=false manually. Make another output the default first.` + ); } const updateData: Nullable> = { ...omit(data, 'ssl') }; const mergedType = data.type ?? originalOutput.type; const defaultDataOutputId = await this.getDefaultDataOutputId(soClient); - await validateTypeChanges( soClient, esClient, @@ -609,12 +651,11 @@ class OutputService { // ensure only default output exists if (data.is_default) { if (defaultDataOutputId && defaultDataOutputId !== id) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultDataOutputId, { is_default: false }, - { fromPreconfiguration } + fromPreconfiguration ); } } @@ -622,12 +663,11 @@ class OutputService { const defaultMonitoringOutputId = await this.getDefaultMonitoringOutputId(soClient); if (defaultMonitoringOutputId && defaultMonitoringOutputId !== id) { - await this.update( + await this._updateDefaultOutput( soClient, - esClient, defaultMonitoringOutputId, { is_default_monitoring: false }, - { fromPreconfiguration } + fromPreconfiguration ); } } diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx index 8b7e604c6f9b9..7760fbc8bc16e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx @@ -24,7 +24,7 @@ import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import { HostsLensMetricChartFormulas } from '../../../../../common/visualizations'; import { useHostsViewContext } from '../../hooks/use_hosts_view'; import { LensWrapper } from '../chart/lens_wrapper'; -import { createHostsFilter } from '../../utils'; +import { buildCombinedHostsFilter } from '../../utils'; import { useHostCountContext } from '../../hooks/use_host_count'; import { useAfterLoadedState } from '../../hooks/use_after_loaded_state'; import { TooltipContent } from '../metric_explanation/tooltip_content'; @@ -83,10 +83,11 @@ export const Tile = ({ const filters = useMemo(() => { return [ - createHostsFilter( - hostNodes.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + dataView, + }), ]; }, [hostNodes, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx index 8b9f99c132446..6fe796d33e909 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx @@ -15,7 +15,7 @@ import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state'; import { LogsLinkToStream } from './logs_link_to_stream'; import { LogsSearchBar } from './logs_search_bar'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useLogViewReference } from '../../../hooks/use_log_view_reference'; export const LogsTabContent = () => { @@ -25,7 +25,11 @@ export const LogsTabContent = () => { const { hostNodes, loading } = useHostsViewContext(); const hostsFilterQuery = useMemo( - () => createHostsFilter(hostNodes.map((p) => p.name)), + () => + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }), [hostNodes] ); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx index 89f26a7ba906b..721feccd0f4f1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx @@ -22,7 +22,7 @@ import { useMetricsDataViewContext } from '../../../hooks/use_data_view'; import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { HostsLensLineChartFormulas } from '../../../../../../common/visualizations'; import { useHostsViewContext } from '../../../hooks/use_hosts_view'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useHostsTableContext } from '../../../hooks/use_hosts_table'; import { LensWrapper } from '../../chart/lens_wrapper'; import { useAfterLoadedState } from '../../../hooks/use_after_loaded_state'; @@ -62,10 +62,11 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) => const filters = useMemo(() => { return [ - createHostsFilter( - currentPage.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: currentPage.map((p) => p.name), + dataView, + }), ]; }, [currentPage, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts index 200bff521d86a..d79ee39498386 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts @@ -15,7 +15,7 @@ import { HostsState } from './use_unified_search_url_state'; import { useHostsViewContext } from './use_hosts_view'; import { AlertStatus } from '../types'; import { ALERT_STATUS_QUERY } from '../constants'; -import { createHostsFilter } from '../utils'; +import { buildCombinedHostsFilter } from '../utils'; export interface AlertsEsQuery { bool: BoolQuery; @@ -69,7 +69,10 @@ const createAlertsEsQuery = ({ const alertStatusFilter = createAlertStatusFilter(status); const dateFilter = createDateFilter(dateRange); - const hostsFilter = createHostsFilter(hostNodes.map((p) => p.name)); + const hostsFilter = buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }); const filters = [alertStatusFilter, dateFilter, hostsFilter].filter(Boolean) as Filter[]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts index 63f0f7b172fb8..eba7b4d8ba032 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { DataViewBase, Filter, isCombinedFilter } from '@kbn/es-query'; - -import { BooleanRelation, buildCombinedFilter, buildPhraseFilter } from '@kbn/es-query'; +import { + BooleanRelation, + buildCombinedFilter, + buildPhraseFilter, + Filter, + isCombinedFilter, +} from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/common'; export const buildCombinedHostsFilter = ({ @@ -35,24 +39,6 @@ export const buildCombinedHostsFilter = ({ return buildCombinedFilter(BooleanRelation.OR, filtersFromValues, dataView); }; -export const createHostsFilter = (hostNames: string[], dataView?: DataViewBase): Filter => { - return { - query: { - terms: { - 'host.name': hostNames, - }, - }, - meta: dataView - ? { - value: hostNames.join(), - type: 'phrases', - params: hostNames, - index: dataView.id, - key: 'host.name', - } - : {}, - }; -}; export const retrieveFieldsFromFilter = (filters: Filter[], fields: string[] = []) => { for (const filter of filters) { if (isCombinedFilter(filter)) { diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 2bc7d7e1baaaa..bd143f3245e93 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -19,10 +19,6 @@ flex-grow: 1; } -.lensAnnotationIconFill { - fill: $euiColorEmptyShade; -} - // Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. .lnsNavItem__withDivider { @include euiBreakpoint('m', 'l', 'xl') { diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index 9d7ff324f62d9..32f40368601aa 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -429,8 +429,16 @@ export function DimensionEditor(props: DimensionEditorProps) { } else if (!compatibleWithCurrentField) { label = ( - - {label} + + + {label} + {shouldDisplayDots && ( @@ -502,6 +510,7 @@ export function DimensionEditor(props: DimensionEditorProps) { helpPopoverContainer.current = null; } }} + theme={props.core.theme} > diff --git a/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx b/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx index 803899b3028fd..0959bc7b42287 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/help_popover.tsx @@ -18,6 +18,9 @@ import { EuiText, } from '@elastic/eui'; import './help_popover.scss'; +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { ThemeServiceStart } from '@kbn/core/public'; export const HelpPopoverButton = ({ children, @@ -79,6 +82,7 @@ export const WrappingHelpPopover = ({ closePopover, isOpen, title, + theme, }: { anchorPosition?: EuiWrappingPopoverProps['anchorPosition']; button: EuiWrappingPopoverProps['button']; @@ -86,23 +90,28 @@ export const WrappingHelpPopover = ({ closePopover: EuiWrappingPopoverProps['closePopover']; isOpen: EuiWrappingPopoverProps['isOpen']; title?: string; + theme: ThemeServiceStart; }) => { return ( - - {title && {title}} + + + + {title && {title}} - - {children} - - + + {children} + + + + ); }; diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index ae0a4d9cf71f9..fc504a33aa743 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -379,17 +379,21 @@ export function getTextBasedDatasource({ } render( - , + + + {' '} + + , domElement ); }, @@ -421,73 +425,75 @@ export function getTextBasedDatasource({ }); render( - - { - const meta = fields.find((f) => f.name === choice.field)?.meta; - const newColumn = { - columnId: props.columnId, - fieldName: choice.field, - meta, - }; - return props.setState( - !selectedField - ? { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: [...props.state.layers[props.layerId].columns, newColumn], - allColumns: [ - ...props.state.layers[props.layerId].allColumns, - newColumn, - ], + + + { + const meta = fields.find((f) => f.name === choice.field)?.meta; + const newColumn = { + columnId: props.columnId, + fieldName: choice.field, + meta, + }; + return props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [...props.state.layers[props.layerId].columns, newColumn], + allColumns: [ + ...props.state.layers[props.layerId].allColumns, + newColumn, + ], + }, }, - }, - } - : { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: props.state.layers[props.layerId].columns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), - allColumns: props.state.layers[props.layerId].allColumns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: choice.field, meta } + ), + allColumns: props.state.layers[props.layerId].allColumns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: choice.field, meta } + ), + }, }, - }, - } - ); - }} - /> - - {props.dataSectionExtra && ( -
- {props.dataSectionExtra} -
- )} + } + ); + }} + /> +
+ {props.dataSectionExtra && ( +
+ {props.dataSectionExtra} +
+ )} +
, domElement ); diff --git a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx index d2de460da39f8..d0888189b8592 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions/save_action.tsx @@ -51,48 +51,46 @@ export const SaveModal = ({ const closeModal = () => unmountComponentAtNode(domElement); return ( - - onSave({ ...props, closeModal, newTags: selectedTags })} - onClose={closeModal} - title={title} - description={description} - showCopyOnSave={showCopyOnSave} - objectType={i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.objectType', - { defaultMessage: 'group' } - )} - customModalTitle={i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.modalTitle', - { - defaultMessage: 'Save annotation group to library', - } - )} - showDescription={true} - confirmButtonLabel={ - <> -
- -
-
- {i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.confirmButton', - { defaultMessage: 'Save group' } - )} -
- - } - options={ - savedObjectsTagging ? ( - - ) : undefined + onSave({ ...props, closeModal, newTags: selectedTags })} + onClose={closeModal} + title={title} + description={description} + showCopyOnSave={showCopyOnSave} + objectType={i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.objectType', + { defaultMessage: 'group' } + )} + customModalTitle={i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.modalTitle', + { + defaultMessage: 'Save annotation group to library', } - /> -
+ )} + showDescription={true} + confirmButtonLabel={ + <> +
+ +
+
+ {i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.confirmButton', + { defaultMessage: 'Save group' } + )} +
+ + } + options={ + savedObjectsTagging ? ( + + ) : undefined + } + /> ); }; @@ -206,27 +204,29 @@ export const onSave = async ({ ), text: ((element) => render( -

- goToAnnotationLibrary()} - > - {i18n.translate( - 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary', - { - defaultMessage: 'annotation library', - } - )} - - ), - }} - /> -

, + +

+ goToAnnotationLibrary()} + > + {i18n.translate( + 'xpack.lens.xyChart.annotations.saveAnnotationGroupToLibrary.annotationLibrary', + { + defaultMessage: 'annotation library', + } + )} + + ), + }} + /> +

+
, element )) as MountPoint, }); @@ -272,26 +272,28 @@ export const getSaveLayerAction = ({ if (domElement) { render( - { - await onSave({ - state, - layer, - setState, - eventAnnotationService, - toasts, - modalOnSaveProps: props, - dataViews, - goToAnnotationLibrary, - }); - }} - title={neverSaved ? '' : layer.__lastSaved.title} - description={neverSaved ? '' : layer.__lastSaved.description} - tags={neverSaved ? [] : layer.__lastSaved.tags} - showCopyOnSave={!neverSaved} - /> + + { + await onSave({ + state, + layer, + setState, + eventAnnotationService, + toasts, + modalOnSaveProps: props, + dataViews, + goToAnnotationLibrary, + }); + }} + title={neverSaved ? '' : layer.__lastSaved.title} + description={neverSaved ? '' : layer.__lastSaved.description} + tags={neverSaved ? [] : layer.__lastSaved.tags} + showCopyOnSave={!neverSaved} + /> + , domElement ); diff --git a/x-pack/plugins/observability/common/threshold_rule/types.ts b/x-pack/plugins/observability/common/threshold_rule/types.ts index 0f9dac9f88bd3..0762fef113ead 100644 --- a/x-pack/plugins/observability/common/threshold_rule/types.ts +++ b/x-pack/plugins/observability/common/threshold_rule/types.ts @@ -6,7 +6,6 @@ */ import * as rt from 'io-ts'; -import { indexPatternRt } from '@kbn/io-ts-utils'; import { ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils/anomaly_threshold'; import { values } from 'lodash'; import { Color } from './color_palette'; @@ -94,68 +93,14 @@ export const logDataViewReferenceRT = rt.type({ dataViewId: rt.string, }); -export type LogDataViewReference = rt.TypeOf; - // Index name export const logIndexNameReferenceRT = rt.type({ type: rt.literal('index_name'), indexName: rt.string, }); -export type LogIndexNameReference = rt.TypeOf; export const logIndexReferenceRT = rt.union([logDataViewReferenceRT, logIndexNameReferenceRT]); -/** - * Properties that represent a full source configuration, which is the result of merging static values with - * saved values. - */ -const SourceConfigurationFieldsRT = rt.type({ - message: rt.array(rt.string), -}); -export const SourceConfigurationRT = rt.type({ - name: rt.string, - description: rt.string, - metricAlias: rt.string, - logIndices: logIndexReferenceRT, - inventoryDefaultView: rt.string, - metricsExplorerDefaultView: rt.string, - fields: SourceConfigurationFieldsRT, - logColumns: rt.array(SourceConfigurationColumnRuntimeType), - anomalyThreshold: rt.number, -}); - -export const metricsSourceConfigurationPropertiesRT = rt.strict({ - name: SourceConfigurationRT.props.name, - description: SourceConfigurationRT.props.description, - metricAlias: SourceConfigurationRT.props.metricAlias, - inventoryDefaultView: SourceConfigurationRT.props.inventoryDefaultView, - metricsExplorerDefaultView: SourceConfigurationRT.props.metricsExplorerDefaultView, - anomalyThreshold: rt.number, -}); - -export type MetricsSourceConfigurationProperties = rt.TypeOf< - typeof metricsSourceConfigurationPropertiesRT ->; - -export const partialMetricsSourceConfigurationReqPayloadRT = rt.partial({ - ...metricsSourceConfigurationPropertiesRT.type.props, - metricAlias: indexPatternRt, -}); - -export const partialMetricsSourceConfigurationPropertiesRT = rt.partial({ - ...metricsSourceConfigurationPropertiesRT.type.props, -}); - -export type PartialMetricsSourceConfigurationProperties = rt.TypeOf< - typeof partialMetricsSourceConfigurationPropertiesRT ->; - -const metricsSourceConfigurationOriginRT = rt.keyof({ - fallback: null, - internal: null, - stored: null, -}); - /** * Source status */ @@ -180,32 +125,6 @@ export const metricsSourceStatusRT = rt.strict({ export type MetricsSourceStatus = rt.TypeOf; -export const metricsSourceConfigurationRT = rt.exact( - rt.intersection([ - rt.type({ - id: rt.string, - origin: metricsSourceConfigurationOriginRT, - configuration: metricsSourceConfigurationPropertiesRT, - }), - rt.partial({ - updatedAt: rt.number, - version: rt.string, - status: metricsSourceStatusRT, - }), - ]) -); - -export type MetricsSourceConfiguration = rt.TypeOf; -export type PartialMetricsSourceConfiguration = DeepPartial; - -export const metricsSourceConfigurationResponseRT = rt.type({ - source: metricsSourceConfigurationRT, -}); - -export type MetricsSourceConfigurationResponse = rt.TypeOf< - typeof metricsSourceConfigurationResponseRT ->; - export enum Comparator { GT = '>', LT = '<', diff --git a/x-pack/plugins/observability/kibana.jsonc b/x-pack/plugins/observability/kibana.jsonc index 5c08133f15a95..a34eb3ffb0ae6 100644 --- a/x-pack/plugins/observability/kibana.jsonc +++ b/x-pack/plugins/observability/kibana.jsonc @@ -13,6 +13,7 @@ "charts", "data", "dataViews", + "dataViewEditor", "embeddable", "exploratoryView", "features", @@ -29,7 +30,7 @@ "visualizations" ], "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"], - "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "spaces"], + "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "stackAlerts", "spaces"], "extraPublicDirs": ["common"] } } diff --git a/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx b/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx index b09bbb8656699..322d8a467ef38 100644 --- a/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx +++ b/x-pack/plugins/observability/public/application/hideable_react_query_dev_tools.tsx @@ -20,6 +20,7 @@ export function HideableReactQueryDevTools() { color="primary" style={{ zIndex: 99999, position: 'fixed', bottom: '40px', left: '40px' }} onClick={() => setIsHidden(!isHidden)} + aria-label="Hide react query" />
diff --git a/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx b/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx index 4df93e45db500..9f762b20e9146 100644 --- a/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx +++ b/x-pack/plugins/observability/public/components/co_pilot_prompt/co_pilot_prompt.tsx @@ -129,7 +129,7 @@ export default function CoPilotPrompt({ } const tooltipContent = i18n.translate('xpack.observability.coPilotPrompt.askCoPilot', { - defaultMessage: 'Ask Observability Co-Pilot for assistence', + defaultMessage: 'Ask Observability AI Assistent for help', }); return ( diff --git a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap similarity index 92% rename from x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap rename to x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap index 5ee10d2d3381e..f7ccb9d6715c1 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap +++ b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/alert_details_app_section.test.tsx.snap @@ -19,7 +19,7 @@ Array [ "chartType": "line", "derivedIndexPattern": Object { "fields": Array [], - "title": "metricbeat-*", + "title": "unknown-index", }, "expression": Object { "aggType": "count", @@ -35,9 +35,6 @@ Array [ "host.hostname", ], "hideTitle": true, - "source": Object { - "id": "default", - }, "timeRange": Object { "from": "2023-03-28T10:43:13.802Z", "to": "2023-03-29T13:14:09.581Z", diff --git a/x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/expression_row.test.tsx.snap b/x-pack/plugins/observability/public/components/threshold/components/__snapshots__/expression_row.test.tsx.snap similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/__snapshots__/expression_row.test.tsx.snap rename to x-pack/plugins/observability/public/components/threshold/components/__snapshots__/expression_row.test.tsx.snap diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx similarity index 91% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx index d372bdbc95bee..6e490bdb06da0 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.test.tsx @@ -16,7 +16,7 @@ import { buildMetricThresholdAlert, buildMetricThresholdRule, } from '../mocks/metric_threshold_rule'; -import { AlertDetailsAppSection } from './alert_details_app_section'; +import AlertDetailsAppSection from './alert_details_app_section'; import { ExpressionChart } from './expression_chart'; const mockedChartStartContract = chartPluginMock.createStartContract(); @@ -43,14 +43,6 @@ jest.mock('../../../utils/kibana_react', () => ({ }), })); -jest.mock('../helpers/source', () => ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); - describe('AlertDetailsAppSection', () => { const queryClient = new QueryClient(); const mockedSetAlertSummaryFields = jest.fn(); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx similarity index 93% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx index b5ff1d62c2b54..1995afa1923a2 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/alert_details_app_section.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/alert_details_app_section.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DataViewBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useEffect, useMemo } from 'react'; @@ -35,7 +36,6 @@ import { ExpressionChart } from './expression_chart'; import { TIME_LABELS } from './criterion_preview_chart/criterion_preview_chart'; import { Threshold } from './threshold'; import { MetricsExplorerChartType } from '../hooks/use_metrics_explorer_options'; -import { useSourceContext, withSourceProvider } from '../helpers/source'; import { MetricThresholdRuleTypeParams } from '../types'; // TODO Use a generic props for app sections https://github.com/elastic/kibana/issues/152690 @@ -58,19 +58,23 @@ interface AppSectionProps { setAlertSummaryFields: React.Dispatch>; } -export function AlertDetailsAppSection({ +// eslint-disable-next-line import/no-default-export +export default function AlertDetailsAppSection({ alert, rule, ruleLink, setAlertSummaryFields, }: AppSectionProps) { const { uiSettings, charts } = useKibana().services; - const { source, createDerivedIndexPattern } = useSourceContext(); const { euiTheme } = useEuiTheme(); - const derivedIndexPattern = useMemo( - () => createDerivedIndexPattern(), - [createDerivedIndexPattern] + // TODO Use rule data view + const derivedIndexPattern = useMemo( + () => ({ + fields: [], + title: 'unknown-index', + }), + [] ); const chartProps = { theme: charts.theme.useChartsTheme(), @@ -162,7 +166,6 @@ export function AlertDetailsAppSection({ filterQuery={rule.params.filterQueryText} groupBy={rule.params.groupBy} hideTitle - source={source} timeRange={timeRange} />
@@ -173,5 +176,3 @@ export function AlertDetailsAppSection({ ) : null; } -// eslint-disable-next-line import/no-default-export -export default withSourceProvider(AlertDetailsAppSection)('default'); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/alert_flyout.tsx b/x-pack/plugins/observability/public/components/threshold/components/alert_flyout.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/alert_flyout.tsx rename to x-pack/plugins/observability/public/components/threshold/components/alert_flyout.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/autocomplete_field.tsx b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/autocomplete_field.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/autocomplete_field.tsx rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/autocomplete_field.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/index.ts b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/index.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/index.ts rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/index.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/suggestion_item.tsx b/x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/suggestion_item.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/autocomplete_field/suggestion_item.tsx rename to x-pack/plugins/observability/public/components/threshold/components/autocomplete_field/suggestion_item.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/criterion_preview_chart.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.test.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.tsx b/x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/criterion_preview_chart/threshold_annotations.tsx rename to x-pack/plugins/observability/public/components/threshold/components/criterion_preview_chart/threshold_annotations.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.stories.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.stories.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.stories.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.stories.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/custom_equation_editor.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/custom_equation_editor.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/index.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/index.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/index.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/index.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_controls.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_controls.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_controls.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_controls.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_agg.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_agg.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_agg.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_agg.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_count.tsx b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_count.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/metric_row_with_count.tsx rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/metric_row_with_count.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/components/custom_equation/types.ts b/x-pack/plugins/observability/public/components/threshold/components/custom_equation/types.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/components/custom_equation/types.ts rename to x-pack/plugins/observability/public/components/threshold/components/custom_equation/types.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx similarity index 81% rename from x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx index e87c3041c9284..692ffc9539ab2 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression.test.tsx @@ -5,34 +5,35 @@ * 2.0. */ +import { useKibana } from '../../../utils/kibana_react'; +import { kibanaStartMock } from '../../../utils/kibana_react.mock'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import React from 'react'; import { act } from 'react-dom/test-utils'; -// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` -import { coreMock as mockCoreMock } from '@kbn/core/public/mocks'; -import { Expressions } from './expression'; +import Expressions from './expression'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { MetricsExplorerMetric } from '../../../../common/threshold_rule/metrics_explorer'; import { Comparator } from '../../../../common/threshold_rule/types'; -jest.mock('../helpers/source', () => ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); +jest.mock('../../../utils/kibana_react'); -jest.mock('../../../utils/kibana_react', () => ({ - useKibana: () => ({ - services: mockCoreMock.createStart(), - }), -})); +const useKibanaMock = useKibana as jest.Mock; + +const mockKibana = () => { + useKibanaMock.mockReturnValue({ + ...kibanaStartMock.startContract(), + }); +}; const dataViewMock = dataViewPluginMocks.createStartContract(); describe('Expression', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockKibana(); + }); + async function setup(currentOptions: { metrics?: MetricsExplorerMetric[]; filterQuery?: string; @@ -43,6 +44,7 @@ describe('Expression', () => { groupBy: undefined, filterQueryText: '', sourceId: 'default', + searchConfiguration: {}, }; const wrapper = mountWithIntl( { setRuleProperty={() => {}} metadata={{ currentOptions, + adHocDataViewList: [], }} dataViews={dataViewMock} + onChangeMetaData={jest.fn()} /> ); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression.tsx similarity index 80% rename from x-pack/plugins/observability/public/pages/threshold/components/expression.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression.tsx index 7bc42ccd984ba..703b30fe64519 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiAccordion, @@ -18,6 +19,10 @@ import { EuiText, EuiToolTip, } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { DataViewBase } from '@kbn/es-query'; +import { DataViewSelectPopover } from '@kbn/stack-alerts-plugin/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; @@ -36,13 +41,12 @@ import { ExpressionRow } from './expression_row'; import { MetricsExplorerKueryBar } from './kuery_bar'; import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options'; import { convertKueryToElasticSearchQuery } from '../helpers/kuery'; -import { useSourceContext, withSourceProvider } from '../helpers/source'; import { MetricsExplorerGroupBy } from './group_by'; const FILTER_TYPING_DEBOUNCE_MS = 500; type Props = Omit< RuleTypeParamsExpressionProps, - 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' | 'onChangeMetaData' + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch' >; export const defaultExpression = { @@ -53,18 +57,55 @@ export const defaultExpression = { timeUnit: 'm', } as MetricExpression; -export function Expressions(props: Props) { - const { setRuleParams, ruleParams, errors, metadata } = props; - const { docLinks } = useKibana().services; - const { source, createDerivedIndexPattern } = useSourceContext(); +// eslint-disable-next-line import/no-default-export +export default function Expressions(props: Props) { + const { setRuleParams, ruleParams, errors, metadata, onChangeMetaData } = props; + const { data, dataViews, dataViewEditor, docLinks } = useKibana().services; const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); - const derivedIndexPattern = useMemo( - () => createDerivedIndexPattern(), - [createDerivedIndexPattern] + const [dataView, setDataView] = useState(); + const [searchSource, setSearchSource] = useState(); + const derivedIndexPattern = useMemo( + () => ({ + fields: dataView?.fields || [], + title: dataView?.getIndexPattern() || 'unknown-index', + }), + [dataView] ); + useEffect(() => { + const initSearchSource = async () => { + let initialSearchConfiguration = ruleParams.searchConfiguration; + + if (!ruleParams.searchConfiguration) { + const newSearchSource = data.search.searchSource.createEmpty(); + newSearchSource.setField('query', data.query.queryString.getDefaultQuery()); + const defaultDataView = await data.dataViews.getDefaultDataView(); + if (defaultDataView) { + newSearchSource.setField('index', defaultDataView); + setDataView(defaultDataView); + } + initialSearchConfiguration = newSearchSource.getSerializedFields(); + } + + try { + const createdSearchSource = await data.search.searchSource.create( + initialSearchConfiguration + ); + setRuleParams('searchConfiguration', initialSearchConfiguration); + setSearchSource(createdSearchSource); + setDataView(createdSearchSource.getField('index')); + } catch (error) { + // TODO Handle error + console.log('error:', error); + } + }; + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); + const options = useMemo(() => { if (metadata?.currentOptions?.metrics) { return metadata.currentOptions as MetricsExplorerOptions; @@ -76,31 +117,49 @@ export function Expressions(props: Props) { } }, [metadata]); + const onSelectDataView = useCallback( + (newDataView: DataView) => { + const ruleCriteria = (ruleParams.criteria ? ruleParams.criteria.slice() : []).map( + (criterion) => { + criterion.customMetrics?.forEach((metric) => { + metric.field = undefined; + }); + return criterion; + } + ); + setRuleParams('criteria', ruleCriteria); + searchSource?.setParent(undefined).setField('index', newDataView); + setRuleParams('searchConfiguration', searchSource?.getSerializedFields()); + setDataView(newDataView); + }, + [ruleParams.criteria, searchSource, setRuleParams] + ); + const updateParams = useCallback( (id, e: MetricExpression) => { - const exp = ruleParams.criteria ? ruleParams.criteria.slice() : []; - exp[id] = e; - setRuleParams('criteria', exp); + const ruleCriteria = ruleParams.criteria ? ruleParams.criteria.slice() : []; + ruleCriteria[id] = e; + setRuleParams('criteria', ruleCriteria); }, [setRuleParams, ruleParams.criteria] ); const addExpression = useCallback(() => { - const exp = ruleParams.criteria?.slice() || []; - exp.push({ + const ruleCriteria = ruleParams.criteria?.slice() || []; + ruleCriteria.push({ ...defaultExpression, timeSize: timeSize ?? defaultExpression.timeSize, timeUnit: timeUnit ?? defaultExpression.timeUnit, }); - setRuleParams('criteria', exp); + setRuleParams('criteria', ruleCriteria); }, [setRuleParams, ruleParams.criteria, timeSize, timeUnit]); const removeExpression = useCallback( (id: number) => { - const exp = ruleParams.criteria?.slice() || []; - if (exp.length > 1) { - exp.splice(id, 1); - setRuleParams('criteria', exp); + const ruleCriteria = ruleParams.criteria?.slice() || []; + if (ruleCriteria.length > 1) { + ruleCriteria.splice(id, 1); + setRuleParams('criteria', ruleCriteria); } }, [setRuleParams, ruleParams.criteria] @@ -143,26 +202,25 @@ export function Expressions(props: Props) { const updateTimeSize = useCallback( (ts: number | undefined) => { - const criteria = + const ruleCriteria = ruleParams.criteria?.map((c) => ({ ...c, timeSize: ts, })) || []; setTimeSize(ts || undefined); - setRuleParams('criteria', criteria); + setRuleParams('criteria', ruleCriteria); }, [ruleParams.criteria, setRuleParams] ); const updateTimeUnit = useCallback( (tu: string) => { - const criteria = - ruleParams.criteria?.map((c) => ({ - ...c, - timeUnit: tu, - })) || []; + const ruleCriteria = (ruleParams.criteria?.map((c) => ({ + ...c, + timeUnit: tu, + })) || []) as AlertParams['criteria']; setTimeUnit(tu as TimeUnitChar); - setRuleParams('criteria', criteria as AlertParams['criteria']); + setRuleParams('criteria', ruleCriteria); }, [ruleParams.criteria, setRuleParams] ); @@ -230,17 +288,13 @@ export function Expressions(props: Props) { preFillAlertGroupBy(); } - if (!ruleParams.sourceId) { - setRuleParams('sourceId', source?.id || 'default'); - } - if (typeof ruleParams.alertOnNoData === 'undefined') { setRuleParams('alertOnNoData', true); } if (typeof ruleParams.alertOnGroupDisappear === 'undefined') { setRuleParams('alertOnGroupDisappear', true); } - }, [metadata, source]); // eslint-disable-line react-hooks/exhaustive-deps + }, [metadata]); // eslint-disable-line react-hooks/exhaustive-deps const handleFieldSearchChange = useCallback( (e: ChangeEvent) => onFilterChange(e.target.value), @@ -283,7 +337,15 @@ export function Expressions(props: Props) { return ( <> - + { + onChangeMetaData({ ...metadata, adHocDataViewList }); + }} + /> +

1) || false} - fields={derivedIndexPattern.fields} + fields={derivedIndexPattern.fields as any} remove={removeExpression} addExpression={addExpression} key={idx} // idx's don't usually make good key's but here the index has semantic meaning @@ -308,17 +370,16 @@ export function Expressions(props: Props) { expression={e || {}} dataView={derivedIndexPattern} > + {/* Preview */} ); })} -
-
- - - {(metadata && ( + {(metadata && derivedIndexPattern && ( )} - (Expressions)('default'); diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx similarity index 83% rename from x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx index 88d57b8688972..c4314aeb6b665 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression_chart.test.tsx @@ -14,11 +14,7 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { coreMock as mockCoreMock } from '@kbn/core/public/mocks'; import { MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; -import { - Aggregators, - Comparator, - MetricsSourceConfiguration, -} from '../../../../common/threshold_rule/types'; +import { Aggregators, Comparator } from '../../../../common/threshold_rule/types'; const mockStartServices = mockCoreMock.createStart(); @@ -57,24 +53,10 @@ describe('ExpressionChart', () => { fields: [], }; - const source: MetricsSourceConfiguration = { - id: 'default', - origin: 'fallback', - configuration: { - name: 'default', - description: 'The default configuration', - metricAlias: 'metricbeat-*', - inventoryDefaultView: 'host', - metricsExplorerDefaultView: 'host', - anomalyThreshold: 20, - }, - }; - const wrapper = mountWithIntl( ({ - withSourceProvider: () => jest.fn, - useSourceContext: () => ({ - source: { id: 'default' }, - createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), - }), -})); - describe('ExpressionRow', () => { async function setup(expression: MetricExpression) { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx b/x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx similarity index 92% rename from x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx rename to x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx index 08844ec7a9fd7..4bca96a8da3f6 100644 --- a/x-pack/plugins/observability/public/pages/threshold/components/expression_row.tsx +++ b/x-pack/plugins/observability/public/components/threshold/components/expression_row.tsx @@ -144,20 +144,6 @@ export const ExpressionRow: React.FC = (props) => { name: f.name, })); - // for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */ - // const updateAggType = useCallback( - // (at: string) => { - // setRuleParams(expressionId, { - // ...expression, - // aggType: at as MetricExpression['aggType'], - // metric: ['custom', 'count'].includes(at) ? undefined : expression.metric, - // customMetrics: at === 'custom' ? expression.customMetrics : undefined, - // equation: at === 'custom' ? expression.equation : undefined, - // label: at === 'custom' ? expression.label : undefined, - // }); - // }, - // [expressionId, expression, setRuleParams] - // ); return ( <> @@ -177,12 +163,6 @@ export const ExpressionRow: React.FC = (props) => { - {/* for v8.9 we want to show only the Custom Equation. Use EuiExpression instead of WhenExpression */} - {/* */} { diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx similarity index 94% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx index d2c8955e00913..20a7e6213aefa 100644 --- a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.test.tsx @@ -17,13 +17,11 @@ import { MetricsExplorerTimestampsRT, } from './use_metrics_explorer_options'; import { DataViewBase } from '@kbn/es-query'; -import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types'; import { createSeries, derivedIndexPattern, options, resp, - source, timestamps, } from '../../../utils/metrics_explorer'; @@ -54,20 +52,12 @@ const renderUseMetricsExplorerDataHook = () => { return renderHook( (props: { options: MetricsExplorerOptions; - source: MetricsSourceConfigurationProperties | undefined; derivedIndexPattern: DataViewBase; timestamps: MetricsExplorerTimestampsRT; - }) => - useMetricsExplorerData( - props.options, - props.source, - props.derivedIndexPattern, - props.timestamps - ), + }) => useMetricsExplorerData(props.options, props.derivedIndexPattern, props.timestamps), { initialProps: { options, - source, derivedIndexPattern, timestamps, }, @@ -163,7 +153,6 @@ describe('useMetricsExplorerData Hook', () => { aggregation: 'count', metrics: [{ aggregation: 'count' }], }, - source, derivedIndexPattern, timestamps, }); @@ -187,7 +176,6 @@ describe('useMetricsExplorerData Hook', () => { mockedFetch.mockResolvedValue(resp as any); rerender({ options, - source, derivedIndexPattern, timestamps: { fromTimestamp: 1678378092225, toTimestamp: 1678381693477, interval: '>=10s' }, }); diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts similarity index 90% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts index 206e8fe3818f1..eeb4d52d70c36 100644 --- a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_data.ts @@ -9,7 +9,6 @@ import { DataViewBase } from '@kbn/es-query'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { MetricsSourceConfigurationProperties } from '../../../../common/threshold_rule/types'; import { MetricsExplorerResponse, metricsExplorerResponseRT, @@ -24,7 +23,6 @@ import { decodeOrThrow } from '../helpers/runtime_types'; export function useMetricsExplorerData( options: MetricsExplorerOptions, - source: MetricsSourceConfigurationProperties | undefined, derivedIndexPattern: DataViewBase, { fromTimestamp, toTimestamp, interval }: MetricsExplorerTimestampsRT, enabled = true @@ -35,7 +33,7 @@ export function useMetricsExplorerData( MetricsExplorerResponse, Error >({ - queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp], + queryKey: ['metricExplorer', options, fromTimestamp, toTimestamp, derivedIndexPattern.title], queryFn: async ({ signal, pageParam = { afterKey: null } }) => { if (!fromTimestamp || !toTimestamp) { throw new Error('Unable to parse timerange'); @@ -43,8 +41,8 @@ export function useMetricsExplorerData( if (!http) { throw new Error('HTTP service is unavailable'); } - if (!source) { - throw new Error('Source is unavailable'); + if (!derivedIndexPattern.title) { + throw new Error('Data view is unavailable'); } const { afterKey } = pageParam; @@ -57,7 +55,7 @@ export function useMetricsExplorerData( groupBy: options.groupBy, afterKey, limit: options.limit, - indexPattern: source.metricAlias, + indexPattern: derivedIndexPattern.title, filterQuery: (options.filterQuery && convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) || @@ -74,7 +72,7 @@ export function useMetricsExplorerData( return decodeOrThrow(metricsExplorerResponseRT)(response); }, getNextPageParam: (lastPage) => lastPage.pageInfo, - enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!source, + enabled: enabled && !!fromTimestamp && !!toTimestamp && !!http && !!derivedIndexPattern.title, refetchOnWindowFocus: false, }); diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.test.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.test.tsx rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.test.tsx diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_metrics_explorer_options.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_metrics_explorer_options.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/hooks/use_tracked_promise.ts b/x-pack/plugins/observability/public/components/threshold/hooks/use_tracked_promise.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/hooks/use_tracked_promise.ts rename to x-pack/plugins/observability/public/components/threshold/hooks/use_tracked_promise.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/i18n_strings.ts b/x-pack/plugins/observability/public/components/threshold/i18n_strings.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/i18n_strings.ts rename to x-pack/plugins/observability/public/components/threshold/i18n_strings.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.test.ts b/x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.test.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.test.ts rename to x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.test.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.ts b/x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/generate_unique_key.ts rename to x-pack/plugins/observability/public/components/threshold/lib/generate_unique_key.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/lib/transform_metrics_explorer_data.ts b/x-pack/plugins/observability/public/components/threshold/lib/transform_metrics_explorer_data.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/lib/transform_metrics_explorer_data.ts rename to x-pack/plugins/observability/public/components/threshold/lib/transform_metrics_explorer_data.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/mocks/metric_threshold_rule.ts b/x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/mocks/metric_threshold_rule.ts rename to x-pack/plugins/observability/public/components/threshold/mocks/metric_threshold_rule.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/rule_data_formatters.ts b/x-pack/plugins/observability/public/components/threshold/rule_data_formatters.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/threshold/rule_data_formatters.ts rename to x-pack/plugins/observability/public/components/threshold/rule_data_formatters.ts diff --git a/x-pack/plugins/observability/public/pages/threshold/types.ts b/x-pack/plugins/observability/public/components/threshold/types.ts similarity index 95% rename from x-pack/plugins/observability/public/pages/threshold/types.ts rename to x-pack/plugins/observability/public/components/threshold/types.ts index 6317b87ba2767..8aa3eea9ccbf3 100644 --- a/x-pack/plugins/observability/public/pages/threshold/types.ts +++ b/x-pack/plugins/observability/public/components/threshold/types.ts @@ -7,8 +7,8 @@ import * as rt from 'io-ts'; import { CasesUiStart } from '@kbn/cases-plugin/public'; import { ChartsPluginStart } from '@kbn/charts-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataPublicPluginStart, SerializedSearchSourceFields } from '@kbn/data-plugin/public'; +import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DiscoverStart } from '@kbn/discover-plugin/public'; import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; @@ -38,6 +38,7 @@ import { ObservabilityPublicStart } from '../../plugin'; import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options'; export interface AlertContextMeta { + adHocDataViewList: DataView[]; currentOptions?: Partial; series?: MetricsExplorerSeries; } @@ -94,6 +95,7 @@ export interface AlertParams { filterQueryText?: string; alertOnNoData?: boolean; alertOnGroupDisappear?: boolean; + searchConfiguration: SerializedSearchSourceFields; shouldDropPartialBuckets?: boolean; } diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 4be72ada7055a..64d640ef93475 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -88,7 +88,7 @@ const withCore = makeDecorator({ thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx b/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx deleted file mode 100644 index bf1ad0134ea8f..0000000000000 --- a/x-pack/plugins/observability/public/pages/threshold/helpers/source.tsx +++ /dev/null @@ -1,142 +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 createContainer from 'constate'; -import React, { useEffect, useState } from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { IHttpFetchError } from '@kbn/core-http-browser'; -import { - MetricsSourceConfiguration, - MetricsSourceConfigurationResponse, - PartialMetricsSourceConfigurationProperties, -} from '../../../../common/threshold_rule/types'; -import { MissingHttpClientException } from './source_errors'; -import { useTrackedPromise } from '../hooks/use_tracked_promise'; -import { useSourceNotifier } from './notifications'; - -export const pickIndexPattern = ( - source: MetricsSourceConfiguration | undefined, - type: 'metrics' -) => { - if (!source) { - return 'unknown-index'; - } - if (type === 'metrics') { - return source.configuration.metricAlias; - } - return `${source.configuration.metricAlias}`; -}; - -export const useSource = ({ sourceId }: { sourceId: string }) => { - const { services } = useKibana(); - - const notify = useSourceNotifier(); - - const fetchService = services.http; - const API_URL = `/api/metrics/source/${sourceId}`; - - const [source, setSource] = useState(undefined); - - const [loadSourceRequest, loadSource] = useTrackedPromise( - { - cancelPreviousOn: 'resolution', - createPromise: () => { - if (!fetchService) { - throw new MissingHttpClientException(); - } - - return fetchService.fetch(API_URL, { method: 'GET' }); - }, - onResolve: (response) => { - if (response) { - setSource(response.source); - } - }, - }, - [fetchService, sourceId] - ); - - const [createSourceConfigurationRequest, createSourceConfiguration] = useTrackedPromise( - { - createPromise: async (sourceProperties: PartialMetricsSourceConfigurationProperties) => { - if (!fetchService) { - throw new MissingHttpClientException(); - } - - return await fetchService.patch(API_URL, { - method: 'PATCH', - body: JSON.stringify(sourceProperties), - }); - }, - onResolve: (response) => { - if (response) { - notify.updateSuccess(); - setSource(response.source); - } - }, - onReject: (error) => { - notify.updateFailure((error as IHttpFetchError<{ message: string }>).body?.message); - }, - }, - [fetchService, sourceId] - ); - - useEffect(() => { - loadSource(); - }, [loadSource, sourceId]); - - const createDerivedIndexPattern = () => { - return { - fields: source?.status ? source.status.indexFields : [], - title: pickIndexPattern(source, 'metrics'), - }; - }; - - const hasFailedLoadingSource = loadSourceRequest.state === 'rejected'; - const isUninitialized = loadSourceRequest.state === 'uninitialized'; - const isLoadingSource = loadSourceRequest.state === 'pending'; - const isLoading = isLoadingSource || createSourceConfigurationRequest.state === 'pending'; - - const sourceExists = source ? !!source.version : undefined; - - const metricIndicesExist = Boolean(source?.status?.metricIndicesExist); - - const version = source?.version; - - return { - createSourceConfiguration, - createDerivedIndexPattern, - isLoading, - isLoadingSource, - isUninitialized, - hasFailedLoadingSource, - loadSource, - loadSourceRequest, - loadSourceFailureMessage: hasFailedLoadingSource ? `${loadSourceRequest.value}` : undefined, - metricIndicesExist, - source, - sourceExists, - sourceId, - updateSourceConfiguration: createSourceConfiguration, - version, - }; -}; - -export const [SourceProvider, useSourceContext] = createContainer(useSource); - -export const withSourceProvider = - (Component: React.FunctionComponent) => - (sourceId = 'default') => { - // eslint-disable-next-line react/function-component-definition - return function ComponentWithSourceProvider(props: ComponentProps) { - return ( - - - - ); - }; - }; diff --git a/x-pack/plugins/observability/public/plugin.mock.tsx b/x-pack/plugins/observability/public/plugin.mock.tsx index 1a42c2ed98aa8..aa847fd198cf4 100644 --- a/x-pack/plugins/observability/public/plugin.mock.tsx +++ b/x-pack/plugins/observability/public/plugin.mock.tsx @@ -71,6 +71,33 @@ const data = { timefilter: jest.fn(), }, }, + search: { + searchSource: jest.fn(), + }, + }; + }, +}; + +const dataViewEditor = { + createStart() { + return { + userPermissions: { + editDataView: jest.fn(), + }, + }; + }, +}; + +const dataViews = { + createStart() { + return { + getIds: jest.fn().mockImplementation(() => []), + get: jest.fn(), + create: jest.fn().mockImplementation(() => ({ + fields: { + getByName: jest.fn(), + }, + })), }; }, }; @@ -81,6 +108,8 @@ export const observabilityPublicPluginsStartMock = { cases: mockCasesContract(), triggersActionsUi: triggersActionsUiStartMock.createStart(), data: data.createStart(), + dataViews: dataViews.createStart(), + dataViewEditor: dataViewEditor.createStart(), lens: null, discover: null, }; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index fd6300eff8caa..1fb04d947bef2 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { BehaviorSubject, from } from 'rxjs'; import { map } from 'rxjs/operators'; +import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import { AppDeepLink, @@ -82,7 +83,7 @@ export interface ConfigSchema { }; }; compositeSlo: { enabled: boolean }; - coPilot?: { + aiAssistant?: { enabled?: boolean; }; } @@ -103,6 +104,7 @@ export interface ObservabilityPublicPluginsStart { charts: ChartsPluginStart; data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; discover: DiscoverStart; embeddable: EmbeddableStart; exploratoryView: ExploratoryViewPublicStart; @@ -328,7 +330,7 @@ export class Plugin ); this.coPilotService = createCoPilotService({ - enabled: !!config.coPilot?.enabled, + enabled: !!config.aiAssistant?.enabled, http: coreSetup.http, }); diff --git a/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts b/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts index 552473b36ded4..e6e5412cc046e 100644 --- a/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts +++ b/x-pack/plugins/observability/public/rules/register_observability_rule_types.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; import { ALERT_REASON } from '@kbn/rule-data-utils'; @@ -16,8 +17,8 @@ import { SLO_BURN_RATE_RULE_TYPE_ID, } from '../../common/constants'; import { validateBurnRateRule } from '../components/burn_rate_rule_editor/validation'; -import { validateMetricThreshold } from '../pages/threshold/components/validation'; -import { formatReason } from '../pages/threshold/rule_data_formatters'; +import { validateMetricThreshold } from '../components/threshold/components/validation'; +import { formatReason } from '../components/threshold/rule_data_formatters'; const sloBurnRateDefaultActionMessage = i18n.translate( 'xpack.observability.slo.rules.burnRate.defaultActionMessage', @@ -91,7 +92,7 @@ export const registerObservabilityRuleTypes = ( documentationUrl(docLinks) { return `${docLinks.links.observability.threshold}`; }, - ruleParamsExpression: lazy(() => import('../pages/threshold/components/expression')), + ruleParamsExpression: lazy(() => import('../components/threshold/components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.observability.threshold.rule.alerting.threshold.defaultActionMessage', @@ -106,7 +107,7 @@ export const registerObservabilityRuleTypes = ( requiresAppContext: false, format: formatReason, alertDetailsAppSection: lazy( - () => import('../pages/threshold/components/alert_details_app_section') + () => import('../components/threshold/components/alert_details_app_section') ), }); } diff --git a/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx b/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx index 67ca140fdcc57..98b4d32dd7c6c 100644 --- a/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx +++ b/x-pack/plugins/observability/public/utils/kibana_react.storybook_decorator.tsx @@ -35,7 +35,7 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) { thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/public/utils/metrics_explorer.ts b/x-pack/plugins/observability/public/utils/metrics_explorer.ts index ba8168a6f939e..fbbeb8a4f1937 100644 --- a/x-pack/plugins/observability/public/utils/metrics_explorer.ts +++ b/x-pack/plugins/observability/public/utils/metrics_explorer.ts @@ -16,7 +16,7 @@ import { MetricsExplorerTimeOptions, MetricsExplorerTimestampsRT, MetricsExplorerYAxisMode, -} from '../pages/threshold/hooks/use_metrics_explorer_options'; +} from '../components/threshold/hooks/use_metrics_explorer_options'; export const options: MetricsExplorerOptions = { limit: 3, diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index c60fa3e37cd55..ac0a79975af75 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -39,7 +39,7 @@ const defaultConfig: ConfigSchema = { thresholdRule: { enabled: false }, }, compositeSlo: { enabled: false }, - coPilot: { + aiAssistant: { enabled: false, }, }; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 1bdacac1aee58..3ea386dbb1d99 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -51,7 +51,7 @@ const configSchema = schema.object({ groupByPageSize: schema.number({ defaultValue: 10_000 }), }), enabled: schema.boolean({ defaultValue: true }), - coPilot: schema.maybe(observabilityCoPilotConfig), + aiAssistant: schema.maybe(observabilityCoPilotConfig), compositeSlo: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), @@ -60,7 +60,7 @@ const configSchema = schema.object({ export const config: PluginConfigDescriptor = { exposeToBrowser: { unsafe: true, - coPilot: { + aiAssistant: { enabled: true, }, }, diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts b/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts index 420bf33e77802..543492e6719f2 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/register_threshold_rule_type.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; import { IRuleTypeAlerts } from '@kbn/alerting-plugin/server'; import { IBasePath, Logger } from '@kbn/core/server'; @@ -16,10 +17,11 @@ import { IRuleDataClient, } from '@kbn/rule-registry-plugin/server'; import { LicenseType } from '@kbn/licensing-plugin/server'; -import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; +import { EsQueryRuleParamsExtractedParams } from '@kbn/stack-alerts-plugin/server/rule_types/es_query/rule_type_params'; import { observabilityFeatureId } from '../../../../common'; import { Comparator } from '../../../../common/threshold_rule/types'; import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '../../../../common/constants'; +import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; import { alertDetailUrlActionVariableDescription, @@ -148,7 +150,6 @@ export function thresholdRuleType( validate: validateIsStringElasticsearchJSONFilter, }) ), - sourceId: schema.string(), alertOnNoData: schema.maybe(schema.boolean()), alertOnGroupDisappear: schema.maybe(schema.boolean()), }, @@ -203,6 +204,21 @@ export function thresholdRuleType( }, ], }, + useSavedObjectReferences: { + // TODO revisit types https://github.com/elastic/kibana/issues/159714 + extractReferences: (params: any) => { + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams; + + return { params: newParams, references }; + }, + injectReferences: (params: any, references: any) => { + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, producer: observabilityFeatureId, getSummarizedAlerts: getSummarizedAlerts(), alerts: MetricsRulesTypeAlertDefinition, diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts b/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts deleted file mode 100644 index 3d3c7a17cd1dd..0000000000000 --- a/x-pack/plugins/observability/server/lib/rules/threshold/test_mocks.ts +++ /dev/null @@ -1,237 +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. - */ - -const bucketsA = (from: number) => [ - { - doc_count: null, - aggregatedValue: { value: null, values: [{ key: 95.0, value: null }] }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 3, - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -const bucketsB = (from: number) => [ - { - doc_count: 0, - aggregatedValue: { value: null, values: [{ key: 99.0, value: null }] }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 4, - aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 5, - aggregatedValue: { value: 3.5, values: [{ key: 99.0, value: 3.5 }] }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 3, values: [{ key: 99.0, value: 3 }] }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -const bucketsC = (from: number) => [ - { - doc_count: 0, - aggregatedValue: { value: null }, - from_as_string: new Date(from).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 60000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 120000).toISOString(), - }, - { - doc_count: 2, - aggregatedValue: { value: 0.5 }, - from_as_string: new Date(from + 180000).toISOString(), - }, - { - doc_count: 3, - aggregatedValue: { value: 16 }, - from_as_string: new Date(from + 240000).toISOString(), - }, - { - doc_count: 1, - aggregatedValue: { value: 3 }, - from_as_string: new Date(from + 300000).toISOString(), - }, -]; - -export const basicMetricResponse = () => ({ - hits: { - total: { - value: 1, - }, - }, - aggregations: { - aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, - }, -}); - -export const alternateMetricResponse = () => ({ - hits: { - total: { - value: 1, - }, - }, - aggregations: { - aggregatedValue: { value: 3, values: [{ key: 99.0, value: 3 }] }, - }, -}); - -export const emptyMetricResponse = { - aggregations: { - aggregatedIntervals: { - buckets: [], - }, - }, -}; - -export const emptyRateResponse = (from: number) => ({ - aggregations: { - aggregatedIntervals: { - buckets: [ - { - doc_count: 2, - aggregatedValueMax: { value: null }, - from_as_string: new Date(from).toISOString(), - }, - ], - }, - }, -}); - -export const basicCompositeResponse = (from: number) => ({ - aggregations: { - groupings: { - after_key: { groupBy0: 'foo' }, - buckets: [ - { - key: { - groupBy0: 'a', - }, - aggregatedIntervals: { - buckets: bucketsA(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'b', - }, - aggregatedIntervals: { - buckets: bucketsB(from), - }, - doc_count: 1, - }, - ], - }, - }, - hits: { - total: { - value: 2, - }, - }, -}); - -export const alternateCompositeResponse = (from: number) => ({ - aggregations: { - groupings: { - after_key: { groupBy0: 'foo' }, - buckets: [ - { - key: { - groupBy0: 'a', - }, - aggregatedIntervals: { - buckets: bucketsB(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'b', - }, - aggregatedIntervals: { - buckets: bucketsA(from), - }, - doc_count: 1, - }, - { - key: { - groupBy0: 'c', - }, - aggregatedIntervals: { - buckets: bucketsC(from), - }, - doc_count: 1, - }, - ], - }, - }, - hits: { - total: { - value: 3, - }, - }, -}); - -export const compositeEndResponse = { - aggregations: {}, - hits: { total: { value: 0 } }, -}; - -export const changedSourceIdResponse = (from: number) => ({ - aggregations: { - aggregatedIntervals: { - buckets: bucketsC(from), - }, - }, -}); diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts b/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts index 25e7ef8fb7967..5e11aca243f14 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/threshold_executor.ts @@ -120,7 +120,7 @@ export const createMetricThresholdExecutor = ({ }); // TODO: check if we need to use "savedObjectsClient"=> https://github.com/elastic/kibana/issues/159340 - const { alertWithLifecycle, getAlertUuid, getAlertByAlertUuid, dataViews } = services; + const { alertWithLifecycle, getAlertUuid, getAlertByAlertUuid, searchSourceClient } = services; const alertFactory: MetricThresholdAlertFactory = ( id, @@ -138,9 +138,8 @@ export const createMetricThresholdExecutor = ({ ...flattenAdditionalContext(additionalContext), }, }); - // TODO: check if we need to use "sourceId" + const { alertOnNoData, alertOnGroupDisappear: _alertOnGroupDisappear } = params as { - sourceId?: string; alertOnNoData: boolean; alertOnGroupDisappear: boolean | undefined; }; @@ -188,9 +187,9 @@ export const createMetricThresholdExecutor = ({ alertOnGroupDisappear && filterQueryIsSame && groupByIsSame && state.missingGroups ? state.missingGroups : []; - // TODO: check the DATA VIEW - const defaultDataView = await dataViews.getDefaultDataView(); - const dataView = defaultDataView?.getIndexPattern(); + + const initialSearchSource = await searchSourceClient.create(params.searchConfiguration!); + const dataView = initialSearchSource.getField('index')!.getIndexPattern(); if (!dataView) { throw new Error('No matched data view'); } diff --git a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts index 0f3c10611076e..d98a59bd50b9b 100644 --- a/x-pack/plugins/observability/server/lib/rules/threshold/types.ts +++ b/x-pack/plugins/observability/server/lib/rules/threshold/types.ts @@ -37,7 +37,6 @@ export interface MetricAnomalyParams { nodeType: rt.TypeOf; metric: rt.TypeOf; alertInterval?: string; - sourceId?: string; spaceId?: string; threshold: Exclude; influencerFilter: rt.TypeOf | undefined; @@ -48,7 +47,6 @@ export interface MetricAnomalyParams { interface BaseMetricExpressionParams { timeSize: number; timeUnit: TimeUnitChar; - sourceId?: string; threshold: number[]; comparator: Comparator; warningComparator?: Comparator; diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index c94682528a8f6..ab74a511b265f 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -242,7 +242,9 @@ export class ObservabilityPlugin implements Plugin { ); registerSloUsageCollector(plugins.usageCollection); - const openAIService = config.coPilot?.enabled ? new OpenAIService(config.coPilot) : undefined; + const openAIService = config.aiAssistant?.enabled + ? new OpenAIService(config.aiAssistant) + : undefined; core.getStartServices().then(([coreStart, pluginStart]) => { registerRoutes({ diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 77f668338ba35..09e809d69542b 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -77,7 +77,9 @@ "@kbn/safer-lodash-set", "@kbn/core-http-server", "@kbn/cloud-chat-plugin", - "@kbn/cloud-plugin" + "@kbn/cloud-plugin", + "@kbn/stack-alerts-plugin", + "@kbn/data-view-editor-plugin" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts index f1379f9af0ef6..278cba8393741 100644 --- a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts @@ -4751,6 +4751,13 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { ['login:']: { authorizedSpaces: ['x', 'foo'] }, }); + const auditObjects = objects.map((obj) => { + return { + type: obj.saved_object.type, + id: obj.saved_object.id, + }; + }); + test('returns empty array when no objects are provided`', async () => { const { securityExtension } = setup(); const emptyObjects: Array | BulkResolveError> = []; @@ -4790,6 +4797,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { spaces: expectedSpaces, enforceMap: expectedEnforceMap, auditOptions: { + objects: auditObjects, useSuccessOutcome: true, }, }); @@ -4817,7 +4825,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: SecurityAction.INTERNAL_BULK_RESOLVE, typesAndSpaces: expectedEnforceMap, typeMap: partiallyAuthorizedTypeMap, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditObjects, useSuccessOutcome: true }, }); }); @@ -4835,7 +4843,7 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: SecurityAction.INTERNAL_BULK_RESOLVE, typesAndSpaces: expectedEnforceMap, typeMap: fullyAuthorizedTypeMap, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditObjects, useSuccessOutcome: true }, }); expect(result).toEqual(objects); }); @@ -4866,37 +4874,44 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { action: 'saved_object_resolve', addToSpaces: undefined, deleteFromSpaces: undefined, - objects: undefined, + objects: auditObjects, useSuccessOutcome: true, }); - expect(addAuditEventSpy).toHaveBeenCalledTimes(1); - expect(addAuditEventSpy).toHaveBeenCalledWith({ - action: 'saved_object_resolve', - addToSpaces: undefined, - deleteFromSpaces: undefined, - unauthorizedSpaces: undefined, - unauthorizedTypes: undefined, - error: undefined, - outcome: 'success', - }); - expect(auditLogger.log).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledWith({ - error: undefined, - event: { - action: AuditAction.RESOLVE, - category: ['database'], + expect(addAuditEventSpy).toHaveBeenCalledTimes(auditObjects.length); + let i = 1; + for (const auditObj of auditObjects) { + expect(addAuditEventSpy).toHaveBeenNthCalledWith(i++, { + action: 'saved_object_resolve', + addToSpaces: undefined, + deleteFromSpaces: undefined, + unauthorizedSpaces: undefined, + unauthorizedTypes: undefined, + error: undefined, outcome: 'success', - type: ['access'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: undefined, - unauthorized_spaces: undefined, - unauthorized_types: undefined, - saved_object: undefined, - }, - message: `User has resolved saved objects`, - }); + savedObject: auditObj, + }); + } + expect(auditLogger.log).toHaveBeenCalledTimes(auditObjects.length); + i = 1; + for (const auditObj of auditObjects) { + expect(auditLogger.log).toHaveBeenNthCalledWith(i++, { + error: undefined, + event: { + action: AuditAction.RESOLVE, + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + add_to_spaces: undefined, + delete_from_spaces: undefined, + unauthorized_spaces: undefined, + unauthorized_types: undefined, + saved_object: auditObj, + }, + message: `User has resolved ${auditObj.type} [id=${auditObj.id}]`, + }); + } }); test(`throws when unauthorized`, async () => { @@ -4939,28 +4954,31 @@ describe('#authorizeAndRedactInternalBulkResolve', () => { ); expect(enforceAuthorizationSpy).toHaveBeenCalledTimes(1); - expect(addAuditEventSpy).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledTimes(1); - expect(auditLogger.log).toHaveBeenCalledWith({ - error: { - code: 'Error', - message: `Unable to bulk_get ${resolveObj1.saved_object.type},${resolveObj2.saved_object.type}`, - }, - event: { - action: AuditAction.RESOLVE, - category: ['database'], - outcome: 'failure', - type: ['access'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: undefined, - unauthorized_spaces: undefined, - unauthorized_types: undefined, - saved_object: undefined, - }, - message: `Failed attempt to resolve saved objects`, - }); + expect(addAuditEventSpy).toHaveBeenCalledTimes(objects.length); + expect(auditLogger.log).toHaveBeenCalledTimes(objects.length); + let i = 1; + for (const auditObj of auditObjects) { + expect(auditLogger.log).toHaveBeenNthCalledWith(i++, { + error: { + code: 'Error', + message: `Unable to bulk_get ${resolveObj1.saved_object.type},${resolveObj2.saved_object.type}`, + }, + event: { + action: AuditAction.RESOLVE, + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { + add_to_spaces: undefined, + delete_from_spaces: undefined, + unauthorized_spaces: undefined, + unauthorized_types: undefined, + saved_object: auditObj, + }, + message: `Failed attempt to resolve ${auditObj.type} [id=${auditObj.id}]`, + }); + } }); }); @@ -6196,25 +6214,39 @@ describe(`#auditObjectsForSpaceDeletion`, () => { expect(auditHelperSpy).not.toHaveBeenCalled(); // The helper is not called, the addAudit method is called directly expect(addAuditEventSpy).toHaveBeenCalledTimes(objects.length - 1); expect(auditLogger.log).toHaveBeenCalledTimes(objects.length - 1); - const i = 0; - for (const obj of objects) { - if (i === 0) continue; // The first object namespaces includes '*', so there will not be an audit for it - expect(auditLogger.log).toHaveBeenNthCalledWith(i, { - error: undefined, - event: { - action: AuditAction.UPDATE_OBJECTS_SPACES, - category: ['database'], - outcome: 'unknown', - type: ['change'], - }, - kibana: { - add_to_spaces: undefined, - delete_from_spaces: obj.namespaces!.length > 1 ? obj.namespaces : undefined, - saved_object: undefined, - }, - message: `User is updating spaces of dashboard [id=${obj.id}]`, - }); - } + // The first object's namespaces includes '*', so there will not be an audit for it + + // The second object only exists in the space we're deleting, so it is audited as a delete + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + error: undefined, + event: { + action: AuditAction.DELETE, + category: ['database'], + outcome: 'unknown', + type: ['deletion'], + }, + kibana: { + delete_from_spaces: undefined, + saved_object: { type: objects[1].type, id: objects[1].id }, + }, + message: `User is deleting dashboard [id=${objects[1].id}]`, + }); + + // The third object exists in spaces other than what we're deleting, so it is audited as a change + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + error: undefined, + event: { + action: AuditAction.UPDATE_OBJECTS_SPACES, + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + kibana: { + delete_from_spaces: [spaceId], + saved_object: { type: objects[2].type, id: objects[2].id }, + }, + message: `User is updating spaces of dashboard [id=${objects[2].id}]`, + }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts index fe62b40285ca4..e53b129474a62 100644 --- a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts @@ -1212,7 +1212,7 @@ export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExten types: new Set(typesAndSpaces.keys()), spaces: spacesToAuthorize, enforceMap: typesAndSpaces, - auditOptions: { useSuccessOutcome: true }, + auditOptions: { objects: auditableObjects, useSuccessOutcome: true }, }); return objects.map((result) => { diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 079ad8434f445..dd00c9b3ab190 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -117,6 +117,11 @@ export const allowedExperimentalValues = Object.freeze({ */ detectionsCoverageOverview: false, + /** + * Enable risk engine client and initialisation of datastream, component templates and mappings + */ + riskScoringPersistence: false, + /** * Enables experimental Entity Analytics HTTP endpoints */ diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/detection_page_filters.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/detection_page_filters.cy.ts index 3c231fb657630..6e77002c945dd 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/detection_page_filters.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/detection_page_filters.cy.ts @@ -10,12 +10,15 @@ import { getNewRule } from '../../../objects/rule'; import { CONTROL_FRAMES, CONTROL_FRAME_TITLE, + CONTROL_POPOVER, FILTER_GROUP_CHANGED_BANNER, - FILTER_GROUP_EDIT_CONTROL_PANEL_ITEMS, + OPTION_IGNORED, OPTION_LIST_LABELS, OPTION_LIST_VALUES, OPTION_SELECTABLE, + OPTION_SELECTABLE_COUNT, FILTER_GROUP_CONTROL_ACTION_EDIT, + FILTER_GROUP_EDIT_CONTROL_PANEL_ITEMS, } from '../../../screens/common/filter_group'; import { createRule } from '../../../tasks/api_calls/rules'; import { cleanKibana } from '../../../tasks/common'; @@ -26,15 +29,17 @@ import { formatPageFilterSearchParam } from '../../../../common/utils/format_pag import { closePageFilterPopover, markAcknowledgedFirstAlert, + openFirstAlert, openPageFilterPopover, resetFilters, selectCountTable, + togglePageFilterPopover, visitAlertsPageWithCustomFilters, waitForAlerts, waitForPageFilters, } from '../../../tasks/alerts'; -import { ALERTS_COUNT } from '../../../screens/alerts'; -import { navigateFromHeaderTo } from '../../../tasks/security_header'; +import { ALERTS_COUNT, ALERTS_REFRESH_BTN } from '../../../screens/alerts'; +import { kqlSearch, navigateFromHeaderTo } from '../../../tasks/security_header'; import { ALERTS, CASES } from '../../../screens/security_header'; import { addNewFilterGroupControlValues, @@ -45,6 +50,9 @@ import { editFilterGroupControls, saveFilterGroupControls, } from '../../../tasks/common/filter_group'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; +import { setEndDate, setStartDate } from '../../../tasks/date_picker'; +import { fillAddFilterForm, openAddFilterPopover } from '../../../tasks/search_bar'; const customFilters = [ { @@ -233,13 +241,21 @@ describe('Detections : Page Filters', () => { markAcknowledgedFirstAlert(); waitForAlerts(); cy.get(OPTION_LIST_VALUES(0)).click(); - cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible'); + cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible').trigger('click'); cy.get(ALERTS_COUNT) .invoke('text') .should((newAlertCount) => { expect(newAlertCount.split(' ')[0]).eq(String(parseInt(originalAlertCount, 10) - 1)); }); }); + + // cleanup + // revert the changes so that data does not change for further tests. + // It would make sure that tests can run in any order. + cy.get(OPTION_SELECTABLE(0, 'open')).trigger('click'); + togglePageFilterPopover(0); + openFirstAlert(); + waitForAlerts(); }); it(`URL is updated when filters are updated`, () => { @@ -256,14 +272,14 @@ describe('Detections : Page Filters', () => { openPageFilterPopover(1); cy.get(OPTION_SELECTABLE(1, 'high')).should('be.visible'); - cy.get(OPTION_SELECTABLE(1, 'high')).click({ force: true }); + cy.get(OPTION_SELECTABLE(1, 'high')).click({}); closePageFilterPopover(1); }); it(`Filters are restored from localstorage when user navigates back to the page.`, () => { cy.get(OPTION_LIST_VALUES(1)).click(); cy.get(OPTION_SELECTABLE(1, 'high')).should('be.visible'); - cy.get(OPTION_SELECTABLE(1, 'high')).click({ force: true }); + cy.get(OPTION_SELECTABLE(1, 'high')).click({}); // high should be scuccessfully selected. cy.get(OPTION_LIST_VALUES(1)).contains('high'); @@ -311,6 +327,57 @@ describe('Detections : Page Filters', () => { cy.get(FILTER_GROUP_CHANGED_BANNER).should('not.exist'); }); + context('Impact of inputs', () => { + afterEach(() => { + resetFilters(); + }); + it('should recover from invalide kql Query result', () => { + // do an invalid search + // + kqlSearch('\\'); + cy.get(ALERTS_REFRESH_BTN).trigger('click'); + waitForPageFilters(); + cy.get(TOASTER).should('contain.text', 'KQLSyntaxError'); + togglePageFilterPopover(0); + cy.get(OPTION_SELECTABLE(0, 'open')).should('be.visible'); + cy.get(OPTION_SELECTABLE(0, 'open')).should('contain.text', 'open'); + cy.get(OPTION_SELECTABLE(0, 'open')).get(OPTION_SELECTABLE_COUNT).should('have.text', 2); + }); + + it('should take kqlQuery into account', () => { + kqlSearch('kibana.alert.workflow_status: "nothing"'); + cy.get(ALERTS_REFRESH_BTN).trigger('click'); + waitForPageFilters(); + togglePageFilterPopover(0); + cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found'); + cy.get(OPTION_IGNORED(0, 'open')).should('be.visible'); + }); + + it('should take filters into account', () => { + openAddFilterPopover(); + fillAddFilterForm({ + key: 'kibana.alert.workflow_status', + value: 'invalid', + }); + waitForPageFilters(); + togglePageFilterPopover(0); + cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found'); + cy.get(OPTION_IGNORED(0, 'open')).should('be.visible'); + }); + it('should take timeRange into account', () => { + const startDateWithZeroAlerts = 'Jan 1, 2002 @ 00:00:00.000'; + const endDateWithZeroAlerts = 'Jan 1, 2010 @ 00:00:00.000'; + + setStartDate(startDateWithZeroAlerts); + setEndDate(endDateWithZeroAlerts); + + cy.get(ALERTS_REFRESH_BTN).trigger('click'); + waitForPageFilters(); + togglePageFilterPopover(0); + cy.get(CONTROL_POPOVER(0)).should('contain.text', 'No options found'); + cy.get(OPTION_IGNORED(0, 'open')).should('be.visible'); + }); + }); it('Number fields are not visible in field edit panel', () => { const idx = 3; const { FILTER_FIELD_TYPE, FIELD_TYPES } = FILTER_GROUP_EDIT_CONTROL_PANEL_ITEMS; diff --git a/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts b/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts index e33ed8ca11a3d..ab56bee03a34b 100644 --- a/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts +++ b/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts @@ -36,6 +36,12 @@ export const OPTION_SELECTABLE = (popoverIndex: number, value: string) => export const OPTION_IGNORED = (popoverIndex: number, value: string) => `#control-popover-${popoverIndex} [data-test-subj="optionsList-control-ignored-selection-${value}"]`; +export const OPTION_SELECTABLE_COUNT = getDataTestSubjectSelector( + 'optionsList-document-count-badge' +); + +export const CONTROL_POPOVER = (popoverIdx: number) => `#control-popover-${popoverIdx}`; + export const DETECTION_PAGE_FILTER_GROUP_WRAPPER = '.filter-group__wrapper'; export const DETECTION_PAGE_FILTERS_LOADING = '.securityPageWrapper .controlFrame--controlLoading'; 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 0284fb7a8a5ac..5277702f3f405 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 @@ -15,7 +15,6 @@ import { EuiIcon, EuiSpacer, EuiCallOut, - EuiBetaBadge, EuiCodeBlock, EuiModalHeader, EuiModalHeaderTitle, @@ -432,9 +431,6 @@ const InsightEditorComponent = ({ /> )} - - - diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts index 72494dce3608c..4849a06c8eea9 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/translations.ts @@ -82,13 +82,6 @@ export const CANCEL_FORM_BUTTON = i18n.translate( } ); -export const TECH_PREVIEW = i18n.translate( - 'xpack.securitySolution.markdown.insight.technicalPreview', - { - defaultMessage: 'Technical Preview', - } -); - export const PARSE_ERROR = i18n.translate( 'xpack.securitySolution.markdownEditor.plugins.insightProviderError', { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx index 234350129986c..d21fd175ed60c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/use_add_prebuilt_rules_table_columns.tsx @@ -21,6 +21,7 @@ import { hasUserCRUDPermission } from '../../../../../common/utils/privileges'; import type { AddPrebuiltRulesTableActions } from './add_prebuilt_rules_table_context'; import { useAddPrebuiltRulesTableContext } from './add_prebuilt_rules_table_context'; import type { RuleSignatureId } from '../../../../../../common/detection_engine/rule_schema'; +import { getNormalizedSeverity } from '../helpers'; export type TableColumn = EuiBasicTableColumn; @@ -130,7 +131,7 @@ export const useAddPrebuiltRulesTableColumns = (): TableColumn[] => { field: 'severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: true, + sortable: ({ severity }: RuleInstallationInfoForReview) => getNormalizedSeverity(severity), truncateText: true, width: '12%', }, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts index 49d29fc34da76..fda192dca2a67 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.ts @@ -6,6 +6,7 @@ */ import type { Query } from '@elastic/eui'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import type { ExportRulesDetails } from '../../../../../common/detection_engine/rule_management'; import type { BulkActionSummary } from '../../../rule_management/logic'; @@ -75,3 +76,14 @@ export const getExportedRulesCounts = async (blob: Blob): Promise = { + low: 0, + medium: 1, + high: 2, + critical: 3, +}; + +export const getNormalizedSeverity = (severity: Severity): number => { + return NormalizedSeverity[severity] ?? -1; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx index 78ef1f7069ace..603d06c821fa6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_prebuilt_rules_table_columns.tsx @@ -19,6 +19,7 @@ import { SeverityBadge } from '../../../../../detections/components/rules/severi import { useUserData } from '../../../../../detections/components/user_info'; import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations'; import type { Rule } from '../../../../rule_management/logic'; +import { getNormalizedSeverity } from '../helpers'; import type { UpgradePrebuiltRulesTableActions } from './upgrade_prebuilt_rules_table_context'; import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context'; @@ -130,7 +131,8 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => { field: 'rule.severity', name: i18n.COLUMN_SEVERITY, render: (value: Rule['severity']) => , - sortable: true, + sortable: ({ rule: { severity } }: RuleUpgradeInfoForReview) => + getNormalizedSeverity(severity), truncateText: true, width: '12%', }, diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx index 1035f08bb902f..984e19a879637 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.test.tsx @@ -33,6 +33,8 @@ const mockedDataViewServiceGetter = jest.fn(() => { } as unknown as DataView); }); +const mockDataViewCreator = jest.fn(); + const getKibanaServiceWithMockedGetter = ( mockedDataViewGetter: DataViewsServicePublic['get'] = mockedDataViewServiceGetter ) => { @@ -42,6 +44,7 @@ const getKibanaServiceWithMockedGetter = ( ...basicKibanaServicesMock.dataViews, clearInstanceCache: jest.fn(), get: mockedDataViewGetter, + create: mockDataViewCreator, }, }; }; @@ -55,7 +58,6 @@ const TestComponent = (props: Partial @@ -84,29 +86,32 @@ describe('Detection Page Filters', () => { }); }); - it('should check all the fields till any absent field is found', async () => { + it('should create dataview on render', async () => { render(); - expect(screen.getByTestId(TEST_IDS.FILTER_LOADING)).toBeInTheDocument(); + await waitFor(() => { - expect(getFieldByNameMock).toHaveBeenCalledTimes(4); - expect(kibanaServiceDefaultMock.dataViews.clearInstanceCache).toHaveBeenCalledTimes(0); + expect(mockDataViewCreator).toHaveBeenCalledTimes(1); + expect(mockDataViewCreator).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'security_solution_alerts_dv', + name: 'Security Solution Alerts DataView', + allowNoIndex: true, + title: '.siem-signals-spacename', + }) + ); }); }); - it('should stop checking fields if blank field is found and clear the cache', async () => { - const getFieldByNameLocalMock = jest.fn(() => false); - const mockGetter = jest.fn(() => - Promise.resolve({ getFieldByName: getFieldByNameLocalMock } as unknown as DataView) - ); - const modifiedKibanaServicesMock = getKibanaServiceWithMockedGetter(mockGetter); - (useKibana as jest.Mock).mockReturnValueOnce({ services: modifiedKibanaServicesMock }); + it('should clear cache on unmount', async () => { + const { unmount } = render(); - render(); - expect(screen.getByTestId(TEST_IDS.FILTER_LOADING)).toBeInTheDocument(); await waitFor(() => { - expect(getFieldByNameLocalMock).toHaveBeenCalledTimes(1); - expect(modifiedKibanaServicesMock.dataViews.clearInstanceCache).toHaveBeenCalledTimes(1); - expect(screen.getByTestId(TEST_IDS.MOCKED_CONTROL)).toBeInTheDocument(); + // wait for the document to completely load. + unmount(); + }); + + await waitFor(() => { + expect(kibanaServiceDefaultMock.dataViews.clearInstanceCache).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx index d096f56f7faa5..6e149b9866357 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx @@ -10,15 +10,30 @@ import React, { useEffect, useState, useCallback } from 'react'; import type { Filter } from '@kbn/es-query'; import { isEqual } from 'lodash'; import { EuiFlexItem } from '@elastic/eui'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FilterGroupLoading } from '../../../common/components/filter_group/loading'; import { useKibana } from '../../../common/lib/kibana'; import { DEFAULT_DETECTION_PAGE_FILTERS } from '../../../../common/constants'; import { FilterGroup } from '../../../common/components/filter_group'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; -type FilterItemSetProps = Omit, 'initialControls'>; +type FilterItemSetProps = Omit< + ComponentProps, + 'initialControls' | 'dataViewId' +>; + +const SECURITY_ALERT_DATA_VIEW = { + id: 'security_solution_alerts_dv', + name: 'Security Solution Alerts DataView', +}; const FilterItemSetComponent = (props: FilterItemSetProps) => { - const { dataViewId, onFilterChange, ...restFilterItemGroupProps } = props; + const { onFilterChange, ...restFilterItemGroupProps } = props; + + const { + indexPattern: { title }, + dataViewId, + } = useSourcererDataView(SourcererScopeName.detections); const [loadingPageFilters, setLoadingPageFilters] = useState(true); @@ -27,24 +42,21 @@ const FilterItemSetComponent = (props: FilterItemSetProps) => { } = useKibana(); useEffect(() => { - // this makes sure, that if fields are not present in existing copy of the - // dataView, clear the cache before filter group is loaded. This is only - // applicable to `alert` page as new alert mappings are added when first alert - // is encountered (async () => { - const dataView = await dataViewService.get(dataViewId ?? ''); - if (!dataView) return; - for (const filter of DEFAULT_DETECTION_PAGE_FILTERS) { - const fieldExists = dataView.getFieldByName(filter.fieldName); - if (!fieldExists) { - dataViewService.clearInstanceCache(dataViewId ?? ''); - setLoadingPageFilters(false); - return; - } - } + // creates an adhoc dataview if it does not already exists just for alert index + const { timeFieldName = '@timestamp' } = await dataViewService.get(dataViewId ?? ''); + await dataViewService.create({ + id: SECURITY_ALERT_DATA_VIEW.id, + name: SECURITY_ALERT_DATA_VIEW.name, + title, + allowNoIndex: true, + timeFieldName, + }); setLoadingPageFilters(false); })(); - }, [dataViewId, dataViewService]); + + return () => dataViewService.clearInstanceCache(); + }, [title, dataViewService, dataViewId]); const [initialFilterControls] = useState(DEFAULT_DETECTION_PAGE_FILTERS); @@ -78,7 +90,7 @@ const FilterItemSetComponent = (props: FilterItemSetProps) => { return ( = ({ const { indexPattern, runtimeMappings, - dataViewId, loading: isLoadingIndexPattern, } = useSourcererDataView(SourcererScopeName.detections); @@ -351,7 +350,6 @@ const DetectionEnginePageComponent: React.FC = ({ ) : ( = ({ [ topLevelFilters, arePageFiltersEnabled, - dataViewId, statusFilter, onFilterGroupChangedCallback, pageFiltersUpdateHandler, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx index 033797216b63a..0db382886e794 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx @@ -16,32 +16,35 @@ import * as i18n from '../translations'; import type { TimelineTypeLiteral } from '../../../../../common/types/timeline/api'; import { TimelineType } from '../../../../../common/types/timeline/api'; -const StyledEuiFieldText = styled(EuiFieldText)` - padding-left: 12px; - padding-right: 40px; +const StyledEuiInputPopover = styled(EuiInputPopover)` + .rightArrowIcon { + .euiFieldText { + padding-left: 12px; + padding-right: 40px; - &[readonly] { - cursor: pointer; - background-size: 0 100%; - background-repeat: no-repeat; + &[readonly] { + cursor: pointer; + background-size: 0 100%; + background-repeat: no-repeat; - // To match EuiFieldText focus state - &:focus { - background-color: ${({ theme }) => theme.eui.euiFormBackgroundColor}; - background-image: linear-gradient( - to top, - ${({ theme }) => theme.eui.euiFocusRingColor}, - ${({ theme }) => theme.eui.euiFocusRingColor} 2px, - transparent 2px, - transparent 100% - ); - background-size: 100% 100%; + // To match EuiFieldText focus state + &:focus { + background-color: ${({ theme }) => theme.eui.euiFormBackgroundColor}; + background-image: linear-gradient( + to top, + ${({ theme }) => theme.eui.euiFocusRingColor}, + ${({ theme }) => theme.eui.euiFocusRingColor} 2px, + transparent 2px, + transparent 100% + ); + background-size: 100% 100%; + } + } + } + .euiFormControlLayoutIcons { + left: unset; + right: 12px; } - } - - & + .euiFormControlLayoutIcons { - left: unset; - right: 12px; } `; @@ -87,7 +90,7 @@ const SearchTimelineSuperSelectComponent: React.FC ( - - + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index 94e688a4db079..f45a57208e915 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -34,6 +34,7 @@ import type { import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks'; import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz'; +import { riskEngineDataClientMock } from '../../../risk_engine/__mocks__/risk_engine_data_client_mock'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); @@ -61,6 +62,7 @@ export const createMockClients = () => { detectionEngineHealthClient: detectionEngineHealthClientMock.create(), ruleExecutionLog: ruleExecutionLogMock.forRoutes.create(), + riskEngineDataClient: riskEngineDataClientMock.create(), }; }; @@ -139,6 +141,7 @@ const createSecuritySolutionRequestContextMock = ( // TODO: Mock EndpointInternalFleetServicesInterface and return the mocked object. throw new Error('Not implemented'); }), + getRiskEngineDataClient: jest.fn(() => clients.riskEngineDataClient), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts index 571684f1fdcee..d9232b99a49c8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.test.ts @@ -318,7 +318,6 @@ describe('buildEqlSearchRequest', () => { "query": "process where true", "runtime_mappings": undefined, "size": 100, - "tiebreaker_field": undefined, "timestamp_field": undefined, }, "index": Array [ @@ -429,4 +428,42 @@ describe('buildEqlSearchRequest', () => { }, }); }); + + describe('handles the tiebreaker field', () => { + test('should pass a tiebreaker field with a valid value', async () => { + const request = buildEqlSearchRequest({ + query: 'process where true', + index: ['testindex1', 'testindex2'], + from: 'now-5m', + to: 'now', + size: 100, + filters: undefined, + primaryTimestamp: '@timestamp', + secondaryTimestamp: undefined, + runtimeMappings: undefined, + tiebreakerField: 'host.name', + eventCategoryOverride: undefined, + exceptionFilter: undefined, + }); + expect(request?.body?.tiebreaker_field).toEqual(`host.name`); + }); + + test('should not pass a tiebreaker field with a valid value', async () => { + const request = buildEqlSearchRequest({ + query: 'process where true', + index: ['testindex1', 'testindex2'], + from: 'now-5m', + to: 'now', + size: 100, + filters: undefined, + primaryTimestamp: '@timestamp', + secondaryTimestamp: undefined, + runtimeMappings: undefined, + tiebreakerField: '', + eventCategoryOverride: undefined, + exceptionFilter: undefined, + }); + expect(request?.body?.tiebreaker_field).toEqual(undefined); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts index 3d8910a8e9bab..317cb57402b8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_eql_search_request.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter } from '@kbn/es-query'; +import { isEmpty } from 'lodash/fp'; import type { RuleFilterArray, TimestampOverride, @@ -89,7 +90,11 @@ export const buildEqlSearchRequest = ({ runtime_mappings: runtimeMappings, timestamp_field: timestampField, event_category_field: eventCategoryOverride, - tiebreaker_field: tiebreakerField, + ...(!isEmpty(tiebreakerField) + ? { + tiebreaker_field: tiebreakerField, + } + : {}), fields, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts new file mode 100644 index 0000000000000..0e9f1fade7bb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RiskEngineDataClient } from '../risk_engine_data_client'; + +const createRiskEngineDataClientMock = () => + ({ + getWriter: jest.fn(), + initializeResources: jest.fn(), + } as unknown as jest.Mocked); + +export const riskEngineDataClientMock = { create: createRiskEngineDataClientMock }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts new file mode 100644 index 0000000000000..64b31b2c705a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts @@ -0,0 +1,97 @@ +/* + * 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 { FieldMap } from '@kbn/alerts-as-data-utils'; +import type { IIndexPatternString } from './utils/create_datastream'; + +export const ilmPolicy = { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, +}; + +export const riskFieldMap: FieldMap = { + '@timestamp': { + type: 'date', + array: false, + required: false, + }, + identifierField: { + type: 'keyword', + array: false, + required: false, + }, + identifierValue: { + type: 'keyword', + array: false, + required: false, + }, + level: { + type: 'keyword', + array: false, + required: false, + }, + totalScore: { + type: 'float', + array: false, + required: false, + }, + totalScoreNormalized: { + type: 'float', + array: false, + required: false, + }, + alertsScore: { + type: 'float', + array: false, + required: false, + }, + otherScore: { + type: 'float', + array: false, + required: false, + }, + riskiestInputs: { + type: 'nested', + required: false, + }, + 'riskiestInputs.id': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.index': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.riskScore': { + type: 'float', + array: false, + required: false, + }, +} as const; + +export const ilmPolicyName = '.risk-score-ilm-policy'; +export const mappingComponentName = '.risk-score-mappings'; +export const totalFieldsLimit = 1000; + +const riskScoreBaseIndexName = 'risk-score'; + +export const getIndexPattern = (namespace: string): IIndexPatternString => ({ + template: `.${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}-index-template`, + alias: `${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}`, +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts new file mode 100644 index 0000000000000..391d89b2ebf8b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { RiskEngineDataClient } from './risk_engine_data_client'; +import { createDataStream } from './utils/create_datastream'; + +jest.mock('@kbn/alerting-plugin/server', () => ({ + createOrUpdateComponentTemplate: jest.fn(), + createOrUpdateIlmPolicy: jest.fn(), + createOrUpdateIndexTemplate: jest.fn(), +})); + +jest.mock('./utils/create_datastream', () => ({ + createDataStream: jest.fn(), +})); + +describe('RiskEngineDataClient', () => { + let riskEngineDataClient: RiskEngineDataClient; + let logger: ReturnType; + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const totalFieldsLimit = 1000; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + const options = { + logger, + kibanaVersion: '8.9.0', + elasticsearchClientPromise: Promise.resolve(esClient), + }; + riskEngineDataClient = new RiskEngineDataClient(options); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getWriter', () => { + it('should return a writer object', async () => { + const writer = await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(writer).toBeDefined(); + expect(typeof writer?.bulk).toBe('function'); + }); + + it('should cache and return the same writer for the same namespace', async () => { + const writer1 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer2 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer3 = await riskEngineDataClient.getWriter({ namespace: 'space-1' }); + + expect(writer1).toEqual(writer2); + expect(writer2).not.toEqual(writer3); + }); + + it('should cache writer and not call initializeResources for a second tme', async () => { + const initializeResourcesSpy = jest.spyOn(riskEngineDataClient, 'initializeResources'); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(initializeResourcesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('initializeResources succes', () => { + it('should initialize risk engine resources', async () => { + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(createOrUpdateIlmPolicy).toHaveBeenCalledWith({ + logger, + esClient, + name: '.risk-score-ilm-policy', + policy: { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }, + }); + + expect(createOrUpdateComponentTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score-mappings', + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: { + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }, + }, + }, + totalFieldsLimit, + }); + + expect(createOrUpdateIndexTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score.risk-score-default-index-template', + body: { + data_stream: { hidden: true }, + index_patterns: ['risk-score.risk-score-default'], + composed_of: ['.risk-score-mappings'], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: '.risk-score-ilm-policy', + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + }); + + expect(createDataStream).toHaveBeenCalledWith({ + logger, + esClient, + totalFieldsLimit, + indexPatterns: { + template: `.risk-score.risk-score-default-index-template`, + alias: `risk-score.risk-score-default`, + }, + }); + }); + }); + + describe('initializeResources error', () => { + it('should handle errors during initialization', async () => { + const error = new Error('There error'); + (createOrUpdateIlmPolicy as jest.Mock).mockRejectedValue(error); + + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(logger.error).toHaveBeenCalledWith( + `Error initializing risk engine resources: ${error.message}` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts new file mode 100644 index 0000000000000..9b77741bb164a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts @@ -0,0 +1,142 @@ +/* + * 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 { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types'; +import { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { + riskFieldMap, + getIndexPattern, + totalFieldsLimit, + mappingComponentName, + ilmPolicyName, + ilmPolicy, +} from './configurations'; +import { createDataStream } from './utils/create_datastream'; + +interface InitializeRiskEngineResourcesOpts { + namespace?: string; +} + +interface RiskEngineDataClientOpts { + logger: Logger; + kibanaVersion: string; + elasticsearchClientPromise: Promise; +} + +interface Writer { + bulk: () => Promise; +} + +export class RiskEngineDataClient { + private writerCache: Map = new Map(); + constructor(private readonly options: RiskEngineDataClientOpts) {} + + public async getWriter({ namespace }: { namespace: string }): Promise { + if (this.writerCache.get(namespace)) { + return this.writerCache.get(namespace) as Writer; + } + + await this.initializeResources({ namespace }); + return this.writerCache.get(namespace) as Writer; + } + + private async initializeWriter(namespace: string): Promise { + const writer: Writer = { + bulk: async () => {}, + }; + this.writerCache.set(namespace, writer); + return writer; + } + + public async initializeResources({ + namespace = DEFAULT_NAMESPACE_STRING, + }: InitializeRiskEngineResourcesOpts) { + try { + const esClient = await this.options.elasticsearchClientPromise; + + const indexPatterns = getIndexPattern(namespace); + + const indexMetadata: Metadata = { + kibana: { + version: this.options.kibanaVersion, + }, + managed: true, + namespace, + }; + + await Promise.all([ + createOrUpdateIlmPolicy({ + logger: this.options.logger, + esClient, + name: ilmPolicyName, + policy: ilmPolicy, + }), + createOrUpdateComponentTemplate({ + logger: this.options.logger, + esClient, + template: { + name: mappingComponentName, + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: mappingFromFieldMap(riskFieldMap, 'strict'), + }, + } as ClusterPutComponentTemplateRequest, + totalFieldsLimit, + }), + ]); + + await createOrUpdateIndexTemplate({ + logger: this.options.logger, + esClient, + template: { + name: indexPatterns.template, + body: { + data_stream: { hidden: true }, + index_patterns: [indexPatterns.alias], + composed_of: [mappingComponentName], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: ilmPolicyName, + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: indexMetadata, + }, + }, + _meta: indexMetadata, + }, + }, + }); + + await createDataStream({ + logger: this.options.logger, + esClient, + totalFieldsLimit, + indexPatterns, + }); + + this.initializeWriter(namespace); + } catch (error) { + this.options.logger.error(`Error initializing risk engine resources: ${error.message}`); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts new file mode 100644 index 0000000000000..910ba5e887046 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts @@ -0,0 +1,206 @@ +/* + * 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. + */ + +// This file is a copy of x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts +// original function create index instead of datastream, and their have plan to use datastream in the future +// so we probably should remove this file and use the original when datastream will be supported + +import type { IndicesSimulateIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { get } from 'lodash'; +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +export interface IIndexPatternString { + template: string; + alias: string; +} + +interface ConcreteIndexInfo { + index: string; + alias: string; + isWriteIndex: boolean; +} + +interface UpdateIndexMappingsOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndices: ConcreteIndexInfo[]; +} + +interface UpdateIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndexInfo: ConcreteIndexInfo; +} + +const updateTotalFieldLimitSetting = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + try { + await retryTransientEsErrors( + () => + esClient.indices.putSettings({ + index, + body: { 'index.mapping.total_fields.limit': totalFieldsLimit }, + }), + { logger } + ); + return; + } catch (err) { + logger.error( + `Failed to PUT index.mapping.total_fields.limit settings for alias ${alias}: ${err.message}` + ); + throw err; + } +}; + +// This will update the mappings of backing indices but *not* the settings. This +// is due to the fact settings can be classed as dynamic and static, and static +// updates will fail on an index that isn't closed. New settings *will* be applied as part +// of the ILM policy rollovers. More info: https://github.com/elastic/kibana/pull/113389#issuecomment-940152654 +const updateUnderlyingMapping = async ({ + logger, + esClient, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + let simulatedIndexMapping: IndicesSimulateIndexTemplateResponse; + try { + simulatedIndexMapping = await retryTransientEsErrors( + () => esClient.indices.simulateIndexTemplate({ name: index }), + { logger } + ); + } catch (err) { + logger.error( + `Ignored PUT mappings for alias ${alias}; error generating simulated mappings: ${err.message}` + ); + return; + } + + const simulatedMapping = get(simulatedIndexMapping, ['template', 'mappings']); + + if (simulatedMapping == null) { + logger.error(`Ignored PUT mappings for alias ${alias}; simulated mappings were empty`); + return; + } + + try { + await retryTransientEsErrors( + () => esClient.indices.putMapping({ index, body: simulatedMapping }), + { logger } + ); + + return; + } catch (err) { + logger.error(`Failed to PUT mapping for alias ${alias}: ${err.message}`); + throw err; + } +}; +/** + * Updates the underlying mapping for any existing concrete indices + */ +const updateIndexMappings = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndices, +}: UpdateIndexMappingsOpts) => { + logger.debug(`Updating underlying mappings for ${concreteIndices.length} indices.`); + + // Update total field limit setting of found indices + // Other index setting changes are not updated at this time + await Promise.all( + concreteIndices.map((index) => + updateTotalFieldLimitSetting({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); + + // Update mappings of the found indices. + await Promise.all( + concreteIndices.map((index) => + updateUnderlyingMapping({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); +}; + +interface CreateConcreteWriteIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + indexPatterns: IIndexPatternString; +} +/** + * Create a data stream + */ +export const createDataStream = async ({ + logger, + esClient, + indexPatterns, + totalFieldsLimit, +}: CreateConcreteWriteIndexOpts) => { + logger.info(`Creating data stream - ${indexPatterns.alias}`); + + // check if a datastream already exists + let dataStreams: ConcreteIndexInfo[] = []; + try { + // Specify both the index pattern for the backing indices and their aliases + // The alias prevents the request from finding other namespaces that could match the -* pattern + const response = await retryTransientEsErrors( + () => esClient.indices.getDataStream({ name: indexPatterns.alias, expand_wildcards: 'all' }), + { logger } + ); + + dataStreams = response.data_streams.map((dataStream) => ({ + index: dataStream.name, + alias: dataStream.name, + isWriteIndex: true, + })); + + logger.debug( + `Found ${dataStreams.length} concrete indices for ${indexPatterns.alias} - ${JSON.stringify( + dataStreams + )}` + ); + } catch (error) { + // 404 is expected if no datastream have been created + if (error.statusCode !== 404) { + logger.error( + `Error fetching concrete indices for ${indexPatterns.alias} pattern - ${error.message}` + ); + throw error; + } + } + + const dataStreamsExist = dataStreams.length > 0; + + // if a concrete write datastream already exists, update the underlying mapping + if (dataStreams.length > 0) { + await updateIndexMappings({ logger, esClient, totalFieldsLimit, concreteIndices: dataStreams }); + } + + // check if a concrete write datastream already exists + if (!dataStreamsExist) { + try { + await retryTransientEsErrors( + () => + esClient.indices.createDataStream({ + name: indexPatterns.alias, + }), + { logger } + ); + } catch (error) { + logger.error(`Error creating datastream - ${error.message}`); + throw error; + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts new file mode 100644 index 0000000000000..2501c57776d80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { loggerMock } from '@kbn/logging-mocks'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +const logger = loggerMock.create(); +const randomDelayMultiplier = 0.01; + +describe('retryTransientErrors', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier); + }); + + it("doesn't retry if operation is successful", async () => { + const esCallMock = jest.fn().mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); + + it('logs a warning message on retry', async () => { + const esCallMock = jest + .fn() + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockResolvedValue('success'); + + await retryTransientEsErrors(esCallMock, { logger }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries with an exponential backoff', async () => { + let attempt = 0; + const esCallMock = jest.fn(async () => { + attempt++; + if (attempt < 4) { + throw new EsErrors.ConnectionError('foo'); + } else { + return 'success'; + } + }); + + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(4); + expect(logger.warn).toHaveBeenCalledTimes(3); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[1][0]).toMatch( + `Retrying Elasticsearch operation after [4s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[2][0]).toMatch( + `Retrying Elasticsearch operation after [8s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries each supported error type', async () => { + const errors = [ + new EsErrors.NoLivingConnectionsError('no living connection', { + warnings: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + meta: {} as any, + }), + new EsErrors.ConnectionError('no connection'), + new EsErrors.TimeoutError('timeout'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 503, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 408, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 410, meta: {} as any, warnings: [] }), + ]; + + for (const error of errors) { + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(2); + } + }); + + it('does not retry unsupported errors', async () => { + const error = new Error('foo!'); + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + await expect(retryTransientEsErrors(esCallMock, { logger })).rejects.toThrow(error); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts new file mode 100644 index 0000000000000..7a3839ad3c5bc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +const MAX_ATTEMPTS = 3; + +const retryResponseStatuses = [ + 503, // ServiceUnavailable + 408, // RequestTimeout + 410, // Gone +]; + +const isRetryableError = (e: Error) => + e instanceof EsErrors.NoLivingConnectionsError || + e instanceof EsErrors.ConnectionError || + e instanceof EsErrors.TimeoutError || + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (e instanceof EsErrors.ResponseError && retryResponseStatuses.includes(e?.statusCode!)); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const retryTransientEsErrors = async ( + esCall: () => Promise, + { + logger, + attempt = 0, + }: { + logger: Logger; + attempt?: number; + } +): Promise => { + try { + return await esCall(); + } catch (e) { + if (attempt < MAX_ATTEMPTS && isRetryableError(e)) { + const retryCount = attempt + 1; + const retryDelaySec: number = Math.min(Math.pow(2, retryCount), 30); // 2s, 4s, 8s, 16s, 30s, 30s, 30s... + + logger.warn( + `Retrying Elasticsearch operation after [${retryDelaySec}s] due to error: ${e.toString()} ${ + e.stack + }` + ); + + // delay with some randomness + await delay(retryDelaySec * 1000 * Math.random()); + return retryTransientEsErrors(esCall, { logger, attempt: retryCount }); + } + + throw e; + } +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a838c83a31e2b..b9dce72f64671 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -94,6 +94,8 @@ import { ENDPOINT_FIELDS_SEARCH_STRATEGY, ENDPOINT_SEARCH_STRATEGY, } from '../common/endpoint/constants'; +import { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; + import { AppFeatures } from './lib/app_features'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -118,6 +120,7 @@ export class Plugin implements ISecuritySolutionPlugin { private checkMetadataTransformsTask: CheckMetadataTransformsTask | undefined; private telemetryUsageCounter?: UsageCounter; private endpointContext: EndpointAppContext; + private riskEngineDataClient: RiskEngineDataClient | undefined; constructor(context: PluginInitializerContext) { const serverConfig = createConfig(context); @@ -159,6 +162,18 @@ export class Plugin implements ISecuritySolutionPlugin { this.ruleMonitoringService.setup(core, plugins); + this.riskEngineDataClient = new RiskEngineDataClient({ + logger: this.logger, + kibanaVersion: this.pluginContext.env.packageInfo.version, + elasticsearchClientPromise: core + .getStartServices() + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), + }); + + if (experimentalFeatures.riskScoringPersistence) { + this.riskEngineDataClient.initializeResources({}); + } + const requestContextFactory = new RequestContextFactory({ config, logger, @@ -168,6 +183,7 @@ export class Plugin implements ISecuritySolutionPlugin { ruleMonitoringService: this.ruleMonitoringService, kibanaVersion: pluginContext.env.packageInfo.version, kibanaBranch: pluginContext.env.packageInfo.branch, + riskEngineDataClient: this.riskEngineDataClient, }); const router = core.http.createRouter(); diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index e56df95a10650..c5cf4b24750a4 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -25,6 +25,7 @@ import type { import type { Immutable } from '../common/endpoint/types'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export interface IRequestContextFactory { create( @@ -42,6 +43,7 @@ interface ConstructorOptions { ruleMonitoringService: IRuleMonitoringService; kibanaVersion: string; kibanaBranch: string; + riskEngineDataClient: RiskEngineDataClient; } export class RequestContextFactory implements IRequestContextFactory { @@ -56,7 +58,15 @@ export class RequestContextFactory implements IRequestContextFactory { request: KibanaRequest ): Promise { const { options, appClientFactory } = this; - const { config, core, plugins, endpointAppContextService, ruleMonitoringService } = options; + const { + config, + core, + plugins, + endpointAppContextService, + ruleMonitoringService, + riskEngineDataClient, + } = options; + const { lists, ruleRegistry, security } = plugins; const [, startPlugins] = await core.getStartServices(); @@ -128,6 +138,8 @@ export class RequestContextFactory implements IRequestContextFactory { }, getInternalFleetServices: memoize(() => endpointAppContextService.getInternalFleetServices()), + + getRiskEngineDataClient: () => riskEngineDataClient, }; } } diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 64211998defc5..8326d13ad03a7 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -29,6 +29,7 @@ import type { import type { FrameworkRequest } from './lib/framework'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointInternalFleetServicesInterface } from './endpoint/services/fleet'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export { AppClient }; @@ -46,6 +47,7 @@ export interface SecuritySolutionApiRequestHandlerContext { getRacClient: (req: KibanaRequest) => Promise; getExceptionListClient: () => ExceptionListClient | null; getInternalFleetServices: () => EndpointInternalFleetServicesInterface; + getRiskEngineDataClient: () => RiskEngineDataClient; } export type SecuritySolutionRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/plugins/stack_alerts/public/index.ts b/x-pack/plugins/stack_alerts/public/index.ts index d043883d6e248..da63456eca347 100644 --- a/x-pack/plugins/stack_alerts/public/index.ts +++ b/x-pack/plugins/stack_alerts/public/index.ts @@ -7,4 +7,6 @@ import { StackAlertsPublicPlugin } from './plugin'; +export { DataViewSelectPopover } from './rule_types/components/data_view_select_popover'; + export const plugin = () => new StackAlertsPublicPlugin(); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx index 78969e3e5dd21..b6729d9ab6395 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { DataViewSelectPopover, DataViewSelectPopoverProps } from './data_view_select_popover'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/public'; import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; @@ -24,12 +23,6 @@ const selectedDataView = { getName: () => 'kibana_sample_data_logs', } as unknown as DataView; -const props: DataViewSelectPopoverProps = { - onSelectDataView: () => {}, - onChangeMetaData: () => {}, - dataView: selectedDataView, -}; - const dataViewIds = ['mock-data-logs-id', 'mock-ecommerce-id', 'mock-test-id', 'mock-ad-hoc-id']; const dataViewOptions = [ @@ -80,15 +73,15 @@ const mount = () => { Promise.resolve(dataViewOptions.find((current) => current.id === id)) ); const dataViewEditorMock = dataViewEditorPluginMock.createStartContract(); + const props: DataViewSelectPopoverProps = { + dependencies: { dataViews: dataViewsMock, dataViewEditor: dataViewEditorMock }, + onSelectDataView: () => {}, + onChangeMetaData: () => {}, + dataView: selectedDataView, + }; return { - wrapper: mountWithIntl( - - - - ), + wrapper: mountWithIntl(), dataViewsMock, }; }; diff --git a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx index ac71501dd5c34..2bf75dcbd9c08 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/components/data_view_select_popover.tsx @@ -20,13 +20,17 @@ import { EuiText, useEuiPaddingCSS, } from '@elastic/eui'; -import type { DataView } from '@kbn/data-views-plugin/public'; +import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; +import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DataViewSelector } from '@kbn/unified-search-plugin/public'; import type { DataViewListItemEnhanced } from '@kbn/unified-search-plugin/public/dataview_picker/dataview_list'; -import { useTriggerUiActionServices } from '../es_query/util'; import { EsQueryRuleMetaData } from '../es_query/types'; export interface DataViewSelectPopoverProps { + dependencies: { + dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; + }; dataView?: DataView; metadata?: EsQueryRuleMetaData; onSelectDataView: (selectedDataView: DataView) => void; @@ -43,12 +47,12 @@ const toDataViewListItem = (dataView: DataView): DataViewListItemEnhanced => { }; export const DataViewSelectPopover: React.FunctionComponent = ({ + dependencies: { dataViews, dataViewEditor }, metadata = { adHocDataViewList: [], isManagementPage: true }, dataView, onSelectDataView, onChangeMetaData, }) => { - const { dataViews, dataViewEditor } = useTriggerUiActionServices(); const [dataViewItems, setDataViewsItems] = useState([]); const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx index f2a6ca3a84cee..1cc45801ffe65 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/search_source_expression_form.tsx @@ -78,6 +78,7 @@ const isSearchSourceParam = (action: LocalStateAction): action is SearchSourcePa export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { const services = useTriggerUiActionServices(); const unifiedSearch = services.unifiedSearch; + const { dataViews, dataViewEditor } = useTriggerUiActionServices(); const { searchSource, errors, initialSavedQuery, setParam, ruleParams } = props; const [savedQuery, setSavedQuery] = useState(); @@ -117,7 +118,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp ); const { index: dataView, query, filter: filters } = ruleConfiguration; - const dataViews = useMemo(() => (dataView ? [dataView] : []), [dataView]); + const indexPatterns = useMemo(() => (dataView ? [dataView] : []), [dataView]); const [esFields, setEsFields] = useState( dataView ? convertFieldSpecToFieldOption(dataView.fields.map((field) => field.toSpec())) : [] @@ -296,6 +297,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp ( actionId: string, - data: unknown -): ConnectorTypeExecutorResult { + data: T +): ConnectorTypeExecutorResult { return { status: 'ok', data, actionId }; } diff --git a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts index e257ee20a3d7c..3a96528ba2801 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts @@ -11,6 +11,10 @@ export const SlackApiSecretsSchema = schema.object({ token: schema.string({ minLength: 1 }), }); +export const SlackApiConfigSchema = schema.object({ + allowedChannels: schema.maybe(schema.arrayOf(schema.string())), +}); + export const GetChannelsParamsSchema = schema.object({ subAction: schema.literal('getChannels'), }); diff --git a/x-pack/plugins/stack_connectors/common/slack_api/types.ts b/x-pack/plugins/stack_connectors/common/slack_api/types.ts index 1098d40eded19..6d87b078b8c15 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/types.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/types.ts @@ -14,52 +14,64 @@ import { PostMessageSubActionParamsSchema, SlackApiSecretsSchema, SlackApiParamsSchema, + SlackApiConfigSchema, } from './schema'; export type SlackApiSecrets = TypeOf; +export type SlackApiConfig = TypeOf; export type PostMessageParams = TypeOf; export type PostMessageSubActionParams = TypeOf; export type SlackApiParams = TypeOf; -export type SlackApiConnectorType = ConnectorType<{}, SlackApiSecrets, SlackApiParams, unknown>; +export type SlackApiConnectorType = ConnectorType< + SlackApiConfig, + SlackApiSecrets, + SlackApiParams, + unknown +>; export type SlackApiExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackApiActionParams = TypeOf; -export interface GetChannelsResponse { - ok: true; - error?: string; - channels?: Array<{ - id: string; - name: string; - is_channel: boolean; - is_archived: boolean; - is_private: boolean; - }>; -} - -export interface PostMessageResponse { +export interface SlackAPiResponse { ok: boolean; - channel?: string; error?: string; message?: { text: string; }; + response_metadata?: { + next_cursor: string; + }; +} + +export interface ChannelsResponse { + id: string; + name: string; + is_channel: boolean; + is_archived: boolean; + is_private: boolean; +} +export interface GetChannelsResponse extends SlackAPiResponse { + channels?: ChannelsResponse[]; +} + +export interface PostMessageResponse extends SlackAPiResponse { + channel?: string; } export interface SlackApiService { - getChannels: () => Promise>; + getChannels: () => Promise>; postMessage: ({ channels, text, diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 6b985dbb90e34..102595ebb89b8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -20,13 +20,14 @@ import type { SlackApiActionParams, SlackApiSecrets, PostMessageParams, + SlackApiConfig, } from '../../../common/slack_api/types'; import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; import { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; export const getConnectorType = (): ConnectorTypeModel< - unknown, + SlackApiConfig, SlackApiSecrets, PostMessageParams > => ({ diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx index ef9877c5a8772..8346e4b07c697 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -7,29 +7,57 @@ import React from 'react'; import { act, render, fireEvent, screen } from '@testing-library/react'; -import SlackActionFields from './slack_connectors'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; + import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; +import SlackActionFields from './slack_connectors'; +import { useFetchChannels } from './use_fetch_channels'; jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); +jest.mock('./use_fetch_channels'); + +(useKibana as jest.Mock).mockImplementation(() => ({ + services: { + docLinks: { + links: { + alerting: { slackApiAction: 'url' }, + }, + }, + notifications: { + toasts: { + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }, + }, +})); + +(useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [], + isLoading: false, +})); describe('SlackActionFields renders', () => { const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); + it('all connector fields is rendered for web_api type', async () => { const actionConnector = { secrets: { token: 'some token', }, + config: { + allowedChannels: ['foo', 'bar'], + }, id: 'test', actionTypeId: '.slack', name: 'slack', - config: {}, isDeprecated: false, }; - render( + const { container } = render( {}} /> @@ -37,6 +65,17 @@ describe('SlackActionFields renders', () => { expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + const allowedChannels: string[] = []; + container + .querySelectorAll('[data-test-subj="config.allowedChannels-input"] .euiBadge') + .forEach((node) => { + const channel = node.getAttribute('title'); + if (channel) { + allowedChannels.push(channel); + } + }); + expect(allowedChannels).toEqual(['foo', 'bar']); }); it('connector validation succeeds when connector config is valid for Web API type', async () => { @@ -66,6 +105,9 @@ describe('SlackActionFields renders', () => { secrets: { token: 'some token', }, + config: { + allowedChannels: [], + }, id: 'test', actionTypeId: '.slack', name: 'slack', @@ -74,4 +116,62 @@ describe('SlackActionFields renders', () => { isValid: true, }); }); + + it('Allowed Channels combobox should be disable when there is NO token', async () => { + const actionConnector = { + secrets: { + token: '', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + const { container } = render( + + {}} /> + + ); + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).toBeInTheDocument(); + }); + + it('Allowed Channels combobox should NOT be disable when there is token', async () => { + const actionConnector = { + secrets: { + token: 'qwertyuiopasdfghjklzxcvbnm', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + (useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [{ label: 'foo' }, { label: 'bar' }, { label: 'hello' }, { label: 'world' }], + isLoading: false, + })); + + const { container } = render( + + {}} /> + + ); + + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 70d1e06501748..2caf8bff0b611 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -5,17 +5,26 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActionConnectorFieldsProps, + ConfigFieldSchema, SecretsFieldSchema, SimpleConnectorForm, useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import { EuiLink } from '@elastic/eui'; +import { EuiComboBoxOptionOption, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { DocLinksStart } from '@kbn/core/public'; + +import { useFormContext, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { debounce, isEmpty } from 'lodash'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as i18n from './translations'; +import { useFetchChannels } from './use_fetch_channels'; + +/** wait this many ms after the user completes typing before applying the filter input */ +const INPUT_TIMEOUT = 250; const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => [ { @@ -33,18 +42,83 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => }, ]; -const SlackActionFields: React.FC = ({ readOnly, isEdit }) => { +const getConfigFormSchemaAfterSecrets = ( + options: EuiComboBoxOptionOption[], + isLoading: boolean, + isDisabled: boolean +): ConfigFieldSchema[] => [ + { + id: 'allowedChannels', + isRequired: false, + label: i18n.ALLOWED_CHANNELS, + helpText: ( + + ), + type: 'COMBO_BOX', + euiFieldProps: { + isDisabled, + isLoading, + noSuggestions: false, + options, + }, + }, +]; + +const NO_SCHEMA: ConfigFieldSchema[] = []; + +export const SlackActionFieldsComponents: React.FC = ({ + readOnly, + isEdit, +}) => { const { docLinks } = useKibana().services; + const form = useFormContext(); + const { setFieldValue } = form; + const [formData] = useFormData({ form }); + const [authToken, setAuthToken] = useState(''); + + const { channels, isLoading } = useFetchChannels({ authToken }); + const configFormSchemaAfterSecrets = useMemo( + () => getConfigFormSchemaAfterSecrets(channels, isLoading, channels.length === 0), + [channels, isLoading] + ); + + const debounceSetToken = debounce(setAuthToken, INPUT_TIMEOUT); + useEffect(() => { + if (formData.secrets && formData.secrets.token !== authToken) { + debounceSetToken(formData.secrets.token); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData.secrets]); + + useEffect(() => { + if (isEmpty(authToken) && channels.length > 0) { + setFieldValue('config.allowedChannels', []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authToken]); + return ( ); }; +export const simpleConnectorQueryClient = new QueryClient(); + +const SlackActionFields: React.FC = (props) => ( + + + +); + // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx index e8353a3bbabf3..85f909783f178 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import SlackParamsFields from './slack_params'; import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; @@ -156,28 +156,33 @@ describe('SlackParamsFields renders', () => { }); test('all params fields is rendered for getChannels call', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - - ); + const WrappedComponent = () => { + return ( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + }; + const { getByTestId } = render(); + + getByTestId('slackChannelsComboBox').click(); + getByTestId('comboBoxSearchInput').focus(); - expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels'); - fireEvent.click(screen.getByTestId('slackChannelsButton')); - expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument(); - expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general'); - fireEvent.click(screen.getByText('general')); - expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true'); + const options = getByTestId( + 'comboBoxOptionsList slackChannelsComboBox-optionsList' + ).querySelectorAll('.euiComboBoxOption__content'); + expect(options).toHaveLength(1); + expect(options[0].textContent).toBe('general'); }); test('show error message when no channel is selected', async () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx index 6d5f284e764b5..68172f50d51de 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -9,24 +9,10 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; -import { - EuiSpacer, - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiSelectable, - EuiSelectableOption, - EuiFormRow, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types'; -interface ChannelsStatus { - label: string; - checked?: 'on'; -} - const SlackParamsFields: React.FunctionComponent> = ({ actionConnector, actionParams, @@ -85,6 +71,10 @@ const SlackParamsFields: React.FunctionComponent( + (channels ?? []).map((c) => ({ label: c })) + ); + const slackChannels = useMemo( () => channelsInfo @@ -93,44 +83,11 @@ const SlackParamsFields: React.FunctionComponent(channels ?? []); - - const button = ( - setIsPopoverOpen(!isPopoverOpen)} - numFilters={selectedChannels.length} - hasActiveFilters={selectedChannels.length > 0} - numActiveFilters={selectedChannels.length} - data-test-subj="slackChannelsButton" - > - - - ); - - const options: ChannelsStatus[] = useMemo( - () => - slackChannels.map((slackChannel) => ({ - label: slackChannel.label, - ...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}), - })), - [slackChannels, selectedChannels] - ); - const onChange = useCallback( - (newOptions: EuiSelectableOption[]) => { - const newSelectedChannels = newOptions.reduce((result, option) => { - if (option.checked === 'on') { - result = [...result, option.label]; - } - return result; - }, []); + (newOptions: EuiComboBoxOptionOption[]) => { + const newSelectedChannels = newOptions.map((option) => option.label); - setSelectedChannels(newSelectedChannels); + setSelectedChannels(newOptions); editAction('subActionParams', { channels: newSelectedChannels, text }, index); }, [editAction, index, text] @@ -139,53 +96,22 @@ const SlackParamsFields: React.FunctionComponent 0 && channels.length === 0} > - - setIsPopoverOpen(false)} - > - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + => { + return http.post( + `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + { + body: JSON.stringify({ + authToken: newAuthToken, + }), + } + ); +}; + +export function useFetchChannels(props: UseFetchChannelsProps) { + const { authToken } = props; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const queryFn = () => { + return fetchChannels(http, authToken); + }; + + const onErrorFn = () => { + toasts.addDanger(i18n.ERROR_FETCH_CHANNELS); + }; + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['fetchChannels', authToken], + queryFn, + onError: onErrorFn, + enabled: authToken.length > 0, + refetchOnWindowFocus: false, + }); + + const channels = useMemo(() => { + return (data?.channels ?? []).map((channel: ChannelsResponse) => ({ + label: channel.name, + })); + }, [data]); + + return { + channels, + isLoading: isLoading || isFetching, + }; +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts index 66bc3fba1219c..2b4022285dea8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { Logger } from '@kbn/core/server'; import { Services } from '@kbn/actions-plugin/server/types'; -import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { getConnectorType } from '.'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; @@ -48,6 +48,36 @@ describe('connector registration', () => { }); }); +describe('validate config', () => { + test('should throw error when config are invalid', () => { + expect(() => { + validateConfig(connectorType, { message: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [message]: definition for this key is missing"` + ); + + expect(() => { + validateConfig(connectorType, { allowedChannels: 'foo' }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [allowedChannels]: could not parse array value from json input"` + ); + }); + + test('should validate when config are valid', () => { + expect(() => { + validateConfig( + connectorType, + { allowedChannels: ['foo', 'bar'] }, + { configurationUtilities } + ); + }).not.toThrow(); + + expect(() => { + validateConfig(connectorType, {}, { configurationUtilities }); + }).not.toThrow(); + }); +}); + describe('validate params', () => { test('should validate and throw error when params are invalid', () => { expect(() => { @@ -280,7 +310,7 @@ describe('execute', () => { configurationUtilities, logger: mockedLogger, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); expect(response).toEqual({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts index ee467dad3d8a2..bc3128dc666b8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -13,14 +13,17 @@ import { import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; import type { SlackApiExecutorOptions, SlackApiConnectorType, SlackApiParams, SlackApiSecrets, } from '../../../common/slack_api/types'; -import { SlackApiSecretsSchema, SlackApiParamsSchema } from '../../../common/slack_api/schema'; +import { + SlackApiSecretsSchema, + SlackApiParamsSchema, + SlackApiConfigSchema, +} from '../../../common/slack_api/schema'; import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { SLACK_CONNECTOR_NAME } from './translations'; import { api } from './api'; @@ -35,7 +38,7 @@ export const getConnectorType = (): SlackApiConnectorType => { name: SLACK_CONNECTOR_NAME, supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], validate: { - config: { schema: schema.object({}, { defaultValue: {} }) }, + config: { schema: SlackApiConfigSchema }, secrets: { schema: SlackApiSecretsSchema, customValidator: validateSlackUrl, @@ -80,6 +83,7 @@ const renderParameterTemplates = (params: SlackApiParams, variables: Record { logger, configurationUtilities, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); }); @@ -177,5 +177,50 @@ describe('Slack API service', () => { status: 'error', }); }); + + test('should NOT by pass allowed channels when present', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar'] }, + }, + logger, + configurationUtilities + ); + + expect( + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }) + ).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + serviceMessage: + 'The channel "general,privat" is not included in the allowed channels list "foo,bar"', + message: 'error posting slack message', + status: 'error', + }); + }); + + test('should allowed channels to be persisted', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar', 'general', 'privat'] }, + }, + logger, + configurationUtilities + ); + requestMock.mockImplementation(() => postMessageResponse); + + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'a message' }, + }); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts index 723d629a74418..a2b6ff8989880 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -18,6 +18,9 @@ import type { PostMessageSubActionParams, SlackApiService, PostMessageResponse, + GetChannelsResponse, + SlackAPiResponse, + ChannelsResponse, } from '../../../common/slack_api/types'; import { retryResultSeconds, @@ -29,13 +32,16 @@ import { import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; +const RE_TRY = 5; +const LIMIT = 1000; + const buildSlackExecutorErrorResponse = ({ slackApiError, logger, }: { slackApiError: { message: string; - response: { + response?: { status: number; statusText: string; headers: Record; @@ -78,11 +84,11 @@ const buildSlackExecutorErrorResponse = ({ return errorResult(SLACK_API_CONNECTOR_ID, errorMessage); }; -const buildSlackExecutorSuccessResponse = ({ +const buildSlackExecutorSuccessResponse = ({ slackApiResponseData, }: { - slackApiResponseData: PostMessageResponse; -}) => { + slackApiResponseData: T; +}): ConnectorTypeExecutorResult => { if (!slackApiResponseData) { const errMessage = i18n.translate( 'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage', @@ -96,17 +102,16 @@ const buildSlackExecutorSuccessResponse = ({ if (!slackApiResponseData.ok) { return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiResponseData.error); } - - return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); + return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); }; export const createExternalService = ( - { secrets }: { secrets: { token: string } }, + { config, secrets }: { config?: { allowedChannels?: string[] }; secrets: { token: string } }, logger: Logger, configurationUtilities: ActionsConfigurationUtilities ): SlackApiService => { const { token } = secrets; - + const { allowedChannels } = config || { allowedChannels: [] }; if (!token) { throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`); } @@ -119,17 +124,73 @@ export const createExternalService = ( }, }); - const getChannels = async (): Promise> => { + const getChannels = async (): Promise< + ConnectorTypeExecutorResult + > => { try { - const result = await request({ - axios: axiosInstance, - configurationUtilities, - logger, - method: 'get', - url: 'conversations.list?types=public_channel,private_channel', - }); + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + result.data.channels = channels; + const responseData = result.data; + if ((allowedChannels ?? []).length > 0) { + const allowedChannelsList = channels.filter((channel: ChannelsResponse) => + allowedChannels?.includes(channel.name) + ); + allowedChannels?.forEach((ac) => { + if (!allowedChannelsList.find((c: ChannelsResponse) => c.name === ac)) { + allowedChannelsList.push({ + id: '-1', + name: ac, + is_channel: true, + is_archived: false, + is_private: false, + }); + } + }); + responseData.channels = allowedChannelsList; + } - return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + return buildSlackExecutorSuccessResponse({ + slackApiResponseData: responseData, + }); } catch (error) { return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); } @@ -140,6 +201,19 @@ export const createExternalService = ( text, }: PostMessageSubActionParams): Promise> => { try { + if ( + allowedChannels && + allowedChannels.length > 0 && + !channels.every((c) => allowedChannels?.includes(c)) + ) { + return buildSlackExecutorErrorResponse({ + slackApiError: { + message: `The channel "${channels.join()}" is not included in the allowed channels list "${allowedChannels.join()}"`, + }, + logger, + }); + } + const result: AxiosResponse = await request({ axios: axiosInstance, method: 'post', diff --git a/x-pack/plugins/stack_connectors/server/plugin.ts b/x-pack/plugins/stack_connectors/server/plugin.ts index ce1795b4eb7fb..3e76b9adbb083 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; +import { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server'; import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { registerConnectorTypes } from './connector_types'; -import { getWellKnownEmailServiceRoute } from './routes'; +import { getSlackApiChannelsRoute, getWellKnownEmailServiceRoute } from './routes'; export interface ConnectorsPluginsSetup { actions: ActionsPluginSetupContract; } @@ -18,13 +18,18 @@ export interface ConnectorsPluginsStart { } export class StackConnectorsPlugin implements Plugin { - constructor(context: PluginInitializerContext) {} + private readonly logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } public setup(core: CoreSetup, plugins: ConnectorsPluginsSetup) { const router = core.http.createRouter(); const { actions } = plugins; getWellKnownEmailServiceRoute(router); + getSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger); registerConnectorTypes({ actions, diff --git a/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts new file mode 100644 index 0000000000000..dac35c0503cbe --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import axios, { AxiosResponse } from 'axios'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; +import { SLACK_URL } from '../../common/slack_api/constants'; +import { ChannelsResponse, GetChannelsResponse } from '../../common/slack_api/types'; + +const bodySchema = schema.object({ + authToken: schema.string(), +}); + +const RE_TRY = 5; +const LIMIT = 1000; + +export const getSlackApiChannelsRoute = ( + router: IRouter, + configurationUtilities: ActionsConfigurationUtilities, + logger: Logger +) => { + router.post( + { + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + validate: { + body: bodySchema, + }, + }, + handler + ); + + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise { + const { authToken } = req.body; + + const axiosInstance = axios.create({ + baseURL: SLACK_URL, + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + + return res.ok({ + body: { + ...result.data, + channels, + }, + }); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/routes/index.ts b/x-pack/plugins/stack_connectors/server/routes/index.ts index 2766b99679845..df48f18480252 100644 --- a/x-pack/plugins/stack_connectors/server/routes/index.ts +++ b/x-pack/plugins/stack_connectors/server/routes/index.ts @@ -6,3 +6,4 @@ */ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; +export { getSlackApiChannelsRoute } from './get_slack_api_channels'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3c839d1c973fa..d1a13fec106d7 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7409,7 +7409,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "Une erreur est survenue lors de la création d'une ou de plusieurs tâches de détection des anomalies pour les environnements de service APM [{environments}]. Erreur : \"{errorMessage}\"", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "Tâches de détection des anomalies créées avec succès pour les environnements de service APM [{environments}]. Le démarrage de l'analyse du trafic à la recherche d'anomalies par le Machine Learning va prendre un certain temps.", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "La détection des anomalies n'est pas encore activée pour l'environnement \"{currentEnvironment}\". Cliquez pour continuer la configuration.", - "xpack.apm.apmSettings.kibanaLink": "La liste complète d'options APM est disponible dans {link}", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0 modification non enregistrée} one {1 modification non enregistrée} many {# modifications non enregistrées} other {# modifications non enregistrées}} ", "xpack.apm.compositeSpanCallsLabel": ", {count} appels, sur une moyenne de {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "Les données pour l'analyse de corrélation n'ont pas pu être totalement récupérées. Cette fonctionnalité est prise en charge uniquement pour {version} et versions ultérieures.", @@ -7779,9 +7778,7 @@ "xpack.apm.apmDescription": "Collecte automatiquement les indicateurs et les erreurs de performances détaillés depuis vos applications.", "xpack.apm.apmSchema.index": "Schéma du serveur APM - Index", "xpack.apm.apmServiceGroups.title": "Groupes de services APM", - "xpack.apm.apmSettings.callOutTitle": "Vous recherchez tous les paramètres ?", "xpack.apm.apmSettings.index": "Paramètres APM - Index", - "xpack.apm.apmSettings.kibanaLink.label": "Paramètres avancés de Kibana.", "xpack.apm.apmSettings.save.error": "Une erreur s'est produite lors de l'enregistrement des paramètres", "xpack.apm.apmSettings.saveButton": "Enregistrer les modifications", "xpack.apm.appName": "APM", @@ -32660,7 +32657,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "Annuler", "xpack.securitySolution.markdown.insight.relativeTimerange": "Plage temporelle relative", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "Sélectionnez une plage horaire pour limiter la requête, par rapport à l'heure de création de l'alerte (facultatif).", - "xpack.securitySolution.markdown.insight.technicalPreview": "Version d'évaluation technique", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "Ajouter une recherche", "xpack.securitySolution.markdown.osquery.addModalTitle": "Ajouter une recherche", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "Enregistrer les modifications", @@ -35296,10 +35292,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Le message est requis.", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Envoyer vers Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "L'URL de webhook n'est pas valide.", - "xpack.stackConnectors.components.slack.loadingMessage": "Chargement des canaux", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "Message", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "Aucun canal disponible", - "xpack.stackConnectors.components.slack.noChannelsFound": "Aucun canal trouvé", "xpack.stackConnectors.components.slack.selectMessageText": "Envoyez un message à un canal ou à un utilisateur Slack.", "xpack.stackConnectors.components.slack.webApi": "API web", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35452,7 +35445,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "erreur lors de la configuration de l'action slack : impossible d'analyser le nom de l'hôte depuis webhookUrl", "xpack.stackConnectors.slack.errorPostingErrorMessage": "erreur lors de la publication du message slack", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement", - "xpack.stackConnectors.slack.params..showChannelsListButton": "Canaux", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Impossible de récupérer la liste des canaux Slack", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack", @@ -39563,4 +39555,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f51885cdb11d3..497b63577437d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7410,7 +7410,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "APMサービス環境[{environments}]用に1つ以上の異常検知ジョブを作成しているときに問題が発生しました。エラー「{errorMessage}」", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APMサービス環境[{environments}]の異常検知ジョブが正常に作成されました。機械学習がトラフィック異常値の分析を開始するには、少し時間がかかります。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "「{currentEnvironment}」環境では、まだ異常検知が有効ではありません。クリックすると、セットアップを続行します。", - "xpack.apm.apmSettings.kibanaLink": "APMオプションの一覧は{link}を参照してください", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0個の保存されていない変更} other {#個の保存されていない変更}} ", "xpack.apm.compositeSpanCallsLabel": "、{count}件の呼び出し、平均:{duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "相関関係分析のデータを完全に取得できませんでした。この機能はバージョン{version}以降でのみサポートされています。", @@ -7780,9 +7779,7 @@ "xpack.apm.apmDescription": "アプリケーション内から自動的に詳細なパフォーマンスメトリックやエラーを集めます。", "xpack.apm.apmSchema.index": "APMサーバースキーマ - インデックス", "xpack.apm.apmServiceGroups.title": "APMサービスグループ", - "xpack.apm.apmSettings.callOutTitle": "すべての設定を探している場合", "xpack.apm.apmSettings.index": "APM 設定 - インデックス", - "xpack.apm.apmSettings.kibanaLink.label": "Kibana詳細設定。", "xpack.apm.apmSettings.save.error": "設定の保存中にエラーが発生しました", "xpack.apm.apmSettings.saveButton": "変更を保存", "xpack.apm.appName": "APM", @@ -32641,7 +32638,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "キャンセル", "xpack.securitySolution.markdown.insight.relativeTimerange": "相対的時間範囲", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "アラートの作成日時に相対的な、クエリを限定するための時間範囲を選択します(任意)。", - "xpack.securitySolution.markdown.insight.technicalPreview": "テクニカルプレビュー", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "クエリを追加", "xpack.securitySolution.markdown.osquery.addModalTitle": "クエリを追加", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "変更を保存", @@ -35277,10 +35273,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "メッセージが必要です。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Slack に送信", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Web フック URL が無効です。", - "xpack.stackConnectors.components.slack.loadingMessage": "チャンネルを読み込み中", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "メッセージ", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "チャンネルがありません", - "xpack.stackConnectors.components.slack.noChannelsFound": "チャンネルが見つかりません", "xpack.stackConnectors.components.slack.selectMessageText": "Slack チャネルにメッセージを送信します。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Web フック", @@ -35433,7 +35426,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "slack アクションの構成エラー:Web フック URL からホスト名をパースできません", "xpack.stackConnectors.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行", - "xpack.stackConnectors.slack.params..showChannelsListButton": "チャンネル", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Slackチャンネルリストを取得できませんでした", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "Slack から予期せぬ null 応答", @@ -39533,4 +39525,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 02fdd4e443530..f4618236706fd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7409,7 +7409,6 @@ "xpack.apm.anomalyDetection.createJobs.failed.text": "为 APM 服务环境 [{environments}] 创建一个或多个异常检测作业时出现问题。错误:“{errorMessage}”", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APM 服务环境 [{environments}] 的异常检测作业已成功创建。Machine Learning 要过一些时间才会开始分析流量以发现异常。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "尚未针对环境“{currentEnvironment}”启用异常检测。单击可继续设置。", - "xpack.apm.apmSettings.kibanaLink": "可在 {link} 中找到 APM 选项的完整列表", "xpack.apm.bottomBarActions.unsavedChanges": "{unsavedChangesCount, plural, =0 {0 个未保存更改} other {# 个未保存更改}} ", "xpack.apm.compositeSpanCallsLabel": ",{count} 个调用,平均 {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "无法完全检索相关性分析的数据。仅 {version} 及更高版本支持此功能。", @@ -7779,9 +7778,7 @@ "xpack.apm.apmDescription": "自动从您的应用程序内收集深层的性能指标和错误。", "xpack.apm.apmSchema.index": "APM Server 架构 - 索引", "xpack.apm.apmServiceGroups.title": "APM 服务组", - "xpack.apm.apmSettings.callOutTitle": "正在查找所有设置?", "xpack.apm.apmSettings.index": "APM 设置 - 索引", - "xpack.apm.apmSettings.kibanaLink.label": "Kibana 高级设置。", "xpack.apm.apmSettings.save.error": "保存设置时出错", "xpack.apm.apmSettings.saveButton": "保存更改", "xpack.apm.appName": "APM", @@ -32637,7 +32634,6 @@ "xpack.securitySolution.markdown.insight.modalCancelButtonLabel": "取消", "xpack.securitySolution.markdown.insight.relativeTimerange": "相对时间范围", "xpack.securitySolution.markdown.insight.relativeTimerangeText": "选择相对于告警创建时间的时间范围(可选)以限制查询。", - "xpack.securitySolution.markdown.insight.technicalPreview": "技术预览", "xpack.securitySolution.markdown.osquery.addModalConfirmButtonLabel": "添加查询", "xpack.securitySolution.markdown.osquery.addModalTitle": "添加查询", "xpack.securitySolution.markdown.osquery.editModalConfirmButtonLabel": "保存更改", @@ -35271,10 +35267,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "“消息”必填。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "发送到 Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Webhook URL 无效。", - "xpack.stackConnectors.components.slack.loadingMessage": "正在加载频道", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "消息", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "无频道可用", - "xpack.stackConnectors.components.slack.noChannelsFound": "找不到频道", "xpack.stackConnectors.components.slack.selectMessageText": "向 Slack 频道或用户发送消息。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35427,7 +35420,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "配置 slack 操作时出错:无法解析 webhookUrl 中的主机名", "xpack.stackConnectors.slack.errorPostingErrorMessage": "发布 slack 消息时出错", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试", - "xpack.stackConnectors.slack.params..showChannelsListButton": "频道", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "无法检索 Slack 频道列表", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "来自 slack 的异常空响应", @@ -39527,4 +39519,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx index d552babfcab8d..d76b76e641821 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx @@ -7,8 +7,12 @@ import React, { memo, ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { Field, PasswordField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + ComboBoxField, + Field, + PasswordField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { FIELD_TYPES, getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { i18n } from '@kbn/i18n'; @@ -16,11 +20,14 @@ export interface CommonFieldSchema { id: string; label: string; helpText?: string | ReactNode; + isRequired?: boolean; + type?: keyof typeof FIELD_TYPES; + euiFieldProps?: Record; } export interface ConfigFieldSchema extends CommonFieldSchema { isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; } export interface SecretsFieldSchema extends CommonFieldSchema { @@ -32,35 +39,45 @@ interface SimpleConnectorFormProps { readOnly: boolean; configFormSchema: ConfigFieldSchema[]; secretsFormSchema: SecretsFieldSchema[]; + configFormSchemaAfterSecrets?: ConfigFieldSchema[]; } type FormRowProps = ConfigFieldSchema & SecretsFieldSchema & { readOnly: boolean }; -const UseField = getUseField({ component: Field }); +const UseTextField = getUseField({ component: Field }); +const UseComboBoxField = getUseField({ component: ComboBoxField }); const { emptyField, urlField } = fieldValidators; const getFieldConfig = ({ label, + isRequired = true, isUrlField = false, defaultValue, + type, }: { label: string; + isRequired?: boolean; isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; + type?: keyof typeof FIELD_TYPES; }) => ({ label, validations: [ - { - validator: emptyField( - i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + ...(isRequired + ? [ { - values: { label }, - defaultMessage: `{label} is required.`, - } - ) - ), - }, + validator: emptyField( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + { + values: { label }, + defaultMessage: `{label} is required.`, + } + ) + ), + }, + ] + : []), ...(isUrlField ? [ { @@ -77,18 +94,33 @@ const getFieldConfig = ({ : []), ], defaultValue, + ...(type && FIELD_TYPES[type] + ? { type: FIELD_TYPES[type], defaultValue: Array.isArray(defaultValue) ? defaultValue : [] } + : {}), }); +const getComponentByType = (type?: keyof typeof FIELD_TYPES) => { + let UseField = UseTextField; + if (type && FIELD_TYPES[type] === FIELD_TYPES.COMBO_BOX) { + UseField = UseComboBoxField; + } + return UseField; +}; + const FormRow: React.FC = ({ id, label, readOnly, isPasswordField, + isRequired = true, isUrlField, helpText, defaultValue, + euiFieldProps = {}, + type, }) => { const dataTestSub = `${id}-input`; + const UseField = getComponentByType(type); return ( <> @@ -96,20 +128,26 @@ const FormRow: React.FC = ({ {!isPasswordField ? ( ) : ( = ({ readOnly, configFormSchema, secretsFormSchema, + configFormSchemaAfterSecrets = [], }) => { return ( <> @@ -162,6 +201,12 @@ const SimpleConnectorFormComponent: React.FC = ({ {index !== secretsFormSchema.length ? : null} ))} + {configFormSchemaAfterSecrets.map(({ id, ...restConfigSchemaAfterSecrets }, index) => ( + + + {index !== configFormSchemaAfterSecrets.length ? : null} + + ))} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx index 557e0cf04b0f9..ccf31e86f4d1a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx @@ -707,7 +707,8 @@ describe('AlertsTableState', () => { }); }); - describe('field browser', () => { + // FLAKY: https://github.com/elastic/kibana/issues/160091 + describe.skip('field browser', () => { const browserFields: BrowserFields = { kibana: { fields: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx index 44b72072f836d..2dc460fc755ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/event_log/event_log_list_status.tsx @@ -56,9 +56,9 @@ export const EventLogListStatus = (props: EventLogListStatusProps) => { }, [useExecutionStatus, status]); return ( -
+ {statusString} -
+ ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx index 2f0aa0460c754..9e393a6507fc6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState, useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import datemath from '@kbn/datemath'; -import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; import { IExecutionKPIResult } from '@kbn/alerting-plugin/common'; import { ComponentOpts as RuleApis, @@ -130,12 +130,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => { const isLoadingData = useMemo(() => isLoading || !kpi, [isLoading, kpi]); const getStatDescription = (element: React.ReactNode) => { - return ( - <> - {element} - - - ); + return {element}; }; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx index c9626bad07dd6..89f330515091e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.test.tsx @@ -85,13 +85,12 @@ describe('rule_execution_summary_and_chart', () => { const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); expect(avgExecutionDurationPanel.exists()).toBeTruthy(); expect(avgExecutionDurationPanel.first().prop('color')).toEqual('subdued'); - expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + expect(wrapper.find('EuiPanel[data-test-subj="avgExecutionDurationPanel"]').text()).toEqual( 'Average duration00:00:00.100' ); expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="avgExecutionDurationPanel"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="ruleEventLogListAvgDuration"]').first().text()).toEqual( '00:00:00.100' ); @@ -115,15 +114,17 @@ describe('rule_execution_summary_and_chart', () => { // Does not fetch for the rule summary by itself expect(loadRuleSummaryMock).toHaveBeenCalledTimes(1); - ( - wrapper - .find('[data-test-subj="executionDurationChartPanelSelect"]') - .first() - .prop('onChange') as any - )({ - target: { - value: 30, - }, + await act(async () => { + ( + wrapper + .find('[data-test-subj="executionDurationChartPanelSelect"]') + .first() + .prop('onChange') as any + )({ + target: { + value: 30, + }, + }); }); await act(async () => { @@ -137,13 +138,12 @@ describe('rule_execution_summary_and_chart', () => { const avgExecutionDurationPanel = wrapper.find('[data-test-subj="avgExecutionDurationPanel"]'); expect(avgExecutionDurationPanel.exists()).toBeTruthy(); expect(avgExecutionDurationPanel.first().prop('color')).toEqual('subdued'); - expect(wrapper.find('EuiStat[data-test-subj="avgExecutionDurationStat"]').text()).toEqual( + expect(wrapper.find('EuiPanel[data-test-subj="avgExecutionDurationPanel"]').text()).toEqual( 'Average duration00:00:00.100' ); expect(wrapper.find('[data-test-subj="ruleDurationWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="executionDurationChartPanel"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="avgExecutionDurationPanel"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="ruleEventLogListAvgDuration"]').first().text()).toEqual( '00:00:00.100' ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx index f3e7409b66d2b..6a577e9ecf667 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_execution_summary_and_chart.tsx @@ -7,7 +7,14 @@ import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiPanel, EuiStat, EuiFlexItem, EuiFlexGroup, EuiIconTip } from '@elastic/eui'; +import { + EuiPanel, + EuiFlexItem, + EuiFlexGroup, + EuiIconTip, + EuiText, + useEuiTheme, +} from '@elastic/eui'; import { RuleSummary, RuleType } from '../../../../types'; import { useKibana } from '../../../../common/lib/kibana'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; @@ -36,6 +43,13 @@ type RuleExecutionSummaryAndChartProps = { fetchRuleSummary?: boolean; } & Pick; +const ruleTypeExcessDurationMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.ruleTypeExcessDurationMessage', + { + defaultMessage: `Duration exceeds the rule's expected run time.`, + } +); + export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChartProps) => { const { ruleId, @@ -52,6 +66,7 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart const { notifications: { toasts }, } = useKibana().services; + const { euiTheme } = useEuiTheme(); const isInitialized = useRef(false); @@ -154,40 +169,37 @@ export const RuleExecutionSummaryAndChart = (props: RuleExecutionSummaryAndChart color={showDurationWarning ? 'warning' : 'subdued'} hasBorder={false} > - - {showDurationWarning && ( - - - - )} - - {formatMillisForDisplay(computedRuleSummary.executionDuration.average)} - -
- } - description={i18n.translate( + + {i18n.translate( 'xpack.triggersActionsUI.sections.ruleDetails.alertsList.avgDurationDescription', { defaultMessage: `Average duration`, } )} - /> + + + {showDurationWarning && ( + + + + )} + + + + {formatMillisForDisplay(computedRuleSummary.executionDuration.average)} + + + + diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts index 267f5472e6ebb..6da8cfccf1352 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import type SuperTest from 'supertest'; -import { MAX_DOCS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; +import { MAX_COMMENTS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; import { Alerts, createCaseAttachAlertAndDeleteCase, @@ -170,7 +170,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -210,14 +210,14 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase1.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), findAttachments({ supertest: supertestWithoutAuth, caseId: postedCase2.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -457,7 +457,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, auth: { user: secAllUser, space: 'space1' }, }), diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 60b1e789ec775..00bdaa5b25f0b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -8,7 +8,7 @@ import { v1 as uuidv1 } from 'uuid'; import expect from '@kbn/expect'; -import { CASES_URL } from '@kbn/cases-plugin/common/constants'; +import { CASES_URL, MAX_CATEGORY_FILTER_LENGTH } from '@kbn/cases-plugin/common/constants'; import { Case, CaseSeverity, CaseStatuses, CommentType } from '@kbn/cases-plugin/common/api'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -349,6 +349,12 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('unhappy path - 400s when more than the maximum category fields are supplied', async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); + + await findCases({ supertest, query: { category }, expectedHttpCode: 400 }); + }); + describe('search and searchField', () => { beforeEach(async () => { await createCase(supertest, postCaseReq); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts index a218350ec95ca..761959db29f66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts @@ -114,7 +114,7 @@ export default ({ getService }: FtrProviderContext): void => { { name: 'field is wrong type', queryParams: { perPage: true } }, { name: 'field is unknown', queryParams: { foo: 'bar' } }, { name: 'page > 10k', queryParams: { page: 10001 } }, - { name: 'perPage > 10k', queryParams: { perPage: 10001 } }, + { name: 'perPage > 100', queryParams: { perPage: 101 } }, { name: 'page * perPage > 10k', queryParams: { page: 2, perPage: 9001 } }, ]) { it(`400s when ${errorScenario.name}`, async () => { diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index 8454915db9a7d..c4c3c44f1c418 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -76,6 +76,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'previewTelemetryUrlEnabled', + 'riskScoringPersistence', 'riskScoringRoutesEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts index c8501050fc25c..6841648145fd3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts @@ -37,6 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./throttle')); loadTestFile(require.resolve('./ignore_fields')); loadTestFile(require.resolve('./migrations')); + loadTestFile(require.resolve('./risk_engine_install_resources')); loadTestFile(require.resolve('./risk_engine')); loadTestFile(require.resolve('./set_alert_tags')); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts new file mode 100644 index 0000000000000..a7cae20fa8b34 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const es = getService('es'); + + describe('install risk engine resources', () => { + it('should install resources on startup', async () => { + const ilmPolicyName = '.risk-score-ilm-policy'; + const componentTemplateName = '.risk-score-mappings'; + const indexTemplateName = '.risk-score.risk-score-default-index-template'; + const indexName = 'risk-score.risk-score-default'; + + const ilmPolicy = await es.ilm.getLifecycle({ + name: ilmPolicyName, + }); + + expect(ilmPolicy[ilmPolicyName].policy).to.eql({ + _meta: { + managed: true, + }, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }); + + const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({ + name: componentTemplateName, + }); + + expect(componentTemplates1.length).to.eql(1); + const componentTemplate = componentTemplates1[0]; + + expect(componentTemplate.name).to.eql(componentTemplateName); + expect(componentTemplate.component_template.template.mappings).to.eql({ + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }); + + const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({ + name: indexTemplateName, + }); + expect(indexTemplates.length).to.eql(1); + const indexTemplate = indexTemplates[0]; + expect(indexTemplate.name).to.eql(indexTemplateName); + expect(indexTemplate.index_template.index_patterns).to.eql(['risk-score.risk-score-default']); + expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']); + expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); + expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true); + expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default'); + expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a( + 'string' + ); + expect(indexTemplate.index_template.template!.settings).to.eql({ + index: { + lifecycle: { + name: '.risk-score-ilm-policy', + }, + mapping: { + total_fields: { + limit: '1000', + }, + }, + hidden: 'true', + auto_expand_replicas: '0-1', + }, + }); + + const dsResponse = await es.indices.get({ + index: indexName, + }); + + const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName); + + expect(dataStream?.mappings?._meta?.managed).to.eql(true); + expect(dataStream?.mappings?._meta?.namespace).to.eql('default'); + expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string'); + expect(dataStream?.mappings?.dynamic).to.eql('false'); + + expect(dataStream?.settings?.index?.lifecycle).to.eql({ + name: '.risk-score-ilm-policy', + }); + + expect(dataStream?.settings?.index?.mapping).to.eql({ + total_fields: { + limit: '1000', + }, + }); + + expect(dataStream?.settings?.index?.hidden).to.eql('true'); + expect(dataStream?.settings?.index?.number_of_shards).to.eql(1); + expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1'); + }); + }); +}; diff --git a/x-pack/test/fleet_functional/apps/home/welcome.ts b/x-pack/test/fleet_functional/apps/home/welcome.ts index 87926f1803102..4498dda69197f 100644 --- a/x-pack/test/fleet_functional/apps/home/welcome.ts +++ b/x-pack/test/fleet_functional/apps/home/welcome.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'home']); const kibanaServer = getService('kibanaServer'); - // FLAKY: https://github.com/elastic/kibana/issues/156809 - describe.skip('Welcome interstitial', () => { + describe('Welcome interstitial', () => { before(async () => { // Need to navigate to page first to clear storage before test can be run await PageObjects.common.navigateToUrl('home', undefined); diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index f126384071b3d..b3ee528237002 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -26,6 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.createIndexPattern('secrepo', '@timestamp'); log.debug('navigateTo graph'); await PageObjects.common.navigateToApp('graph'); + await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.graph.createWorkspace(); }); @@ -85,6 +86,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } it('should show correct node labels', async function () { + await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.graph.selectIndexPattern('secrepo'); await buildGraph(); const { nodes } = await PageObjects.graph.getGraphObjects(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index 8823f5144a0fc..fe8ed483db24a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -85,8 +85,8 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('case-view-title'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user2'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user2-remove-group'); }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index d2d6d7b6a4396..dd584a51fa979 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -596,11 +596,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('shows the unknown assignee', async () => { - await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.existOrFail('user-profile-assigned-user-abc-remove-group'); }); it('removes the unknown assignee when selecting the remove all users in the popover', async () => { - await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.existOrFail('user-profile-assigned-user-abc-remove-group'); await cases.singleCase.openAssigneesPopover(); await cases.common.setSearchTextInAssigneesPopover('case'); @@ -608,7 +608,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await (await find.byButtonText('Remove all assignees')).click(); await cases.singleCase.closeAssigneesPopover(); - await testSubjects.missingOrFail('user-profile-assigned-user-group-abc'); + await testSubjects.missingOrFail('user-profile-assigned-user-abc-remove-group'); }); }); @@ -627,7 +627,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('assigns the case to the current user when clicking the assign to self link', async () => { await testSubjects.click('case-view-assign-yourself-link'); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); }); }); @@ -652,7 +652,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.common.selectFirstRowInAssigneesPopover(); await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); }); it('assigns multiple users', async () => { @@ -662,8 +662,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user2'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user2-remove-group'); }); }); @@ -678,17 +678,17 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // navigate out of the modal await cases.singleCase.closeAssigneesPopover(); await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + await testSubjects.existOrFail('user-profile-assigned-user-cases_all_user-remove-group'); // hover over the assigned user await ( await find.byCssSelector( - '[data-test-subj="user-profile-assigned-user-group-cases_all_user"]' + '[data-test-subj="user-profile-assigned-user-cases_all_user-remove-group"]' ) ).moveMouseTo(); // delete the user - await testSubjects.click('user-profile-assigned-user-cross-cases_all_user'); + await testSubjects.click('user-profile-assigned-user-cases_all_user-remove-button'); await testSubjects.existOrFail('case-view-assign-yourself-link'); }); diff --git a/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts b/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts index b7f01d9b5d63d..971ba3288366d 100644 --- a/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/integrations/artifact_entries_list.ts @@ -51,7 +51,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { .set('kbn-xsrf', 'true'); }; - describe('For each artifact list under management', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/160166 + describe.skip('For each artifact list under management', function () { this.timeout(150_000); let indexedData: IndexedHostsAndAlertsResponse; let policyInfo: PolicyTestResourceInfo;