diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 391443430f6c4..f7c74b42a54b6 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -156,7 +156,7 @@ pageLoadAssetSize: searchQueryRules: 6689 searchSynonyms: 6371 security: 79627 - securitySolution: 119378 + securitySolution: 186285 securitySolutionEss: 43319 securitySolutionServerless: 62689 serverless: 7852 diff --git a/src/platform/packages/private/kbn-reporting/common/constants.ts b/src/platform/packages/private/kbn-reporting/common/constants.ts index a61ef1d96c91d..7350d0a8af087 100644 --- a/src/platform/packages/private/kbn-reporting/common/constants.ts +++ b/src/platform/packages/private/kbn-reporting/common/constants.ts @@ -13,6 +13,7 @@ import { DASHBOARD_APP_LOCATOR, LENS_APP_LOCATOR, VISUALIZE_APP_LOCATOR, + AI_VALUE_REPORT_LOCATOR, } from '@kbn/deeplinks-analytics'; import type { LicenseType } from '@kbn/licensing-types'; @@ -74,6 +75,7 @@ export const REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES = [ DASHBOARD_APP_LOCATOR, LENS_APP_LOCATOR, VISUALIZE_APP_LOCATOR, + AI_VALUE_REPORT_LOCATOR, ]; // Redirection URL used to load app state for screenshotting diff --git a/src/platform/packages/private/kbn-reporting/public/share/integrations/pdf/index.ts b/src/platform/packages/private/kbn-reporting/public/share/integrations/pdf/index.ts index 9661a52cd7d1b..1826896eac30b 100644 --- a/src/platform/packages/private/kbn-reporting/public/share/integrations/pdf/index.ts +++ b/src/platform/packages/private/kbn-reporting/public/share/integrations/pdf/index.ts @@ -16,7 +16,7 @@ export const reportingPDFExportShareIntegration = ({ apiClient, startServices$, }: ExportModalShareOpts): RegisterShareIntegrationArgs => { - const supportedObjectTypes = ['dashboard', 'visualization', 'lens']; + const supportedObjectTypes = ['dashboard', 'visualization', 'lens', 'ai_value_report']; return { id: 'pdfReports', diff --git a/src/platform/packages/shared/deeplinks/analytics/constants.ts b/src/platform/packages/shared/deeplinks/analytics/constants.ts index e2ff238704e68..c8886a500b66a 100644 --- a/src/platform/packages/shared/deeplinks/analytics/constants.ts +++ b/src/platform/packages/shared/deeplinks/analytics/constants.ts @@ -32,3 +32,5 @@ export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard'; export const LENS_APP_LOCATOR = 'LENS_APP_LOCATOR'; export const VISUALIZE_APP_LOCATOR = 'VISUALIZE_APP_LOCATOR'; + +export const AI_VALUE_REPORT_LOCATOR = 'AI_VALUE_REPORT_LOCATOR'; diff --git a/src/platform/packages/shared/deeplinks/analytics/index.ts b/src/platform/packages/shared/deeplinks/analytics/index.ts index 14ae160026e55..c306b228ce5cc 100644 --- a/src/platform/packages/shared/deeplinks/analytics/index.ts +++ b/src/platform/packages/shared/deeplinks/analytics/index.ts @@ -17,6 +17,7 @@ export { LENS_APP_LOCATOR, DISCOVER_ESQL_LOCATOR, DASHBOARD_APP_LOCATOR, + AI_VALUE_REPORT_LOCATOR, } from './constants'; export type { AppId, DeepLinkId } from './deep_links'; diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts index 1fdd8111410c1..1b4da515511fb 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts @@ -42,6 +42,18 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'keyword', _meta: { description: 'Non-default value of setting.' }, }, + 'securitySolution:defaultValueReportMinutes': { + type: 'keyword', + _meta: { description: 'Non-default value of setting.' }, + }, + 'securitySolution:defaultValueReportRate': { + type: 'keyword', + _meta: { description: 'Non-default value of setting.' }, + }, + 'securitySolution:defaultValueReportTitle': { + type: 'keyword', + _meta: { description: 'Non-default value of setting.' }, + }, 'xpackReporting:customPdfLogo': { type: 'keyword', _meta: { description: 'Default value of the setting was changed.' }, diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts index 7b8282986598c..cb36c6ee5effd 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts @@ -78,6 +78,9 @@ export interface UsageStats { 'securitySolution:enableEsqlRiskScoring': boolean; 'securitySolution:enableCloudConnector': boolean; 'securitySolution:suppressionBehaviorOnAlertClosure': string; + 'securitySolution:defaultValueReportMinutes': string; + 'securitySolution:defaultValueReportRate': string; + 'securitySolution:defaultValueReportTitle': string; 'search:includeFrozen': boolean; 'courier:maxConcurrentShardRequests': number; 'courier:setRequestPreference': string; diff --git a/src/platform/plugins/shared/telemetry/schema/oss_platform.json b/src/platform/plugins/shared/telemetry/schema/oss_platform.json index fce1d8d948005..e06f515e0a954 100644 --- a/src/platform/plugins/shared/telemetry/schema/oss_platform.json +++ b/src/platform/plugins/shared/telemetry/schema/oss_platform.json @@ -10109,6 +10109,24 @@ "description": "Non-default value of setting." } }, + "securitySolution:defaultValueReportMinutes": { + "type": "keyword", + "_meta": { + "description": "Non-default value of setting." + } + }, + "securitySolution:defaultValueReportRate": { + "type": "keyword", + "_meta": { + "description": "Non-default value of setting." + } + }, + "securitySolution:defaultValueReportTitle": { + "type": "keyword", + "_meta": { + "description": "Non-default value of setting." + } + }, "xpackReporting:customPdfLogo": { "type": "keyword", "_meta": { diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/prompt/prompts.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/prompt/prompts.ts index 2c9191c657cae..6cd8eedb68e02 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/prompt/prompts.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/prompt/prompts.ts @@ -333,7 +333,7 @@ export const starterPromptPrompt4 = 'Can you provide examples of questions I can ask about Elastic Security, such as investigating alerts, running ES|QL queries, incident response, or threat intelligence?'; export const costSavingsInsightPart1 = `You are given Elasticsearch Lens aggregation results showing cost savings over time:`; -export const costSavingsInsightPart2 = `Generate a concise bulleted summary in mdx markdown. Follow the style and tone of the example below, highlighting key trends, averages, peaks, and projections: +export const costSavingsInsightPart2 = `Generate a concise bulleted summary in mdx markdown, no more than 500 characters. Follow the style and tone of the example below, highlighting key trends, averages, peaks, and projections: \`\`\` - Between July 18 and August 18, daily cost savings **averaged around $135K** diff --git a/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.test.ts b/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.test.ts new file mode 100644 index 0000000000000..a0ba324bc55d6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AI_VALUE_REPORT_LOCATOR } from '@kbn/deeplinks-analytics'; +import { AIValueReportLocatorDefinition, parseLocationState } from './locator'; +import { AI_VALUE_PATH, APP_UI_ID } from '../../constants'; + +describe('AIValueReportLocatorDefinition', () => { + const locator = new AIValueReportLocatorDefinition(); + + const validParams = { + timeRange: { + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + }, + insight: 'Some valuable insight!', + reportDataHash: 'abc123', + }; + + test('id should match constant', () => { + expect(locator.id).toBe(AI_VALUE_REPORT_LOCATOR); + }); + + test('getLocation returns correct location object', async () => { + const result = await locator.getLocation(validParams); + + expect(result).toEqual({ + app: APP_UI_ID, + path: AI_VALUE_PATH, + state: validParams, + }); + }); +}); + +describe('parseLocationState', () => { + const validState = { + timeRange: { + from: '2024-01-01T00:00:00Z', + to: '2024-01-01T00:00:00Z', + }, + insight: 'Some valuable insight!', + reportDataHash: 'hash123', + }; + + it('returns parsed state when valid', () => { + const result = parseLocationState(validState); + expect(result).toEqual(validState); + }); + + it('strips unknown fields but preserves valid ones', () => { + const stateWithExtras = { + ...validState, + extraField: 'foo', + anotherOne: 42, + }; + + const result = parseLocationState(stateWithExtras); + + expect(result).toEqual(stateWithExtras); + }); + + it('returns undefined for invalid state (missing fields)', () => { + const invalid = { + insight: 'missing timeRange and hash', + }; + + const result = parseLocationState(invalid); + expect(result).toBeUndefined(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.ts b/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.ts new file mode 100644 index 0000000000000..7b46dc3802a19 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/locators/ai_value_report/locator.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; +import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import { AI_VALUE_REPORT_LOCATOR } from '@kbn/deeplinks-analytics'; +import { AI_VALUE_PATH, APP_UI_ID } from '../../constants'; + +const AIValueReportParamsSchema = z.object({ + timeRange: z.object({ + to: z.string().nonempty(), + from: z.string().nonempty(), + }), + insight: z.string().nonempty(), + reportDataHash: z.string().nonempty(), +}); + +export type AIValueReportParams = z.infer; + +export type ForwardedAIValueReportState = AIValueReportParams; + +export type AIValueReportLocator = LocatorPublic; + +export class AIValueReportLocatorDefinition implements LocatorDefinition { + public readonly id = AI_VALUE_REPORT_LOCATOR; + + public readonly getLocation = async (params: AIValueReportParams) => { + return { + app: APP_UI_ID, + path: AI_VALUE_PATH, + state: params, + }; + }; +} + +export const parseLocationState = (state: unknown): ForwardedAIValueReportState | undefined => { + const result = AIValueReportParamsSchema.passthrough().safeParse(state); + if (result.error) { + // This will cause the page to fallback to rendering normally + return undefined; + } + + return result.data; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc index d0cac06a6d23d..914ff5ddfb5eb 100644 --- a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc +++ b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc @@ -63,6 +63,7 @@ "productDocBase", "telemetry", "elasticAssistantSharedState", + "share" ], "optionalPlugins": [ "encryptedSavedObjects", diff --git a/x-pack/solutions/security/plugins/security_solution/moon.yml b/x-pack/solutions/security/plugins/security_solution/moon.yml index c75ee53edda42..e4e4b08eb7419 100644 --- a/x-pack/solutions/security/plugins/security_solution/moon.yml +++ b/x-pack/solutions/security/plugins/security_solution/moon.yml @@ -261,6 +261,7 @@ dependsOn: - '@kbn/response-ops-rule-form' - '@kbn/core-lifecycle-browser-mocks' - '@kbn/connector-schemas' + - '@kbn/deeplinks-analytics' - '@kbn/tour-queue' tags: - plugin diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts index 4450e3ac35bb5..1ceb9e33069e6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/security_side_nav/categories.ts @@ -47,5 +47,9 @@ export const getNavCategories = ( type: LinkCategoryType.separator, linkIds: [SecurityPageName.siemReadiness], }, + { + type: LinkCategoryType.separator, + linkIds: [SecurityPageName.aiValue], + }, ]; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.test.tsx index 688d394f7f4b1..6f60e94541726 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/search_bar/index.test.tsx @@ -363,6 +363,10 @@ describe('SearchBarComponent', () => { timerange: mockGlobalState.inputs.timeline.timerange, linkTo: mockGlobalState.inputs.timeline.linkTo, }, + valueReport: { + timerange: mockGlobalState.inputs.valueReport.timerange, + linkTo: mockGlobalState.inputs.valueReport.linkTo, + }, }, ]); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.test.tsx index 0f107e27c24b5..f200e4250d1a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.test.tsx @@ -11,7 +11,6 @@ import * as redux from 'react-redux'; import * as experimentalFeatures from '../use_experimental_features'; import * as globalQueryString from '../../utils/global_query_string'; import { TestProviders } from '../../mock'; -import { useKibana } from '../../lib/kibana'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -27,12 +26,6 @@ describe('useInitTimerangeFromUrlParam', () => { jest.clearAllMocks(); (redux.useDispatch as jest.Mock).mockReturnValue(dispatch); (experimentalFeatures.useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); - (useKibana as jest.Mock).mockReturnValue({ - services: { - serverless: {}, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); }); it('should call useInitializeUrlParam with correct params', () => { @@ -45,20 +38,7 @@ describe('useInitTimerangeFromUrlParam', () => { ); }); - it('should call dispatch 2 times on init url params when not serverless', () => { - (useKibana as jest.Mock).mockReturnValue({ - services: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - renderHook(() => useInitTimerangeFromUrlParam(), { - wrapper: TestProviders, - }); - const callback = (globalQueryString.useInitializeUrlParam as jest.Mock).mock.calls[0][1]; - callback({ valueReport: { timerange: { kind: 'absolute' } } }); - expect(dispatch).toHaveBeenCalledTimes(2); - }); - - it('should call dispatch 3 times on init url params when serverless and valueReport exists', () => { + it('should call dispatch 3 times on init url params when valueReport exists', () => { renderHook(() => useInitTimerangeFromUrlParam(), { wrapper: TestProviders, }); @@ -67,7 +47,7 @@ describe('useInitTimerangeFromUrlParam', () => { expect(dispatch).toHaveBeenCalledTimes(3); }); - it('should call dispatch 6 times on init url params when serverless, valueReport exists, and isSocTrendsEnabled=true', () => { + it('should call dispatch 6 times on init url params when valueReport exists, and isSocTrendsEnabled=true', () => { (experimentalFeatures.useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); renderHook(() => useInitTimerangeFromUrlParam(), { wrapper: TestProviders, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.ts index 2c8a3ad725ee7..0b23dff666b4b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_init_timerange_url_params.ts @@ -9,7 +9,6 @@ import { useCallback } from 'react'; import { get, isEmpty } from 'lodash/fp'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'redux'; -import { useKibana } from '../../lib/kibana'; import { useIsExperimentalFeatureEnabled } from '../use_experimental_features'; import type { TimeRangeKinds } from '../../store/inputs/constants'; import type { @@ -28,18 +27,11 @@ import { InputsModelId } from '../../store/inputs/constants'; export const useInitTimerangeFromUrlParam = () => { const dispatch = useDispatch(); const isSocTrendsEnabled = useIsExperimentalFeatureEnabled('socTrendsEnabled'); - const { serverless } = useKibana().services; - // only on serverless - const isValueReportEnabled = !!serverless; + const onInitialize = useCallback( (initialState: UrlInputsModel | null) => - initializeTimerangeFromUrlParam( - initialState, - dispatch, - isSocTrendsEnabled, - isValueReportEnabled - ), - [dispatch, isSocTrendsEnabled, isValueReportEnabled] + initializeTimerangeFromUrlParam(initialState, dispatch, isSocTrendsEnabled), + [dispatch, isSocTrendsEnabled] ); useInitializeUrlParam(URL_PARAM_KEY.timerange, onInitialize); @@ -48,8 +40,7 @@ export const useInitTimerangeFromUrlParam = () => { const initializeTimerangeFromUrlParam = ( initialState: UrlInputsModel | null, dispatch: Dispatch, - isSocTrendsEnabled: boolean, - isValueReportEnabled: boolean + isSocTrendsEnabled: boolean ) => { if (initialState != null) { const globalLinkTo: LinkTo = { linkTo: get('global.linkTo', initialState) }; @@ -188,7 +179,7 @@ const initializeTimerangeFromUrlParam = ( ); } } - if (valueReportType && isValueReportEnabled) { + if (valueReportType) { if (valueReportType === 'absolute') { const absoluteRange = normalizeTimeRange( get('valueReport.timerange', initialState) diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_sync_timerange_url_param.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_sync_timerange_url_param.ts index dbb4b343e0772..9757673c7884c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_sync_timerange_url_param.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/search_bar/use_sync_timerange_url_param.ts @@ -6,7 +6,6 @@ */ import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { useKibana } from '../../lib/kibana'; import { useIsExperimentalFeatureEnabled } from '../use_experimental_features'; import type { UrlInputsModel } from '../../store/inputs/model'; import { inputsSelectors } from '../../store/inputs'; @@ -18,9 +17,6 @@ export const useSyncTimerangeUrlParam = () => { const getInputSelector = useMemo(() => inputsSelectors.inputsSelector(), []); const inputState = useSelector(getInputSelector); const isSocTrendsEnabled = useIsExperimentalFeatureEnabled('socTrendsEnabled'); - const { serverless } = useKibana().services; - // only on serverless - const isValueReportEnabled = !!serverless; const { linkTo: globalLinkTo, timerange: globalTimerange } = inputState.global; const { linkTo: timelineLinkTo, timerange: timelineTimerange } = inputState.timeline; @@ -39,17 +35,14 @@ export const useSyncTimerangeUrlParam = () => { }, [inputState.socTrends, isSocTrendsEnabled]); const valueReportUrlParams = useMemo(() => { - if (isValueReportEnabled && inputState.valueReport) { - const { linkTo: valueReportLinkTo, timerange: valueReportTimerange } = inputState.valueReport; - return { - valueReport: { - [URL_PARAM_KEY.timerange]: valueReportTimerange, - linkTo: valueReportLinkTo, - }, - }; - } - return {}; - }, [inputState.valueReport, isValueReportEnabled]); + const { linkTo: valueReportLinkTo, timerange: valueReportTimerange } = inputState.valueReport; + return { + valueReport: { + [URL_PARAM_KEY.timerange]: valueReportTimerange, + linkTo: valueReportLinkTo, + }, + }; + }, [inputState.valueReport]); useEffect(() => { updateTimerangeUrlParam({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/services.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/services.ts index 4a50efe98910a..9dcfef20629fa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/services.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/kibana/services.ts @@ -9,7 +9,7 @@ import type { CoreStart } from '@kbn/core/public'; import type { StartPlugins } from '../../../types'; type GlobalServices = Pick & - Pick; + Pick; /** * This class is a singleton that holds references to core Kibana services. @@ -46,6 +46,7 @@ export class KibanaServices { notifications, expressions, savedSearch, + share, }: GlobalServices & { kibanaBranch: string; kibanaVersion: string; @@ -61,6 +62,7 @@ export class KibanaServices { notifications, expressions, savedSearch, + share, }; this.kibanaBranch = kibanaBranch; this.kibanaVersion = kibanaVersion; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/index.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/index.ts new file mode 100644 index 0000000000000..0e53804fecd23 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AIValueReportTelemetryEvent } from './types'; +import { AIValueReportEventTypes } from './types'; + +export const AIValueReportExportExecutionEvent: AIValueReportTelemetryEvent = { + eventType: AIValueReportEventTypes.AIValueReportExportExecution, + schema: {}, +}; + +export const aiValueReportExportErrorEvent: AIValueReportTelemetryEvent = { + eventType: AIValueReportEventTypes.AIValueReportExportError, + schema: { + errorMessage: { + type: 'text', + _meta: { + description: 'The error message that occurs while exporting the AI Value Report', + optional: false, + }, + }, + isExportMode: { + type: 'boolean', + _meta: { + description: 'Flag indicating if the error occurs in export mode', + optional: false, + }, + }, + }, +}; + +export const aiValueReportExportInsightVerifiedEvent: AIValueReportTelemetryEvent = { + eventType: AIValueReportEventTypes.AIValueReportExportInsightVerified, + schema: { + shouldRegenerate: { + type: 'boolean', + _meta: { + description: + 'Flag indicating if the insight received as parameter in the export should be regenerated', + optional: false, + }, + }, + }, +}; + +export const aiValueReportTelemetryEvents = [ + aiValueReportExportErrorEvent, + AIValueReportExportExecutionEvent, + aiValueReportExportInsightVerifiedEvent, +]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/types.ts new file mode 100644 index 0000000000000..2c48bef4a3407 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/ai_value_report/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { RootSchema } from '@kbn/core/public'; + +export enum AIValueReportEventTypes { + AIValueReportExportExecution = 'AI Value Report Export Execution', + AIValueReportExportError = 'AI Value Report Export Error', + AIValueReportExportInsightVerified = 'AI Value Report Export Insight Regenerated', +} + +interface ReportAIValueReportExportErrorParams { + errorMessage: string; + isExportMode: boolean; +} + +interface ReportAIValueReportExportInsightVerifiedParams { + shouldRegenerate: boolean; +} + +export interface AIValueReportTelemetryEventsMap { + [AIValueReportEventTypes.AIValueReportExportExecution]: {}; + [AIValueReportEventTypes.AIValueReportExportError]: ReportAIValueReportExportErrorParams; + [AIValueReportEventTypes.AIValueReportExportInsightVerified]: ReportAIValueReportExportInsightVerifiedParams; +} + +export interface AIValueReportTelemetryEvent { + eventType: AIValueReportEventTypes; + schema: RootSchema; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index a8ff691da9dbc..c1a4eaa790a21 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -17,6 +17,7 @@ import { onboardingHubTelemetryEvents } from './onboarding'; import { previewRuleTelemetryEvents } from './preview_rule'; import { siemMigrationsTelemetryEvents } from './siem_migrations'; import { ruleUpgradeTelemetryEvents } from './rule_upgrade'; +import { aiValueReportTelemetryEvents } from './ai_value_report'; export const telemetryEvents = [ ...alertsTelemetryEvents, @@ -32,4 +33,5 @@ export const telemetryEvents = [ ...notesTelemetryEvents, ...appTelemetryEvents, ...siemMigrationsTelemetryEvents, + ...aiValueReportTelemetryEvents, ]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts index 8710a50c596cb..10ca12eabef2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -51,6 +51,11 @@ import type { RuleUpgradeTelemetryEventsMap, } from './events/rule_upgrade/types'; +import type { + AIValueReportEventTypes, + AIValueReportTelemetryEventsMap, +} from './events/ai_value_report/types'; + export * from './events/app/types'; export * from './events/alerts_grouping/types'; export * from './events/data_quality/types'; @@ -95,6 +100,8 @@ export type TelemetryEventTypeData = T extends Al ? SiemMigrationsTelemetryEventsMap[T] : T extends RuleUpgradeEventTypes ? RuleUpgradeTelemetryEventsMap[T] + : T extends AIValueReportEventTypes + ? AIValueReportTelemetryEventsMap[T] : never; export type TelemetryEventTypes = @@ -111,4 +118,5 @@ export type TelemetryEventTypes = | AppEventTypes | SiemMigrationsRuleEventTypes | SiemMigrationsDashboardEventTypes - | RuleUpgradeEventTypes; + | RuleUpgradeEventTypes + | AIValueReportEventTypes; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/store/inputs/model.ts b/x-pack/solutions/security/plugins/security_solution/public/common/store/inputs/model.ts index 5342c4afc79d5..239e92230343b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/store/inputs/model.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/store/inputs/model.ts @@ -102,8 +102,7 @@ export interface UrlInputsModelInputs { export interface UrlInputsModel { global: UrlInputsModelInputs; timeline: UrlInputsModelInputs; - // serverless only - valueReport?: UrlInputsModelInputs; + valueReport: UrlInputsModelInputs; // TODO: remove ? when isSocTrendsEnabled feature flag is removed socTrends?: UrlInputsModelInputs; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index 3926fcdf686a3..ecafce8e586db 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -66,6 +66,7 @@ import { getExternalReferenceAttachmentEndpointRegular } from './cases/attachmen import { isSecuritySolutionAccessible } from './helpers_access'; import { generateAttachmentType } from './threat_intelligence/modules/cases/utils/attachments'; import { defaultDeepLinks } from './app/links/default_deep_links'; +import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_report/locator'; export class Plugin implements IPlugin { private config: SecuritySolutionUiConfigType; @@ -104,8 +105,11 @@ export class Plugin implements IPlugin { diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_filtering_metric.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_filtering_metric.tsx index 609c167a64c4b..c707c480930b0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_filtering_metric.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_filtering_metric.tsx @@ -17,6 +17,7 @@ import { VisualizationContextMenuActions } from '../../../common/components/visu import { getAlertFilteringMetricLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/ai/alert_filtering_metric'; import * as i18n from './translations'; import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { attackAlertIds: string[]; @@ -34,6 +35,8 @@ const AlertFilteringMetricComponent: React.FC = ({ const { euiTheme: { colors }, } = useEuiTheme(); + const aiValueExportContext = useAIValueExportContext(); + const isExportMode = aiValueExportContext?.isExportMode === true; const extraVisualizationOptions = useMemo( () => ({ filters: getExcludeAlertsFilters(attackAlertIds), @@ -53,7 +56,10 @@ const AlertFilteringMetricComponent: React.FC = ({ height: 100% !important; } .echMetricText__icon .euiIcon { - fill: ${colors.vis.euiColorVis4}; + ${isExportMode ? 'display: none;' : `fill: ${colors.vis.euiColorVis4};`} + } + .echMetricText__valueBlock { + grid-row-start: 3 !important; } .echMetricText { padding: 8px 16px 60px; diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_processing_key_insight.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_processing_key_insight.tsx index 5e8ff4c484a15..73c4aa6402858 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_processing_key_insight.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/alert_processing_key_insight.tsx @@ -76,6 +76,7 @@ export const AlertProcessingKeyInsight: React.FC = ({ isLoading, valueMet css={css` line-height: 1.6em; `} + color="subdued" >
  • - + {percentInfo.note} {` `} {i18n.TIME_RANGE(timeRange)} diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.test.tsx index ed349018488ea..1d3a74611f187 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.test.tsx @@ -21,6 +21,7 @@ import { MessageRole } from '@kbn/inference-common'; import type { VisualizationTablesWithMeta } from '../../../common/components/visualization_actions/types'; import type { StartServices } from '../../../types'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; jest.mock('../../../common/lib/kibana', () => ({ useKibana: jest.fn(), @@ -51,9 +52,15 @@ jest.mock('../../../common/hooks/use_ai_connectors', () => ({ useAIConnectors: jest.fn(), })); +jest.mock('../../providers/ai_value/export_provider', () => ({ + useAIValueExportContext: jest.fn(), +})); + const mockUseKibana = useKibana as jest.Mock; const mockLicenseService = licenseService as jest.Mocked; const mockUseAssistantAvailability = useAssistantAvailability as jest.Mock; +const mockUseAIValueExportContext = useAIValueExportContext as jest.Mock; +const mockSetInsightInExportContext = jest.fn(); const mockUseFindCostSavingsPrompts = useFindCostSavingsPrompts as jest.MockedFunction< typeof useFindCostSavingsPrompts >; @@ -115,6 +122,9 @@ describe('CostSavingsKeyInsight', () => { }); beforeEach(() => { jest.clearAllMocks(); + mockUseAIValueExportContext.mockReturnValue({ + setInsight: mockSetInsightInExportContext, + }); mockUseKibana.mockReturnValue(createMockKibanaServices()); mockLicenseService.isEnterprise.mockReturnValue(true); @@ -168,6 +178,15 @@ describe('CostSavingsKeyInsight', () => { toasts: expect.any(Object), }, }); + expect(mockUseAIValueExportContext).toHaveBeenCalled(); + }); + }); + + it("sets the insight in the AI Value Export context after it's fetched", async () => { + render(, { wrapper }); + + await waitFor(() => { + expect(mockSetInsightInExportContext).toHaveBeenCalledWith('Test result'); }); }); @@ -286,4 +305,87 @@ describe('CostSavingsKeyInsight', () => { expect(mockChatComplete).toHaveBeenCalledTimes(2); }); }); + + describe('export mode', () => { + const baseMockedContext = { + forwardedState: { + insight: chatCompleteResult, + }, + setInsight: mockSetInsightInExportContext, + isInsightVerified: false, + shouldRegenerateInsight: undefined, + }; + let rerender: (ui: React.ReactNode) => void; + beforeEach(() => { + mockUseAIValueExportContext.mockReturnValue(baseMockedContext); + const renderResult = render(, { wrapper }); + rerender = renderResult.rerender; + }); + + it('should show the loading component when the insight has not been verified', () => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + describe('when the insight in the forwarded state can be used', () => { + beforeEach(() => { + mockUseAIValueExportContext.mockReturnValue({ + ...baseMockedContext, + isInsightVerified: true, + shouldRegenerateInsight: false, + }); + + rerender(); + }); + + it('should not attempt to generate the insight', () => { + expect(mockUseKibana).not.toHaveBeenCalled(); + expect(mockUseFindCostSavingsPrompts).not.toHaveBeenCalled(); + expect(mockLicenseService.isEnterprise).not.toHaveBeenCalled(); + expect(mockUseAssistantAvailability).not.toHaveBeenCalled(); + }); + + it('should display the insight', () => { + expect(screen.getByText(chatCompleteResult)).toBeInTheDocument(); + }); + }); + + describe('when the insight should be regenerated', () => { + beforeEach(() => { + mockUseAIValueExportContext.mockReturnValue({ + ...baseMockedContext, + isInsightVerified: true, + shouldRegenerateInsight: true, + }); + + rerender(); + }); + + it('should attempt to generate the insight', async () => { + await waitFor(() => { + expect(screen.getByTestId('alertProcessingKeyInsightsContainer')).toBeInTheDocument(); + expect(screen.getByTestId('alertProcessingKeyInsightsGreetingGroup')).toBeInTheDocument(); + expect(screen.getByTestId('alertProcessingKeyInsightsLogo')).toBeInTheDocument(); + expect(screen.getByTestId('alertProcessingKeyInsightsGreeting')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(mockUseKibana).toHaveBeenCalled(); + expect(mockLicenseService.isEnterprise).toHaveBeenCalled(); + expect(mockUseAssistantAvailability).toHaveBeenCalled(); + expect(mockUseFindCostSavingsPrompts).toHaveBeenCalledWith({ + context: { + isAssistantEnabled: true, + httpFetch: expect.any(Function), + toasts: expect.any(Object), + }, + }); + expect(mockUseAIValueExportContext).toHaveBeenCalled(); + }); + }); + + it('should display the insight', async () => { + await waitFor(() => { + expect(screen.getByText(chatCompleteResult)).toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.tsx index e221c4f6eec5e..c72aad1fcf164 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_key_insight.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -25,27 +25,26 @@ import { licenseService } from '../../../common/hooks/use_license'; import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; import { useFindCostSavingsPrompts } from '../../hooks/use_find_cost_savings_prompts'; import { useDefaultAIConnectorId } from '../../../common/hooks/use_default_ai_connector_id'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { isLoading: boolean; lensResponse: VisualizationTablesWithMeta | null; } -export const CostSavingsKeyInsight: React.FC = ({ isLoading, lensResponse }) => { - const { - euiTheme: { size }, - } = useEuiTheme(); - +const CostSavingsKeyInsightLoader: React.FC = ({ isLoading, lensResponse }) => { const { http, notifications, inference } = useKibana().services; const [insightResult, setInsightResult] = useState(''); const { defaultConnectorId } = useDefaultAIConnectorId(); + const exportContext = useAIValueExportContext(); + const setInsightForExportContext = exportContext?.setInsight; - const hasEnterpriseLicence = licenseService.isEnterprise(); + const hasEnterpriseLicense = licenseService.isEnterprise(); const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability(); const prompts = useFindCostSavingsPrompts({ context: { isAssistantEnabled: - hasEnterpriseLicence && (isAssistantEnabled ?? false) && (hasAssistantPrivilege ?? false), + hasEnterpriseLicense && (isAssistantEnabled ?? false) && (hasAssistantPrivilege ?? false), httpFetch: http.fetch, toasts: notifications.toasts, }, @@ -73,6 +72,23 @@ export const CostSavingsKeyInsight: React.FC = ({ isLoading, lensResponse useEffect(() => { if (!lensResponse) setInsightResult(''); }, [lensResponse]); + useEffect(() => { + if (insightResult.length) { + setInsightForExportContext?.(insightResult); + } + }, [setInsightForExportContext, insightResult]); + return ; +}; + +interface ViewProps { + insight: string; + isLoading: boolean; +} + +const CostSavingsKeyInsightView: React.FC = ({ insight, isLoading }) => { + const { + euiTheme: { size, colors }, + } = useEuiTheme(); return (
    = ({ isLoading, lensResponse border-radius: ${size.s}; padding: ${size.base}; min-height: 200px; + + .keyInsightMarkdown { + color: ${colors.textSubdued}; + } `} > @@ -106,8 +126,8 @@ export const CostSavingsKeyInsight: React.FC = ({ isLoading, lensResponse - {insightResult && !isLoading ? ( - + {insight && !isLoading ? ( + ) : ( )} @@ -116,6 +136,32 @@ export const CostSavingsKeyInsight: React.FC = ({ isLoading, lensResponse ); }; +export const CostSavingsKeyInsight: React.FC = (props) => { + const exportContext = useAIValueExportContext(); + const Loading = useMemo(() => , []); + + if (props.isLoading) { + return Loading; + } + + if (exportContext?.forwardedState?.insight) { + const { + forwardedState: { insight }, + isInsightVerified, + shouldRegenerateInsight, + } = exportContext; + if (!isInsightVerified) { + return Loading; + } + + if (shouldRegenerateInsight === false) { + return ; + } + } + + return ; +}; + const getPrompt = (result: string, prompts: { part1: string; part2: string }) => { const prompt = `${prompts.part1} diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.test.tsx index 18959399f6528..adb690809cb12 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.test.tsx @@ -10,6 +10,8 @@ import { render } from '@testing-library/react'; import { CostSavingsMetric } from './cost_savings_metric'; import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; import { useSignalIndexWithDefault } from '../../hooks/use_signal_index_with_default'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; +import { useMetricAnimation } from '../../hooks/use_metric_animation'; // Mock VisualizationEmbeddable jest.mock('../../../common/components/visualization_actions/visualization_embeddable', () => ({ @@ -20,6 +22,17 @@ jest.mock('../../hooks/use_signal_index_with_default', () => ({ useSignalIndexWithDefault: jest.fn(), })); +jest.mock('../../providers/ai_value/export_provider', () => ({ + useAIValueExportContext: jest.fn(), +})); + +jest.mock('../../hooks/use_metric_animation', () => ({ + useMetricAnimation: jest.fn(), +})); + +const useAIValueExportContextMock = useAIValueExportContext as jest.Mock; +const useMetricAnimationMock = useMetricAnimation as jest.Mock; + const defaultProps = { from: '2023-01-01T00:00:00.000Z', to: '2023-01-31T23:59:59.999Z', @@ -56,4 +69,28 @@ describe('CostSavingsMetric', () => { render(); expect(mockUseSignalIndexWithDefault).toHaveBeenCalled(); }); + + it('calls useAIValueExportContext hook', () => { + render(); + expect(useAIValueExportContextMock).toHaveBeenCalled(); + }); + + it('calls useMetricAnimationMock hook', () => { + render(); + expect(useMetricAnimationMock).toHaveBeenCalled(); + }); + + describe('export mode', () => { + beforeEach(() => { + // Force it into export mode + useAIValueExportContextMock.mockReturnValue({ + isExportMode: true, + }); + }); + + it('should not attempt to animate the component', () => { + render(); + expect(useMetricAnimationMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.tsx index 42a9c4cf48b8b..00bacc992adc1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/cost_savings_metric.tsx @@ -19,6 +19,7 @@ import { VisualizationEmbeddable } from '../../../common/components/visualizatio import { getCostSavingsMetricLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/ai/cost_savings_metric'; import { useMetricAnimation } from '../../hooks/use_metric_animation'; import { useSignalIndexWithDefault } from '../../hooks/use_signal_index_with_default'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { from: string; @@ -28,6 +29,16 @@ interface Props { } const ID = 'CostSavingsMetricQuery'; +const WithMetricAnimation = ({ children }: { children: React.ReactNode }) => { + // Apply animation to the metric value + useMetricAnimation({ + animationDurationMs: 1500, + selector: '.echMetricText__value', + }); + + return <>{children}; +}; + /** * Renders a Lens embeddable metric visualization showing estimated cost savings * based on the number of AI filtered alerts, minutes saved per alert, @@ -44,11 +55,9 @@ const CostSavingsMetricComponent: React.FC = ({ euiTheme: { colors }, } = useEuiTheme(); - // Apply animation to the metric value - useMetricAnimation({ - animationDurationMs: 1500, - selector: '.echMetricText__value', - }); + const exportContext = useAIValueExportContext(); + const isExportMode = exportContext?.isExportMode === true; + const signalIndexName = useSignalIndexWithDefault(); const timerange = useMemo(() => ({ from, to }), [from, to]); const getLensAttributes = useCallback( @@ -63,48 +72,57 @@ const CostSavingsMetricComponent: React.FC = ({ [analystHourlyRate, colors.backgroundBaseSuccess, minutesPerAlert, signalIndexName] ); - return ( -
    * { - height: 100% !important; - } - .echMetricText__icon .euiIcon { - fill: ${colors.success}; - } - .echMetricText { - padding: 8px 16px 60px; - } - p.echMetricText__value { - color: ${colors.success}; - font-size: 48px !important; - padding: 10px 0; - } - .euiPanel, - .embPanel__hoverActions > span { - background: ${colors.backgroundBaseSuccess}; - } - .embPanel__hoverActionsAnchor { - --internalBorderStyle: 1px solid ${colors.success}!important; - } - `} - > - -
    + const Visualization = useMemo( + () => ( +
    * { + height: 100% !important; + } + .echMetricText__icon .euiIcon { + fill: ${colors.success}; + } + .echMetricText { + padding: 8px 16px 60px; + } + p.echMetricText__value { + color: ${colors.success}; + font-size: 48px !important; + padding: 10px 0; + } + .euiPanel, + .embPanel__hoverActions > span { + background: ${colors.backgroundBaseSuccess}; + } + .embPanel__hoverActionsAnchor { + --internalBorderStyle: 1px solid ${colors.success}!important; + } + `} + > + +
    + ), + [getLensAttributes, timerange, colors.success, colors.backgroundBaseSuccess] ); + + if (isExportMode) { + return Visualization; + } + + return {Visualization}; }; export const CostSavingsMetric = React.memo(CostSavingsMetricComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/executive_summary.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/executive_summary.tsx index f7afc5b78fc0f..7d6b179b72a40 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/executive_summary.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/executive_summary.tsx @@ -28,6 +28,7 @@ import type { ValueMetrics } from './metrics'; import { TimeSaved } from './time_saved'; import { FilteringRate } from './filtering_rate'; import { ThreatsDetected } from './threats_detected'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { attackAlertIds: string[]; @@ -69,6 +70,8 @@ export const ExecutiveSummary: React.FC = ({ }, [updateTitle] ); + const aiValueExportContext = useAIValueExportContext(); + const isExportMode = aiValueExportContext?.isExportMode === true; const subtitle = useMemo(() => { const fromDate = new Date(from); const toDate = new Date(to); @@ -105,6 +108,7 @@ export const ExecutiveSummary: React.FC = ({ `} > = ({ data-test-subj="executiveSummaryMainInfo" > - + {isLoading ? ( ) : hasAttackDiscoveries ? ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.test.tsx index 4654eab09ed11..f8c89ba61a4b4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.test.tsx @@ -19,6 +19,7 @@ import { AlertProcessing } from './alert_processing'; import { CostSavingsTrend } from './cost_savings_trend'; import { ValueReportSettings } from './value_report_settings'; import type { StartServices } from '../../../types'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; // Mock dependencies jest.mock('../../../common/lib/kibana', () => ({ @@ -45,8 +46,13 @@ jest.mock('./value_report_settings', () => ({ ValueReportSettings: jest.fn(() =>
    ), })); +jest.mock('../../providers/ai_value/export_provider', () => ({ + useAIValueExportContext: jest.fn(), +})); + const mockUseKibana = useKibana as jest.Mock; const mockUseValueMetrics = useValueMetrics as jest.MockedFunction; +const useAIValueExportContextMock = useAIValueExportContext as jest.Mock; const defaultProps = { setHasAttackDiscoveries: jest.fn(), @@ -91,6 +97,7 @@ describe('AIValueMetrics', () => { beforeEach(() => { jest.clearAllMocks(); + useAIValueExportContextMock.mockReturnValue(undefined); mockUseKibana.mockReturnValue(createMockKibanaServices()); mockUseValueMetrics.mockReturnValue({ @@ -196,6 +203,43 @@ describe('AIValueMetrics', () => { }); }); + it('uses the specified timerange when exporting the report', () => { + const timeRange = { + to: '2025-11-18T13:18:59.691Z', + from: '2025-10-18T12:18:59.691Z', + }; + useAIValueExportContextMock.mockReturnValue({ + forwardedState: { + timeRange, + }, + }); + + render(); + + expect(mockUseValueMetrics).toHaveBeenCalledWith( + expect.objectContaining({ + ...timeRange, + }) + ); + }); + + it('should set the report input in the export context when the data is loaded', () => { + const setReportInputMock = jest.fn(); + useAIValueExportContextMock.mockReturnValue({ + setReportInput: setReportInputMock, + }); + + render(); + + expect(setReportInputMock).toHaveBeenCalledWith({ + attackAlertIds: ['alert-1', 'alert-2'], + analystHourlyRate: 50, + minutesPerAlert: 10, + valueMetrics: mockValueMetrics, + valueMetricsCompare: mockValueMetricsCompare, + }); + }); + it('handles different uiSettings values correctly', () => { mockUseKibana.mockReturnValue( createMockKibanaServices({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.tsx index 38c1eda95c29d..2c6f23604ee8e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/index.tsx @@ -18,6 +18,7 @@ import { ExecutiveSummary } from './executive_summary'; import { AlertProcessing } from './alert_processing'; import { useValueMetrics } from '../../hooks/use_value_metrics'; import { useKibana } from '../../../common/lib/kibana'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { setHasAttackDiscoveries: React.Dispatch; @@ -25,8 +26,25 @@ interface Props { to: string; } -export const AIValueMetrics: React.FC = ({ setHasAttackDiscoveries, from, to }) => { +export const AIValueMetrics: React.FC = (props) => { + const { setHasAttackDiscoveries } = props; const { uiSettings } = useKibana().services; + const exportContext = useAIValueExportContext(); + const setReportInputForExportContext = exportContext?.setReportInput; + + const { from, to } = useMemo(() => { + if (exportContext?.forwardedState) { + const { timeRange } = exportContext.forwardedState; + return { + from: timeRange.from, + to: timeRange.to, + }; + } + return { + from: props.from, + to: props.to, + }; + }, [props.from, props.to, exportContext?.forwardedState]); const { analystHourlyRate, minutesPerAlert } = useMemo( () => ({ @@ -35,9 +53,7 @@ export const AIValueMetrics: React.FC = ({ setHasAttackDiscoveries, from, }), [uiSettings] ); - const { - euiTheme: { colors }, - } = useEuiTheme(); + const { attackAlertIds, isLoading, valueMetrics, valueMetricsCompare } = useValueMetrics({ from, to, @@ -50,10 +66,35 @@ export const AIValueMetrics: React.FC = ({ setHasAttackDiscoveries, from, [valueMetrics.attackDiscoveryCount] ); + useEffect(() => { + if (isLoading || !setReportInputForExportContext) { + return; + } + setReportInputForExportContext({ + attackAlertIds, + valueMetrics, + valueMetricsCompare, + analystHourlyRate, + minutesPerAlert, + }); + }, [ + isLoading, + attackAlertIds, + valueMetrics, + valueMetricsCompare, + analystHourlyRate, + minutesPerAlert, + setReportInputForExportContext, + ]); + useEffect(() => { setHasAttackDiscoveries(hasAttackDiscoveries); }, [hasAttackDiscoveries, setHasAttackDiscoveries]); + const { + euiTheme: { colors }, + } = useEuiTheme(); + return (
    = ({ from, to }) => { const { euiTheme: { colors }, } = useEuiTheme(); + const aiValueExportContext = useAIValueExportContext(); + const isExportMode = aiValueExportContext?.isExportMode === true; const spaceId = useSpaceId(); return ( @@ -34,7 +37,10 @@ const ThreatsDetectedMetricComponent: React.FC = ({ from, to }) => { height: 100% !important; } .echMetricText__icon .euiIcon { - fill: ${colors.vis.euiColorVis6}; + ${isExportMode ? 'display: none;' : `fill: ${colors.vis.euiColorVis6};`} + } + .echMetricText__valueBlock { + grid-row-start: 3 !important; } .echMetricText { padding: 8px 16px 60px; diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/time_saved_metric.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/time_saved_metric.tsx index 93e809babf364..34fcfd5cd2ed2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/time_saved_metric.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/time_saved_metric.tsx @@ -18,6 +18,7 @@ import { import { getTimeSavedMetricLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/ai/time_saved_metric'; import * as i18n from './translations'; import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { from: string; @@ -36,6 +37,8 @@ const TimeSavedMetricComponent: React.FC = ({ from, to, minutesPerAlert } } = useEuiTheme(); const timerange = useMemo(() => ({ from, to }), [from, to]); const signalIndexName = useSignalIndexWithDefault(); + const aiValueExportContext = useAIValueExportContext(); + const isExportMode = aiValueExportContext?.isExportMode === true; const getLensAttributes = useCallback( (args) => getTimeSavedMetricLensAttributes({ ...args, minutesPerAlert, signalIndexName }), @@ -50,7 +53,10 @@ const TimeSavedMetricComponent: React.FC = ({ from, to, minutesPerAlert } height: 100% !important; } .echMetricText__icon .euiIcon { - fill: ${colors.vis.euiColorVis2}; + ${isExportMode ? 'display: none;' : `fill: ${colors.vis.euiColorVis2};`} + } + .echMetricText__valueBlock { + grid-row-start: 3 !important; } .echMetricText { padding: 8px 16px 60px; diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/translations.ts index 72aea8873103f..30a626891a3bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/translations.ts @@ -207,6 +207,13 @@ export const CHANGE_RATE = i18n.translate('xpack.securitySolution.reports.aiValu defaultMessage: 'Change rate in advanced settings', }); +export const CHANGE_RATE_EXPORT_MODE = i18n.translate( + 'xpack.securitySolution.reports.aiValue.exportMode.changeRate', + { + defaultMessage: 'Value report rates configured in advanced settings.', + } +); + export const EDIT_TITLE = i18n.translate('xpack.securitySolution.reports.aiValue.editTitle', { defaultMessage: 'Edit title inline', }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/value_report_settings.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/value_report_settings.tsx index d0aeb35eb7790..239949cec0d52 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/value_report_settings.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/components/ai_value/value_report_settings.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiText, EuiLink, EuiIcon, useEuiTheme, EuiTitle, EuiSpacer } from '@elastic/eui'; import { useNavigation } from '@kbn/security-solution-navigation'; import { css } from '@emotion/react'; import * as i18n from './translations'; +import { useAIValueExportContext } from '../../providers/ai_value/export_provider'; interface Props { minutesPerAlert: number; @@ -26,6 +27,26 @@ const ValueReportSettingsComponent: React.FC = ({ minutesPerAlert, analys () => navigateTo({ appId: 'management', path: '/kibana/settings?query=defaultValueReport' }), [navigateTo] ); + const aiValueExportContext = useAIValueExportContext(); + const isExportMode = aiValueExportContext?.isExportMode === true; + const changeRateLink = useMemo(() => { + if (isExportMode) { + return {i18n.CHANGE_RATE_EXPORT_MODE}; + } + + return ( + + {i18n.CHANGE_RATE} + + + ); + }, [isExportMode, goToKibanaSettings]); return (
    = ({ minutesPerAlert, analys

    - {i18n.COST_CALCULATION({ minutesPerAlert, analystHourlyRate })}{' '} - - {i18n.CHANGE_RATE} - - + {i18n.COST_CALCULATION({ minutesPerAlert, analystHourlyRate })} {changeRateLink}

    {i18n.LEGAL_DISCLAIMER}

    diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.test.tsx new file mode 100644 index 0000000000000..911fc470c0805 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { noop } from 'lodash'; +import { useKibana } from '../../common/lib/kibana'; +import { useAIValueExportContext } from '../providers/ai_value/export_provider'; +import { useDownloadAIValueReport } from './use_download_ai_value_report'; + +jest.mock('../../common/lib/kibana', () => ({ useKibana: jest.fn() })); +const useKibanaMock = useKibana as jest.Mock; + +jest.mock('../providers/ai_value/export_provider', () => ({ useAIValueExportContext: jest.fn() })); +const useAIValueExportContextMock = useAIValueExportContext as jest.Mock; + +const shareServiceMock = { + toggleShareContextMenu: jest.fn(), +}; + +const buildForwardedStateMock = jest.fn(); + +const anchorElementMock = { someAnchorElementProp: 'baz' } as unknown as HTMLElement; + +const timeRange = { + to: '2025-11-18T13:18:59.691Z', + from: '2025-10-18T12:18:59.691Z', +}; + +const mockKibana = (share: typeof shareServiceMock | undefined, serverless: boolean) => + useKibanaMock.mockReturnValue({ + services: { + share, + serverless, + }, + }); + +type HookResult = ReturnType; +const TestComponent = ({ + anchorElement, + hookValueFn, +}: { + anchorElement: HTMLElement | null; + hookValueFn: (value: HookResult) => void; +}) => { + const hookValue = useDownloadAIValueReport({ + anchorElement, + timeRange, + }); + + hookValueFn(hookValue); + return <>; +}; + +describe('useDownloadAIValueReport', () => { + beforeEach(() => { + // We set all the conditions so that the report is enabled. + // Then we toggle each condition off in the subsequent describe statements as needed + mockKibana(shareServiceMock, false); + + useAIValueExportContextMock.mockReturnValue({ + buildForwardedState: buildForwardedStateMock, + }); + }); + let hookResult: HookResult = { isExportEnabled: true, toggleContextMenu: noop }; + const callHook = (anchorElement: HTMLElement | null) => { + render( + { + hookResult = value; + }} + /> + ); + }; + describe('when it is used in serverless', () => { + beforeEach(() => { + mockKibana(shareServiceMock, true); + callHook(anchorElementMock); + }); + it('should set isExportEnabled to false', () => { + expect(hookResult.isExportEnabled).toBe(false); + }); + + it('should not call shareService.toggleShareContextMenu', () => { + hookResult.toggleContextMenu(); + expect(shareServiceMock.toggleShareContextMenu).not.toHaveBeenCalled(); + }); + }); + + describe('when the anchor element is null', () => { + beforeEach(() => { + callHook(null); + }); + it('should set isExportEnabled to false', () => { + expect(hookResult.isExportEnabled).toBe(false); + }); + + it('should not call shareService.toggleShareContextMenu', () => { + hookResult.toggleContextMenu(); + expect(shareServiceMock.toggleShareContextMenu).not.toHaveBeenCalled(); + }); + }); + + describe('when the there is not a forwardedState', () => { + beforeEach(() => { + useAIValueExportContextMock.mockResolvedValue({}); + callHook(anchorElementMock); + }); + + it('should set isExportEnabled to false', () => { + expect(hookResult.isExportEnabled).toBe(false); + }); + it('should not call shareService.toggleShareContextMenu', () => { + hookResult.toggleContextMenu(); + expect(shareServiceMock.toggleShareContextMenu).not.toHaveBeenCalled(); + }); + }); + + describe('when the shareService is not available', () => { + beforeEach(() => { + mockKibana(undefined, false); + callHook(anchorElementMock); + }); + it('should set isExportEnabled to false', () => { + expect(hookResult.isExportEnabled).toBe(false); + }); + }); + + describe('when the export is enabled', () => { + const forwardedState = { timeRange }; + beforeEach(() => { + buildForwardedStateMock.mockReturnValue(forwardedState); + callHook(anchorElementMock); + }); + it('should set isExportEnabled to true', () => { + expect(hookResult.isExportEnabled).toBe(true); + }); + + it('should call shareService.toggleShareContextMenu with the right parameters', () => { + hookResult.toggleContextMenu(); + expect(shareServiceMock.toggleShareContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ + allowShortUrl: false, + anchorElement: anchorElementMock, + asExport: true, + isDirty: false, + objectType: 'ai_value_report', + objectTypeMeta: { + config: { + integration: { + export: { + pdfReports: {}, + }, + }, + }, + title: 'Download this report', + }, + sharingData: { + locatorParams: { + id: 'AI_VALUE_REPORT_LOCATOR', + params: forwardedState, + }, + title: 'AI Value Report', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.tsx new file mode 100644 index 0000000000000..329861e8c9980 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/hooks/use_download_ai_value_report.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { AI_VALUE_REPORT_LOCATOR } from '@kbn/deeplinks-analytics'; +import { useMemo } from 'react'; +import { useKibana } from '../../common/lib/kibana'; +import type { AIValueReportParams } from '../../../common/locators/ai_value_report/locator'; +import { useAIValueExportContext } from '../providers/ai_value/export_provider'; + +interface UseDownloadAIValueReportParams { + anchorElement: HTMLElement | null; + timeRange: AIValueReportParams['timeRange']; +} + +export const useDownloadAIValueReport = ({ + anchorElement, + timeRange, +}: UseDownloadAIValueReportParams) => { + const { share: shareService, serverless } = useKibana().services; + const isServerless = !!serverless; + const aiValueExportContext = useAIValueExportContext(); + const buildForwardedState = aiValueExportContext?.buildForwardedState; + + const forwardedState = useMemo(() => { + if (!buildForwardedState) { + return undefined; + } + + return buildForwardedState({ timeRange }); + }, [timeRange, buildForwardedState]); + + const isExportEnabled = + forwardedState !== undefined && + // exporting the report via the share service is only available in ESS + !isServerless && + anchorElement !== null && + shareService !== undefined; + + const toggleContextMenu = useMemo(() => { + if (!isExportEnabled) { + return () => {}; + } + + return () => { + shareService.toggleShareContextMenu({ + isDirty: false, + anchorElement, + allowShortUrl: false, + asExport: true, + objectType: 'ai_value_report', + objectTypeMeta: { + title: i18n.translate('xpack.securitySolution.reports.aiValue.shareModal.title', { + defaultMessage: 'Download this report', + }), + config: { + integration: { + export: { + pdfReports: {}, + }, + }, + }, + }, + sharingData: { + title: i18n.translate('xpack.securitySolution.reports.aiValue.pdfReportJobTitle', { + // TODO confirm what wording we want hre + defaultMessage: 'AI Value Report', + }), + locatorParams: { + id: AI_VALUE_REPORT_LOCATOR, + params: forwardedState, + }, + }, + }); + }; + }, [anchorElement, shareService, forwardedState, isExportEnabled]); + + return { + toggleContextMenu, + isExportEnabled, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/links.ts b/x-pack/solutions/security/plugins/security_solution/public/reports/links.ts index fe49506971a16..b42de0507ed05 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/links.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/links.ts @@ -33,6 +33,10 @@ export const aiValueLinks: LinkItem = { i18n.translate('xpack.securitySolution.appLinks.aiValue', { defaultMessage: 'AI Value', }), + i18n.translate('xpack.securitySolution.appLinks.valueReport', { + defaultMessage: 'Value report', + }), ], - globalNavPosition: 8, + globalNavPosition: 12, + hideTimeline: true, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx index 20eff4ffbbbd0..4b2701e9b2323 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.test.tsx @@ -18,6 +18,7 @@ import { useDataView } from '../../data_view_manager/hooks/use_data_view'; import { useHasSecurityCapability } from '../../helper_hooks'; import { TestProviders } from '../../common/mock/test_providers'; import * as i18n from './translations'; +import { useAIValueExportContext } from '../providers/ai_value/export_provider'; // Mock all dependencies before imports to avoid issues jest.mock('../../common/hooks/search_bar/use_sync_timerange_url_param', () => ({ @@ -44,6 +45,15 @@ jest.mock('../../helper_hooks', () => ({ useHasSecurityCapability: jest.fn(), })); +jest.mock('../providers/ai_value/export_provider', () => { + return { + AIValueExportProvider: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + useAIValueExportContext: jest.fn(), + }; +}); + // Mock docLinks for NoPrivileges component jest.mock('@kbn/doc-links', () => ({ getDocLinksMeta: jest.fn(() => ({})), @@ -127,6 +137,8 @@ const mockUseHasSecurityCapability = useHasSecurityCapability as jest.MockedFunc typeof useHasSecurityCapability >; +const mockUseAIValueExportContext = useAIValueExportContext as jest.Mock; + describe('AIValue', () => { beforeEach(() => { jest.clearAllMocks(); @@ -259,6 +271,16 @@ describe('AIValue', () => { const datePicker = screen.getByTestId('superDatePickerToggleQuickMenuButton'); expect(datePicker).toBeInTheDocument(); }); + + it('should be wrapped in a AIValueExportProvider', () => { + render( + + + + ); + + expect(screen.getByTestId('AIValueExportProvider')).toBeInTheDocument(); + }); }); describe('Hook Integration', () => { @@ -274,6 +296,7 @@ describe('AIValue', () => { expect(mockUseIsExperimentalFeatureEnabled).toHaveBeenCalledWith('newDataViewPickerEnabled'); expect(mockUseHasSecurityCapability).toHaveBeenCalledWith('socManagement'); expect(mockUseAlertsPrivileges).toHaveBeenCalled(); + expect(mockUseAIValueExportContext).toHaveBeenCalled(); }); }); @@ -314,4 +337,28 @@ describe('AIValue', () => { expect(screen.getByTestId('aiValueLoader')).toBeInTheDocument(); }); }); + + describe('export mode', () => { + beforeEach(() => { + mockUseAIValueExportContext.mockReturnValue({ + forwardedState: { + timeRange: { + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-31T23:59:59.999Z', + }, + }, + isExportMode: true, + }); + }); + + it('should not render the header of the page', () => { + render( + + + + ); + + expect(screen.queryByTestId('header-page')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.tsx index 47ef21c4a6ff4..b3a7a7f198840 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/pages/ai_value.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import type { DocLinks } from '@kbn/doc-links'; import { pick } from 'lodash/fp'; @@ -26,6 +26,12 @@ import { useDataView } from '../../data_view_manager/hooks/use_data_view'; import { PageLoader } from '../../common/components/page_loader'; import { inputsSelectors } from '../../common/store'; import { useHasSecurityCapability } from '../../helper_hooks'; +import { useKibana } from '../../common/lib/kibana'; +import { useDownloadAIValueReport } from '../hooks/use_download_ai_value_report'; +import { + AIValueExportProvider, + useAIValueExportContext, +} from '../providers/ai_value/export_provider'; /** * The dashboard includes key performance metrics such as: @@ -41,7 +47,9 @@ import { useHasSecurityCapability } from '../../helper_hooks'; * Data sources and calculation methods are transparent and documented for auditability. */ -const AIValueComponent = () => { +const BaseComponent = () => { + const exportContext = useAIValueExportContext(); + const isExportMode = exportContext?.isExportMode === true; const { loading: oldIsSourcererLoading } = useSourcererDataView(); const { from, to } = useDeepEqualSelector((state) => pick(['from', 'to'], inputsSelectors.valueReportTimeRangeSelector(state)) @@ -57,9 +65,51 @@ const AIValueComponent = () => { const [hasAttackDiscoveries, setHasAttackDiscoveries] = useState(false); const exportPDFRef = useRef<(() => void) | null>(null); + const { serverless } = useKibana().services; + const isServerless = !!serverless; + + const [exportButtonElement, setExportButtonElement] = useState< + HTMLAnchorElement | HTMLButtonElement | null + >(null); + // since we do not have a search bar in the AI Value page, we need to sync the timerange useSyncTimerangeUrlParam(); + const timeRange = useMemo(() => ({ to, from }), [to, from]); + + const { toggleContextMenu, isExportEnabled } = useDownloadAIValueReport({ + anchorElement: exportButtonElement, + timeRange, + }); + + const exportButton = useMemo( + () => + isServerless ? ( + exportPDFRef.current?.()} + size="s" + aria-label={EXPORT_REPORT} + > + {EXPORT_REPORT} + + ) : ( + + {EXPORT_REPORT} + + ), + [isServerless, isExportEnabled, toggleContextMenu] + ); + if (!hasSocManagementCapability) { return docLinks.siem.privileges} />; } @@ -75,30 +125,22 @@ const AIValueComponent = () => { max-width: 1440px; margin: 0 auto; `} + data-shared-items-container > - , - ...(hasAttackDiscoveries - ? [ - exportPDFRef.current?.()} - size="s" - > - {EXPORT_REPORT} - , - ] - : []), - ]} - /> + {!isExportMode && ( + , + ...(hasAttackDiscoveries ? [exportButton] : []), + ]} + /> + )} {isSourcererLoading ? ( ) : ( @@ -125,4 +167,10 @@ const AIValueComponent = () => { ); }; +const AIValueComponent = () => ( + + + +); + export const AIValue = React.memo(AIValueComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.test.tsx new file mode 100644 index 0000000000000..bf726a6a2514c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.test.tsx @@ -0,0 +1,290 @@ +/* + * 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 { act, render, waitFor, screen } from '@testing-library/react'; +import { useHistory } from 'react-router-dom'; +// Jest's global object does not have the crypto library. +// Therefore we import Node's. +// eslint-disable-next-line import/no-nodejs-modules +import { webcrypto } from 'crypto'; +import { AIValueExportProvider, useAIValueExportContext } from './export_provider'; +import { useKibana } from '../../../common/lib/kibana'; +import { AIValueReportEventTypes } from '../../../common/lib/telemetry/events/ai_value_report/types'; + +jest.mock('react-router', () => { + return { + useHistory: jest.fn(), + }; +}); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: jest.fn(), + }; +}); +const useHistoryMock = useHistory as jest.Mock; +const useKibanaMock = useKibana as jest.Mock; +const reportEventMock = jest.fn(); + +type ContextValue = ReturnType; + +const reportInput = { + attackAlertIds: ['0f6ca8ce-4ed5-4d71-88a6-fba3f87003f3'], + valueMetrics: { + attackDiscoveryCount: 14, + filteredAlerts: 4952, + filteredAlertsPerc: 99.73816717019133, + escalatedAlertsPerc: 0.2618328298086606, + hoursSaved: 662, + totalAlerts: 4965, + costSavings: 49650, + }, + valueMetricsCompare: { + attackDiscoveryCount: 0, + filteredAlerts: 5035, + filteredAlertsPerc: 100, + escalatedAlertsPerc: 0, + hoursSaved: 671.3333333333334, + totalAlerts: 5035, + costSavings: 50350, + }, + analystHourlyRate: 75, + minutesPerAlert: 8, +}; + +const reportDataHash = '856cbc3cfa41e8458e99f1b017bde73738a10d0cbbe3dea13a4960297957c645'; + +const timeRange = { + to: '2025-11-18T13:18:59.691Z', + from: '2025-10-18T12:18:59.691Z', +}; + +const TestComponent = ({ contextValueFn }: { contextValueFn: (context: ContextValue) => void }) => { + const context = useAIValueExportContext(); + contextValueFn(context); + return ( + <> + {context?.isInsightVerified ? 'Insight verified' : ''} + {context?.shouldRegenerateInsight ? 'Insight should regenerate' : ''} + + {context?.buildForwardedState({ timeRange }) ? 'buildForwardedState available' : ''} + + + ); +}; + +describe('AIValueExportContext', () => { + let context: ContextValue = null; + const doRender = async (locationState: unknown) => { + useHistoryMock.mockReturnValue({ + location: { + state: locationState, + }, + }); + render( + + { + context = contextValue; + }} + /> + + ); + }; + + const verifyInsight = () => waitFor(() => screen.getByText('Insight verified')); + const verifyInsightShouldRegenerate = () => + waitFor(() => screen.getByText('Insight should regenerate')); + const verifyBuildForwardedStateFnAvailable = () => + waitFor(() => screen.getByText('buildForwardedState available')); + + const setReportInput = (input: typeof reportInput) => act(() => context?.setReportInput(input)); + const setInsight = (insight: string) => act(() => context?.setInsight(insight)); + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperties(global, { + crypto: { value: webcrypto, writable: true }, + }); + + useKibanaMock.mockReturnValue({ + services: { + telemetry: { + reportEvent: reportEventMock, + }, + }, + }); + }); + + const forwardedState = { + timeRange, + insight: 'Some valuable insight', + reportDataHash, + }; + + describe('export mode: the page is being rendered in the backend for export', () => { + describe('when there is a forwarded state is valid', () => { + beforeEach(async () => { + doRender(forwardedState); + setReportInput(reportInput); + await verifyInsight(); + }); + it('should parse the forwarded state correctly', () => { + expect(context?.forwardedState).toEqual(forwardedState); + }); + + it('should verify the insight', () => { + expect(context?.isInsightVerified).toBe(true); + }); + + it('should indicate that the insight should NOT be regenerated', () => { + expect(context?.shouldRegenerateInsight).toBe(false); + }); + + it('should report a telemetry event indicating that the report is being exported', () => { + expect(reportEventMock).toHaveBeenCalledWith( + AIValueReportEventTypes.AIValueReportExportExecution, + {} + ); + }); + + it('should report a telemetry event indicating that the insight should not be regenerated', () => { + expect(reportEventMock).toHaveBeenCalledWith( + AIValueReportEventTypes.AIValueReportExportInsightVerified, + { + shouldRegenerate: false, + } + ); + }); + }); + + describe('when there is a forwarded state is valid and the report input is different', () => { + beforeEach(async () => { + doRender(forwardedState); + setReportInput({ ...reportInput, minutesPerAlert: 12345 }); + await verifyInsight(); + }); + + it('should verify the insight', () => { + expect(context?.isInsightVerified).toBe(true); + }); + + it('should indicate that the insight should be regenerated', () => { + expect(context?.shouldRegenerateInsight).toBe(true); + }); + + it('should report a telemetry event indicating that the insight should be regenerated', () => { + expect(reportEventMock).toHaveBeenCalledWith( + AIValueReportEventTypes.AIValueReportExportInsightVerified, + { + shouldRegenerate: true, + } + ); + }); + }); + + describe('when the forwarded state is invalid', () => { + beforeEach(() => { + doRender('something unexpected'); + }); + it('should set the forwarded state to undefined', () => { + expect(context?.forwardedState).toBe(undefined); + }); + }); + + describe('when hashing returns an error', () => { + const errorMessage = 'Boom while hashing!'; + beforeEach(async () => { + Object.defineProperties(global, { + crypto: { + value: { + subtle: { + digest: () => { + throw Error(errorMessage); + }, + }, + }, + writable: true, + }, + }); + doRender(forwardedState); + setReportInput(reportInput); + await verifyInsight(); + }); + + it('should report a telemetry event with the error', () => { + expect(reportEventMock).toHaveBeenCalledWith( + AIValueReportEventTypes.AIValueReportExportError, + { + errorMessage, + isExportMode: true, + } + ); + }); + + it('should indicate that the insight should be regenerated', async () => { + await verifyInsightShouldRegenerate(); + }); + }); + }); + + describe("normal mode: the page is rendered in the user's browser", () => { + describe('happy path', () => { + beforeEach(() => { + doRender(undefined); + }); + + it('should expose a buildForwardedState that is defined only when the insight and the report input has loaded', async () => { + const buildState = () => context?.buildForwardedState({ timeRange }); + expect(buildState()).toBeUndefined(); + setInsight('Some valuable insight'); + expect(buildState()).toBeUndefined(); + setReportInput(reportInput); + await verifyBuildForwardedStateFnAvailable(); + + expect(buildState()).toEqual({ + timeRange, + insight: 'Some valuable insight', + reportDataHash, + }); + }); + }); + + describe('when hashing returns an error', () => { + const errorMessage = 'Boom while hashing!'; + beforeEach(async () => { + Object.defineProperties(global, { + crypto: { + value: { + subtle: { + digest: () => { + throw Error(errorMessage); + }, + }, + }, + writable: true, + }, + }); + doRender(undefined); + setReportInput(reportInput); + }); + + it('should report a telemetry event with the error', async () => { + waitFor(() => { + expect(reportEventMock).toHaveBeenCalledWith( + AIValueReportEventTypes.AIValueReportExportError, + { + errorMessage, + isExportMode: false, + } + ); + }); + }); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.tsx new file mode 100644 index 0000000000000..7214ed5e7d742 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/reports/providers/ai_value/export_provider.tsx @@ -0,0 +1,183 @@ +/* + * 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, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useHistory } from 'react-router-dom'; +import type { ForwardedAIValueReportState } from '../../../../common/locators/ai_value_report/locator'; +import { parseLocationState } from '../../../../common/locators/ai_value_report/locator'; +import { useKibana } from '../../../common/lib/kibana'; +import { AIValueReportEventTypes } from '../../../common/lib/telemetry/events/ai_value_report/types'; + +interface AIValueExportContext { + forwardedState?: ForwardedAIValueReportState; + isExportMode: boolean; + isInsightVerified: boolean; + shouldRegenerateInsight?: boolean; + setReportInput: (inputData: object) => void; + setInsight: (insight: string) => void; + buildForwardedState: ( + params: Pick + ) => ForwardedAIValueReportState | undefined; +} + +const AIValueExportContext = createContext(null); + +export const useAIValueExportContext = () => useContext(AIValueExportContext); + +interface AIValueExportProviderProps { + children: React.ReactNode; +} + +const hashReportData = async (data: object) => { + const str = JSON.stringify(data); + const enc = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', enc.encode(str)); + return Array.from(new Uint8Array(hash)) + .map((v) => v.toString(16).padStart(2, '0')) + .join(''); +}; + +/** + * This provider manages context for the AI Value Report. + * It exposes hooks for setting the report’s input data and the AI-generated + * cost-savings trend insight when the report is loaded. + * + * After these values are set, the `buildForwardedState` function becomes ready + * to use when exporting the report to PDF. Only the AI-generated insight and a + * hash of the input data it was derived from are included in the forwarded + * state. This ensures the backend does not regenerate the insight (and consume + * AI tokens) if the input data has not changed. + * + * If the navigation history contains a state (indicating that the page is being + * exported, such as to PDF), the provider parses that state and extracts the + * AI-generated insight along with the input-data hash. It then waits for the + * report data to load, hashes it, and checks it against the forwarded hash. + * If the hashes match, `shouldRegenerateInsight` is set to false; otherwise it + * is set to true. + */ +export function AIValueExportProvider({ children }: AIValueExportProviderProps) { + const history = useHistory(); + + const [forwardedState, setForwardedState] = useState(); + const [isInsightVerified, setIsInsightVerified] = useState(false); + const [shouldRegenerateInsight, setShouldRegenerateInsight] = useState(); + + const [reportInput, setReportInput] = useState(); + const [reportDataHash, setReportDataHash] = useState(); + + const [insight, setInsight] = useState(); + const abortControllerRef = useRef(null); + const [hashReportErrorMessage, setHashReportErrorMessage] = useState(''); + const { telemetry } = useKibana().services; + const [isExportMode, setIsExportMode] = useState(false); + + useEffect(() => { + if (history.location.state) { + setForwardedState(parseLocationState(history.location.state)); + setIsExportMode(true); + } + }, [history.location.state]); + + useEffect(() => { + if (reportInput) { + setReportDataHash(undefined); + if (abortControllerRef.current !== null) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + const generateReportDataHash = async () => { + let hash: string; + try { + hash = await hashReportData(reportInput); + } catch (e) { + // Fallback to the date string which will force the regeneration of the insight + hash = new Date().toISOString(); + setHashReportErrorMessage(e?.message ?? 'error during the hash generation'); + } + + if (controller.signal.aborted) { + return; + } + setReportDataHash(hash); + }; + + generateReportDataHash(); + } + }, [reportInput]); + + useEffect(() => { + if (forwardedState && reportDataHash) { + setShouldRegenerateInsight(reportDataHash !== forwardedState.reportDataHash); + setIsInsightVerified(true); + } + }, [forwardedState, reportDataHash]); + + // Telemetry reporting + useEffect(() => { + if (isInsightVerified && shouldRegenerateInsight !== undefined) { + telemetry.reportEvent(AIValueReportEventTypes.AIValueReportExportInsightVerified, { + shouldRegenerate: shouldRegenerateInsight, + }); + } + }, [telemetry, isInsightVerified, shouldRegenerateInsight]); + + useEffect(() => { + if (isExportMode) { + telemetry.reportEvent(AIValueReportEventTypes.AIValueReportExportExecution, {}); + } + }, [isExportMode, telemetry]); + + useEffect(() => { + if (hashReportErrorMessage) { + telemetry.reportEvent(AIValueReportEventTypes.AIValueReportExportError, { + errorMessage: hashReportErrorMessage, + isExportMode, + }); + } + }, [hashReportErrorMessage, isExportMode, telemetry]); + + const buildForwardedState = useCallback( + ({ + timeRange, + }: Pick): ForwardedAIValueReportState | undefined => { + if (!insight || !reportDataHash) { + return undefined; + } + + return { + timeRange, + insight, + reportDataHash, + }; + }, + [insight, reportDataHash] + ); + + const value = useMemo( + () => ({ + isExportMode, + forwardedState, + isInsightVerified, + shouldRegenerateInsight, + buildForwardedState, + setInsight, + setReportInput, + }), + [forwardedState, buildForwardedState, isInsightVerified, shouldRegenerateInsight, isExportMode] + ); + + return {children}; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/types.ts b/x-pack/solutions/security/plugins/security_solution/public/types.ts index c4324a530cd67..1094162150c77 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/types.ts @@ -65,6 +65,7 @@ import type { AutomaticImportPluginStart } from '@kbn/automatic-import-plugin/pu import type { ProductFeatureKeys } from '@kbn/security-solution-features'; import type { ElasticAssistantSharedStatePublicPluginStart } from '@kbn/elastic-assistant-shared-state-plugin/public'; import type { InferencePublicStart } from '@kbn/inference-plugin/public'; +import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { Detections } from './detections'; @@ -103,6 +104,7 @@ import type { SiemMigrationsService } from './siem_migrations/service'; export interface SetupPlugins { cloud?: CloudSetup; home?: HomePublicPluginSetup; + share?: SharePluginSetup; licensing: LicensingPluginSetup; management: ManagementSetup; security: SecurityPluginSetup; @@ -164,6 +166,7 @@ export interface StartPlugins { productDocBase: ProductDocBasePluginStart; elasticAssistantSharedState: ElasticAssistantSharedStatePublicPluginStart; inference: InferencePublicStart; + share?: SharePluginStart; } export interface StartPluginsDependencies extends StartPlugins { diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 5666274649a01..eb803e71f83f1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -146,6 +146,7 @@ import { HealthDiagnosticServiceImpl } from './lib/telemetry/diagnostic/health_d import type { HealthDiagnosticService } from './lib/telemetry/diagnostic/health_diagnostic_service.types'; import { ENTITY_RISK_SCORE_TOOL_ID } from './assistant/tools/entity_risk_score/entity_risk_score'; import type { TelemetryQueryConfiguration } from './lib/telemetry/types'; +import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_report/locator'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -231,6 +232,10 @@ export class Plugin implements ISecuritySolutionPlugin { ): SecuritySolutionPluginSetup { this.logger.debug('plugin setup'); + if (plugins.share) { + plugins.share.url.locators.create(new AIValueReportLocatorDefinition()); + } + const { appClientFactory, productFeaturesService, pluginContext, config, logger } = this; const experimentalFeatures = config.experimentalFeatures; diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts index ffbd6cc5ea0b9..7a97fda24b05d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts @@ -41,7 +41,7 @@ import type { import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server'; import type { OsqueryPluginSetup } from '@kbn/osquery-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import type { SharePluginStart } from '@kbn/share-plugin/server'; +import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/server'; import type { PluginSetup as UnifiedSearchServerPluginSetup } from '@kbn/unified-search-plugin/server'; import type { ElasticAssistantPluginStart } from '@kbn/elastic-assistant-plugin/server'; import type { InferenceServerStart } from '@kbn/inference-plugin/server'; @@ -68,6 +68,7 @@ export interface SecuritySolutionPluginSetupDependencies { licensing: LicensingPluginSetup; osquery: OsqueryPluginSetup; unifiedSearch: UnifiedSearchServerPluginSetup; + share?: SharePluginSetup; } export interface SecuritySolutionPluginStartDependencies { diff --git a/x-pack/solutions/security/plugins/security_solution/server/ui_settings.ts b/x-pack/solutions/security/plugins/security_solution/server/ui_settings.ts index 722fa804504a2..36d65633b55f2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/ui_settings.ts @@ -492,6 +492,7 @@ export const initUiSettings = ( schema: schema.boolean(), solutionViews: ['classic', 'security'], }, + ...getDefaultValueReportSettings(), ...(experimentalFeatures.disableESQLRiskScoring ? {} : { diff --git a/x-pack/solutions/security/plugins/security_solution/tsconfig.json b/x-pack/solutions/security/plugins/security_solution/tsconfig.json index 0d63f58f4943a..44308849d869b 100644 --- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json +++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json @@ -262,6 +262,7 @@ "@kbn/response-ops-rule-form", "@kbn/core-lifecycle-browser-mocks", "@kbn/connector-schemas", + "@kbn/deeplinks-analytics", "@kbn/tour-queue" ] } diff --git a/x-pack/solutions/security/plugins/security_solution_serverless/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution_serverless/server/plugin.ts index aa345a9c7ddba..4f34cd719526e 100644 --- a/x-pack/solutions/security/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution_serverless/server/plugin.ts @@ -14,7 +14,6 @@ import type { } from '@kbn/core/server'; import { SECURITY_PROJECT_SETTINGS } from '@kbn/serverless-security-settings'; -import { getDefaultValueReportSettings } from '@kbn/security-solution-plugin/server/ui_settings'; import { getEnabledProductFeatures } from '../common/pli/pli_features'; import type { ServerlessSecurityConfig } from './config'; @@ -90,11 +89,6 @@ export class SecuritySolutionServerlessPlugin // Setup project uiSettings whitelisting pluginsSetup.serverless.setupProjectSettings(projectSettings); - // Serverless Advanced Settings setup - coreSetup.uiSettings.register({ - ...getDefaultValueReportSettings(), - }); - // Tasks this.cloudSecurityUsageReportingTask = new SecurityUsageReportingTask({ core: coreSetup, diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts index fb5aae03a9c3a..9db51acb111b4 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts @@ -237,7 +237,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { 'contain', "/app/security/network?query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); }); it('sets KQL in host page and detail page and check if href match on breadcrumb, tabs and subTabs', () => { @@ -253,7 +253,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { 'contain', "/app/security/hosts?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); cy.get(NETWORK) .should('have.attr', 'href') @@ -261,7 +261,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { 'contain', "/app/security/network?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); toggleNavigationPanel(EXPLORE_PANEL_BTN); cy.get(HOSTS_NAMES).first().should('have.text', 'siem-kibana'); @@ -275,7 +275,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { .and( 'contain', "/app/security/hosts/name/siem-kibana/anomalies?timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); cy.get(BREADCRUMBS) @@ -285,7 +285,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { 'contain', "/app/security/hosts?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); cy.get(BREADCRUMBS) .eq(3) @@ -294,7 +294,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { 'contain', "/app/security/hosts/name/siem-kibana?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + - "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" ); }); diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/state.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/state.ts index 5c4db26382a4c..59e50fb655730 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/state.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/urls/state.ts @@ -6,7 +6,7 @@ */ export const ABSOLUTE_DATE_RANGE = { - url: '/app/security/network/flows/?timerange=(global:(linkTo:!(timeline),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)),timeline:(linkTo:!(global),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)))', + url: '/app/security/network/flows/?timerange=(global:(linkTo:!(timeline),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)),timeline:(linkTo:!(global),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)),valueReport:(linkTo:!(),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)))', urlWithTimestamps: '/app/security/network/flows/?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))', @@ -19,7 +19,7 @@ export const ABSOLUTE_DATE_RANGE = { urlHost: '/app/security/hosts/allHosts?timerange=(global:(linkTo:!(timeline),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)),timeline:(linkTo:!(global),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)))', urlHostNew: - '/app/security/hosts/allHosts?timerange=(global:(linkTo:!(timeline),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272023-01-01T21:33:29.186Z%27)),timeline:(linkTo:!(global),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272023-01-01T21:33:29.186Z%27)))', + '/app/security/hosts/allHosts?timerange=(global:(linkTo:!(timeline),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272023-01-01T21:33:29.186Z%27)),timeline:(linkTo:!(global),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272023-01-01T21:33:29.186Z%27)),valueReport:(linkTo:!(),timerange:(from:%272019-08-01T20:03:29.186Z%27,kind:absolute,to:%272019-08-01T20:33:29.186Z%27)))', urlFiltersHostsHosts: '/app/security/hosts/allHosts?filters=!((%27$state%27:(store:globalState),meta:(alias:!n,disabled:!f,key:host.name,negate:!f,params:(query:test-host),type:phrase),query:(match_phrase:(host.name:(query:test-host)))),(%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:host.os.name,negate:!f,params:(query:test-os),type:phrase),query:(match_phrase:(host.os.name:(query:test-os)))))',