diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.test.tsx index e550ad37f1224..2e717cdc134b6 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.test.tsx @@ -12,7 +12,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import { DEFAULTS, useFetchAnonymizationFields } from './use_fetch_anonymization_fields'; import type { HttpSetup } from '@kbn/core-http-browser'; -import { useAssistantContext } from '../../../assistant_context'; +import { useMaybeAssistantContext } from '../../../assistant_context'; import { API_VERSIONS, defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; const http = { @@ -30,7 +30,7 @@ const createWrapper = () => { }; describe('useFetchAnonymizationFields', () => { - (useAssistantContext as jest.Mock).mockReturnValue({ + (useMaybeAssistantContext as jest.Mock).mockReturnValue({ http, assistantAvailability: { isAssistantEnabled: true, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.ts index 9f2bdcd90eb91..b8309e31559f9 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields.ts @@ -12,7 +12,7 @@ import { ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND, API_VERSIONS, } from '@kbn/elastic-assistant-common'; -import { useAssistantContext } from '../../../assistant_context'; +import { useMaybeAssistantContext } from '../../../assistant_context'; export interface UseFetchAnonymizationFieldsParams { page?: number; // API uses 1-based index @@ -85,13 +85,15 @@ export const useFetchAnonymizationFields = ( filter, } = params || {}; - const { - http, - assistantAvailability: { isAssistantEnabled }, - } = useAssistantContext(); + const assistantContext = useMaybeAssistantContext(); + const http = assistantContext?.http; + const isAssistantEnabled = assistantContext?.assistantAvailability.isAssistantEnabled ?? false; const fetchPage = useCallback( async ({ pageParam = { page, perPage, sortField, sortOrder, filter, all } }) => { + if (!http) { + throw new Error('useFetchAnonymizationFields requires AssistantProvider when fetching'); + } const { page: p = page, perPage: pp = perPage, @@ -122,6 +124,8 @@ export const useFetchAnonymizationFields = ( [page, perPage, sortField, sortOrder, filter, all, http, signal] ); + const queryEnabled = Boolean(http) && isAssistantEnabled; + // Next page param: include current sorting in next request const getNextPageParam = useCallback( (lastPage: FindAnonymizationFieldsResponse) => { @@ -155,7 +159,7 @@ export const useFetchAnonymizationFields = ( FindAnonymizationFieldsResponse >(CACHING_KEYS, fetchPage, { getNextPageParam, - enabled: isAssistantEnabled, + enabled: queryEnabled, refetchOnWindowFocus: true, }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx index 52b21d80c76bb..939b257bc976b 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -176,6 +176,14 @@ export const useAssistantContext = () => { return context; }; +/** + * Same context as {@link useAssistantContext}, but returns `undefined` when no provider is present. + * Prefer {@link useAssistantContext} for assistant UI; use this only when a hook must degrade + * gracefully outside `AssistantProvider` (e.g. embedded previews). + */ +export const useMaybeAssistantContext = (): UseAssistantContext | undefined => + React.useContext(AssistantContext); + export const useAssistantContextValue = (props: AssistantProviderProps): UseAssistantContext => { const { actionTypeRegistry, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts index 63065b64647d6..a461030cd612f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts @@ -11,7 +11,11 @@ // happens in the root of your app. Optionally provide a custom title for the assistant: /** provides context (from the app) to the assistant, and injects Kibana services, like `http` */ -export { AssistantProvider, useAssistantContext } from './impl/assistant_context'; +export { + AssistantProvider, + useAssistantContext, + useMaybeAssistantContext, +} from './impl/assistant_context'; // Step 2.1: Add the `AssistantOverlay` component to your app. This component displays the assistant // overlay in a modal, bound to a shortcut key: diff --git a/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.test.ts b/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.test.ts new file mode 100644 index 0000000000000..69836d6075538 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; + +import { + consumePreserveAgentBuilderSessionGate, + markPreserveAgentBuilderSessionDuringNextSecurityNavigation, + readLastAgentBuilderAgentIdForSecuritySession, +} from './agent_builder_navigation_gate'; + +describe('agent_builder_navigation_gate', () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + + it('consume returns false when not marked', () => { + expect(consumePreserveAgentBuilderSessionGate()).toBe(false); + expect(consumePreserveAgentBuilderSessionGate()).toBe(false); + }); + + it('consume returns true once after mark', () => { + markPreserveAgentBuilderSessionDuringNextSecurityNavigation(); + expect(consumePreserveAgentBuilderSessionGate()).toBe(true); + expect(consumePreserveAgentBuilderSessionGate()).toBe(false); + }); + + it('readLastAgentBuilderAgentIdForSecuritySession falls back to default', () => { + expect(readLastAgentBuilderAgentIdForSecuritySession()).toBe(agentBuilderDefaultAgentId); + }); + + it('readLastAgentBuilderAgentIdForSecuritySession reads raw string from localStorage', () => { + localStorage.setItem('agentBuilder.agentId', 'my-agent'); + expect(readLastAgentBuilderAgentIdForSecuritySession()).toBe('my-agent'); + }); + + it('readLastAgentBuilderAgentIdForSecuritySession parses JSON-encoded string', () => { + localStorage.setItem('agentBuilder.agentId', JSON.stringify('json-agent')); + expect(readLastAgentBuilderAgentIdForSecuritySession()).toBe('json-agent'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.ts b/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.ts new file mode 100644 index 0000000000000..90c548103224a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/agent_builder_navigation_gate.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { agentBuilderDefaultAgentId } from '@kbn/agent-builder-common'; + +/** Same as agent_builder `storageKeys.agentId`. */ +const AGENT_BUILDER_LAST_AGENT_ID_STORAGE_KEY = 'agentBuilder.agentId'; + +/** + * Session flag: the next Security app effect teardown is from an in-app navigation that must not + * clear Agent Builder session state (e.g. "Open entity in Security" from an attachment). + */ +const IN_APP_NAV_PRESERVE_AGENT_BUILDER_SESSION_KEY = + 'securitySolution.preserveAgentBuilderSessionDuringInAppNav'; + +export const readLastAgentBuilderAgentIdForSecuritySession = (): string => { + if (typeof window === 'undefined' || window.localStorage == null) { + return agentBuilderDefaultAgentId; + } + const stored = window.localStorage.getItem(AGENT_BUILDER_LAST_AGENT_ID_STORAGE_KEY); + if (stored == null || stored === '') { + return agentBuilderDefaultAgentId; + } + try { + const parsed = JSON.parse(stored); + return typeof parsed === 'string' ? parsed : stored; + } catch { + return stored; + } +}; + +export const markPreserveAgentBuilderSessionDuringNextSecurityNavigation = (): void => { + try { + window.sessionStorage?.setItem(IN_APP_NAV_PRESERVE_AGENT_BUILDER_SESSION_KEY, '1'); + } catch { + // private mode / quota + } +}; + +/** + * Returns true once if a preserve gate was set (and clears it). Idempotent per consume call. + */ +export const consumePreserveAgentBuilderSessionGate = (): boolean => { + try { + if (window.sessionStorage?.getItem(IN_APP_NAV_PRESERVE_AGENT_BUILDER_SESSION_KEY) === '1') { + window.sessionStorage.removeItem(IN_APP_NAV_PRESERVE_AGENT_BUILDER_SESSION_KEY); + return true; + } + } catch { + // ignore + } + return false; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/common/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/constants.ts index ff62b01f75357..2ca7a6b28f454 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/constants.ts @@ -718,6 +718,7 @@ export const ESSENTIAL_ALERT_FIELDS: string[] = [ export enum SecurityAgentBuilderAttachments { alert = 'security.alert', entity = 'security.entity', + entityAnalyticsDashboard = 'security.entity_analytics_dashboard', rule = 'security.rule', } diff --git a/x-pack/solutions/security/plugins/security_solution/moon.yml b/x-pack/solutions/security/plugins/security_solution/moon.yml index 2e94775223f3c..9c5da861878fe 100644 --- a/x-pack/solutions/security/plugins/security_solution/moon.yml +++ b/x-pack/solutions/security/plugins/security_solution/moon.yml @@ -293,6 +293,7 @@ dependsOn: - '@kbn/shared-ux-column-presets' - '@kbn/core-overlays-browser' - '@kbn/workflows-management-plugin' + - '@kbn/core-application-browser-mocks' tags: - plugin - prod diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.test.tsx new file mode 100644 index 0000000000000..31bd6aa69d8c6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.test.tsx @@ -0,0 +1,250 @@ +/* + * 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, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; +import type { ISessionService } from '@kbn/data-plugin/public'; +import type { EntityAnalyticsDashboardAttachment } from './entity_analytics_dashboard_attachment'; +import { createEntityAnalyticsDashboardAttachmentDefinition } from './entity_analytics_dashboard_attachment'; +import { navigateToEntityAnalyticsHomePageInApp } from './entity_explore_navigation'; + +interface ResizeDimensions { + width: number; + height: number; +} + +const RESIZE_CALLBACK_KEY = '__eaDashboardOnResize'; + +jest.mock('@elastic/eui', () => { + const React_ = jest.requireActual('react'); + const actual = jest.requireActual('@elastic/eui'); + + const EuiResizeObserver = ({ + onResize, + children, + }: { + onResize: (d: ResizeDimensions) => void; + children: (ref: (el: HTMLElement | null) => void) => React.ReactElement; + }) => { + (global as unknown as Record)[RESIZE_CALLBACK_KEY] = onResize; + return children(() => {}); + }; + + const EuiFlexGroup = React_.forwardRef( + ( + { + direction = 'row', + alignItems, + gutterSize, + responsive, + justifyContent, + wrap, + component: _component, + children, + ...rest + }: Record & { children?: React.ReactNode }, + ref: React.Ref + ) => + React_.createElement( + 'div', + { + ref, + 'data-direction': direction, + 'data-align-items': alignItems ?? '', + ...rest, + }, + children + ) + ); + + const EuiFlexItem = ({ + grow, + component: _component, + children, + ...rest + }: Record & { children?: React.ReactNode }) => + React_.createElement('div', { 'data-grow': String(grow), ...rest }, children); + + return { + ...actual, + EuiResizeObserver, + EuiFlexGroup, + EuiFlexItem, + }; +}); + +jest.mock('../../entity_analytics/components/home/risk_level_breakdown_table', () => ({ + RiskLevelBreakdownTable: () =>
, +})); + +jest.mock('../../entity_analytics/components/risk_score_donut_chart', () => ({ + RiskScoreDonutChart: () =>
, +})); + +jest.mock('./entity_list_table', () => ({ + EntityListTable: ({ closeCanvas }: { closeCanvas?: () => void }) => ( +
+ ), +})); + +jest.mock('./entity_explore_navigation', () => ({ + navigateToEntityAnalyticsHomePageInApp: jest.fn(), +})); + +const triggerResize = (dimensions: ResizeDimensions) => { + const onResize = (global as unknown as Record)[RESIZE_CALLBACK_KEY] as + | ((d: ResizeDimensions) => void) + | undefined; + if (!onResize) { + throw new Error('EuiResizeObserver onResize was never registered'); + } + act(() => { + onResize(dimensions); + }); +}; + +const makeAttachment = (): EntityAnalyticsDashboardAttachment => + ({ + id: 'ea-dashboard-1', + type: 'security.entity_analytics_dashboard', + data: { + attachmentLabel: 'Entity Analytics Dashboard', + summary: 'Top 5 riskiest users.', + entities: [ + { entity_type: 'user', identifier: 'alice' }, + { entity_type: 'host', identifier: 'beta' }, + ], + }, + } as unknown as EntityAnalyticsDashboardAttachment); + +const renderCanvas = ( + overrides: { searchSession?: ISessionService; closeCanvas?: () => void } = {} +) => { + const application = applicationServiceMock.createStartContract(); + const definition = createEntityAnalyticsDashboardAttachmentDefinition({ + application, + searchSession: overrides.searchSession, + }); + return render( + + {definition.renderCanvasContent!( + { + attachment: makeAttachment(), + } as unknown as Parameters>[0], + { + closeCanvas: overrides.closeCanvas ?? jest.fn(), + } as unknown as Parameters>[1] + )} + + ); +}; + +describe('EntityAnalyticsDashboardCanvasContent', () => { + afterEach(() => { + delete (global as unknown as Record)[RESIZE_CALLBACK_KEY]; + (navigateToEntityAnalyticsHomePageInApp as jest.Mock).mockClear(); + }); + + it('returns an "Open in Security" action from getActionButtons in canvas mode and forwards searchSession', () => { + const searchSession = { clear: jest.fn() } as unknown as ISessionService; + const application = applicationServiceMock.createStartContract(); + const definition = createEntityAnalyticsDashboardAttachmentDefinition({ + application, + searchSession, + }); + + const buttons = definition.getActionButtons!({ + attachment: makeAttachment(), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + }); + + expect(buttons).toHaveLength(1); + expect(buttons[0].icon).toBe('popout'); + expect(buttons[0].label).toMatch(/open entity analytics in security/i); + + buttons[0].handler(); + + expect(navigateToEntityAnalyticsHomePageInApp).toHaveBeenCalledTimes(1); + expect(navigateToEntityAnalyticsHomePageInApp).toHaveBeenCalledWith( + expect.objectContaining({ searchSession }) + ); + }); + + it('returns a Preview action from getActionButtons when not in canvas mode', () => { + const application = applicationServiceMock.createStartContract(); + const openCanvas = jest.fn(); + const definition = createEntityAnalyticsDashboardAttachmentDefinition({ application }); + + const buttons = definition.getActionButtons!({ + attachment: makeAttachment(), + isSidebar: false, + isCanvas: false, + openCanvas, + updateOrigin: jest.fn(), + }); + + expect(buttons).toHaveLength(1); + expect(buttons[0].icon).toBe('eye'); + expect(buttons[0].label).toMatch(/preview/i); + buttons[0].handler(); + expect(openCanvas).toHaveBeenCalledTimes(1); + }); + + it('renders the risk breakdown table and the donut chart side-by-side by default', () => { + renderCanvas(); + const innerRow = screen.getByTestId('riskLevelPanelInnerRow'); + expect(innerRow.getAttribute('data-direction')).toBe('row'); + expect(innerRow.getAttribute('data-align-items')).toBe('center'); + expect(screen.getByTestId('riskLevelBreakdownTableMock')).toBeInTheDocument(); + expect(screen.getByTestId('riskScoreDonutChartMock')).toBeInTheDocument(); + }); + + it('stacks the donut chart below the table when the container is narrower than the threshold', () => { + renderCanvas(); + triggerResize({ width: 400, height: 200 }); + const innerRow = screen.getByTestId('riskLevelPanelInnerRow'); + expect(innerRow.getAttribute('data-direction')).toBe('column'); + expect(innerRow.getAttribute('data-align-items')).toBe('stretch'); + }); + + it('returns to the row layout when the container grows past the threshold', () => { + renderCanvas(); + triggerResize({ width: 400, height: 200 }); + triggerResize({ width: 800, height: 200 }); + const innerRow = screen.getByTestId('riskLevelPanelInnerRow'); + expect(innerRow.getAttribute('data-direction')).toBe('row'); + expect(innerRow.getAttribute('data-align-items')).toBe('center'); + }); + + it('treats exactly the threshold width as wide (row layout)', () => { + renderCanvas(); + triggerResize({ width: 500, height: 200 }); + const innerRow = screen.getByTestId('riskLevelPanelInnerRow'); + expect(innerRow.getAttribute('data-direction')).toBe('row'); + }); + + it('sets canvasWidth to 50vw so the dashboard uses the full canvas flyout width', () => { + const application = applicationServiceMock.createStartContract(); + const definition = createEntityAnalyticsDashboardAttachmentDefinition({ application }); + expect(definition.canvasWidth).toBe('50vw'); + }); + + it('forwards closeCanvas from the canvas render callbacks into EntityListTable so per-row navigation can dismiss the canvas overlay', () => { + const closeCanvas = jest.fn(); + renderCanvas({ closeCanvas }); + expect(screen.getByTestId('entityListTableMock').getAttribute('data-has-close-canvas')).toBe( + 'true' + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.tsx new file mode 100644 index 0000000000000..38bf2af9ebe48 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_analytics_dashboard_attachment.tsx @@ -0,0 +1,594 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { css } from '@emotion/react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiResizeObserver, + EuiSpacer, + EuiStat, + EuiText, + EuiTitle, + useIsWithinBreakpoints, +} from '@elastic/eui'; +import type { EuiResizeObserverProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import type { + AttachmentUIDefinition, + AttachmentRenderProps, + AttachmentServiceStartContract, +} from '@kbn/agent-builder-browser/attachments'; +import { ActionButtonType } from '@kbn/agent-builder-browser/attachments'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { ISessionService } from '@kbn/data-plugin/public'; +import { APP_UI_ID, SecurityAgentBuilderAttachments } from '../../../common/constants'; +import { EMPTY_SEVERITY_COUNT, RiskSeverity } from '../../../common/search_strategy'; +import type { SeverityCount } from '../../entity_analytics/components/severity/types'; +import { RiskLevelBreakdownTable } from '../../entity_analytics/components/home/risk_level_breakdown_table'; +import { RiskScoreDonutChart } from '../../entity_analytics/components/risk_score_donut_chart'; +import { EntityListTable, type EntityListRow } from './entity_list_table'; +import { + navigateToEntityAnalyticsHomePageInApp, + type SecurityAgentBuilderChrome, +} from './entity_explore_navigation'; + +export type EntityAnalyticsDashboardAttachment = Attachment< + typeof SecurityAgentBuilderAttachments.entityAnalyticsDashboard, + { + attachmentLabel?: string; + summary?: string; + time_range_label?: string; + watchlist_id?: string; + watchlist_name?: string; + severity_count?: SeverityCount; + distribution_note?: string; + anomaly_highlights?: Array<{ title: string; body?: string }>; + entities: EntityListRow[]; + } +>; + +const rootCanvasStyles = css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 400, +}); + +/** + * Width (in px) below which the risk breakdown table and donut chart stack + * vertically instead of sitting side-by-side. Chosen to avoid a cramped row + * when the Canvas flyout / left column is narrow. + */ +const RISK_LEVEL_PANEL_STACK_WIDTH_THRESHOLD = 500; + +/** + * Preferred width for the Entity Analytics dashboard canvas. The layout has side-by-side + * risk breakdown + donut + anomaly highlights panels, plus an entity list below, so it + * benefits from as much width as the canvas flyout will allow. This matches the default + * max (`50vw`) but is set explicitly to signal intent and survive any future change to + * `DEFAULT_CANVAS_WIDTH`. + */ +const EA_DASHBOARD_CANVAS_WIDTH = '50vw'; + +const mergeSeverityCount = (partial?: SeverityCount): SeverityCount => ({ + ...EMPTY_SEVERITY_COUNT, + ...partial, +}); + +const parseRiskLevelString = (raw?: string): RiskSeverity => { + if (!raw) { + return RiskSeverity.Unknown; + } + const normalized = raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase(); + const allowed = Object.values(RiskSeverity) as string[]; + if (allowed.includes(normalized)) { + return normalized as RiskSeverity; + } + return RiskSeverity.Unknown; +}; + +const inferSeverityCountFromEntities = (entities: EntityListRow[]): SeverityCount => { + const next: SeverityCount = { ...EMPTY_SEVERITY_COUNT }; + for (const row of entities) { + const level = parseRiskLevelString(row.risk_level); + next[level] += 1; + } + return next; +}; + +const severityTotal = (c: SeverityCount): number => + c[RiskSeverity.Critical] + + c[RiskSeverity.High] + + c[RiskSeverity.Moderate] + + c[RiskSeverity.Low] + + c[RiskSeverity.Unknown]; + +const EntityAnalyticsDashboardInlineContent: React.FC< + AttachmentRenderProps +> = ({ attachment }) => { + const { entities, severity_count, attachmentLabel } = attachment.data; + const title = + attachmentLabel ?? + i18n.translate('xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.inlineTitle', { + defaultMessage: 'Entity Analytics dashboard', + }); + const showRiskInSnapshot = + severity_count != null || severityTotal(inferSeverityCountFromEntities(entities ?? [])) > 0; + + return ( + + + {title} + + + + {i18n.translate('xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.inlineMeta', { + defaultMessage: + '{entityCount, plural, one {# entity in snapshot} other {# entities in snapshot}}{riskSuffix}', + values: { + entityCount: entities.length ?? 0, + riskSuffix: showRiskInSnapshot ? ' · Risk chart from snapshot' : '', + }, + })} + + + ); +}; + +const EntityAnalyticsDashboardCanvasContent: React.FC< + AttachmentRenderProps & { + application: ApplicationStart; + searchSession?: ISessionService; + /** + * Dismisses the canvas flyout. Provided by the Agent Builder canvas render + * callbacks. Forwarded into `EntityListTable` so per-row navigation into + * the Entity Analytics home flyout can close the overlay first, otherwise + * the canvas renders on top of the just-opened entity flyout. + */ + closeCanvas?: () => void; + } +> = ({ attachment, application, searchSession, closeCanvas }) => { + const data = attachment.data; + const isXlScreen = useIsWithinBreakpoints(['l', 'xl']); + const [isRiskPanelNarrow, setIsRiskPanelNarrow] = useState(false); + const onRiskPanelResize = useCallback((dimensions) => { + if (!dimensions) return; + setIsRiskPanelNarrow(dimensions.width < RISK_LEVEL_PANEL_STACK_WIDTH_THRESHOLD); + }, []); + const hasExplicitSeverityCount = data.severity_count != null; + const inferredFromEntities = useMemo( + () => inferSeverityCountFromEntities(data.entities ?? []), + [data.entities] + ); + const severityCountForChart = useMemo(() => { + if (hasExplicitSeverityCount) { + return mergeSeverityCount(data.severity_count); + } + return severityTotal(inferredFromEntities) > 0 + ? inferredFromEntities + : mergeSeverityCount(undefined); + }, [data.severity_count, hasExplicitSeverityCount, inferredFromEntities]); + + const entityTypeCounts = useMemo(() => { + const counts: Record = {}; + for (const row of data.entities ?? []) { + const t = row.entity_type; + counts[t] = (counts[t] ?? 0) + 1; + } + return counts; + }, [data.entities]); + + const title = + data.attachmentLabel ?? + i18n.translate('xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.canvasTitle', { + defaultMessage: 'Entity Analytics', + }); + + return ( +
+ + +

{title}

+
+ {data.time_range_label ? ( + <> + + + {data.time_range_label} + + + ) : null} + + {data.summary ? ( + <> + + +

{data.summary}

+
+ + ) : null} + + + + + + + +

+ {data.watchlist_id ? ( + + ) : ( + + )} +

+
+ + + {(resizeRef) => ( +
+ + + + + + + + +
+ )} +
+ {data.distribution_note ? ( + <> + + + {data.distribution_note} + + + ) : null} + {!hasExplicitSeverityCount && severityTotal(inferredFromEntities) > 0 ? ( + <> + + + {i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.riskInferredFromEntities', + { + defaultMessage: + 'Distribution is inferred from risk levels on the entities in this snapshot (not full-environment totals).', + } + )} + + + ) : null} + {!hasExplicitSeverityCount && severityTotal(inferredFromEntities) === 0 ? ( + <> + + + {i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.riskNoDataHint', + { + defaultMessage: + 'For totals that match the Security home page, ask the assistant to include severity_count from investigation, or open Entity Analytics in Security for live KPIs.', + } + )} + + + ) : null} +
+
+ + + + +

+ +

+
+ + {data.anomaly_highlights?.length ? ( + + {data.anomaly_highlights.map((item, idx) => ( + + + {item.title} + + {item.body ? ( + <> + + + {item.body} + + + ) : null} + + ))} + + ) : ( + <> + + {i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.noHighlightsIntro', + { + defaultMessage: + 'This snapshot has no authored highlights yet. The full Entity Analytics home also includes:', + } + )} + + + + + + {i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.noHighlightsAsk', + { + defaultMessage: + 'Ask the assistant to populate anomaly_highlights, or use Open Entity Analytics in Security above.', + } + )} + + + )} +
+
+
+ + + + {Object.keys(entityTypeCounts).length > 0 ? ( + <> + + +

+ +

+
+ + + {(['host', 'user', 'service', 'generic'] as const).map((type) => { + const count = entityTypeCounts[type] ?? 0; + if (count === 0) { + return null; + } + return ( + + + + ); + })} + +
+ + + ) : null} + + + +

+ {data.watchlist_id ? ( + + ) : ( + + )} +

+
+ + {data.entities.length ? ( + + ) : ( + + {i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.emptyEntities', + { + defaultMessage: 'No entities were included in this snapshot.', + } + )} + + )} +
+
+
+ ); +}; + +export const registerEntityAnalyticsDashboardAttachment = ({ + attachments, + application, + agentBuilder, + chrome, + searchSession, +}: { + attachments: AttachmentServiceStartContract; + application: ApplicationStart; + agentBuilder?: AgentBuilderPluginStart; + chrome?: SecurityAgentBuilderChrome; + searchSession?: ISessionService; +}): void => { + attachments.addAttachmentType( + SecurityAgentBuilderAttachments.entityAnalyticsDashboard, + createEntityAnalyticsDashboardAttachmentDefinition({ + application, + agentBuilder, + chrome, + searchSession, + }) + ); +}; + +export const createEntityAnalyticsDashboardAttachmentDefinition = ({ + application, + agentBuilder, + chrome, + searchSession, +}: { + application: ApplicationStart; + agentBuilder?: AgentBuilderPluginStart; + chrome?: SecurityAgentBuilderChrome; + searchSession?: ISessionService; +}): AttachmentUIDefinition => ({ + getLabel: (attachment) => + attachment.data.attachmentLabel ?? + i18n.translate('xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.pillLabel', { + defaultMessage: 'Entity Analytics dashboard', + }), + getIcon: () => 'dashboardApp', + canvasWidth: EA_DASHBOARD_CANVAS_WIDTH, + renderInlineContent: (props) => , + renderCanvasContent: (props, { closeCanvas }) => ( + + ), + getActionButtons: ({ attachment, openCanvas, isCanvas, openSidebarConversation }) => { + if (isCanvas) { + const data = attachment.data; + return [ + { + label: i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.openInSecurity', + { + defaultMessage: 'Open Entity Analytics in Security', + } + ), + icon: 'popout', + type: ActionButtonType.SECONDARY, + handler: () => { + navigateToEntityAnalyticsHomePageInApp({ + application, + appId: APP_UI_ID, + agentBuilder, + chrome, + openSidebarConversation, + watchlistId: data.watchlist_id, + watchlistName: data.watchlist_name, + searchSession, + }); + }, + }, + ]; + } + if (!openCanvas) { + return []; + } + return [ + { + label: i18n.translate( + 'xpack.securitySolution.agentBuilder.entityAnalyticsDashboard.preview', + { + defaultMessage: 'Preview', + } + ), + icon: 'eye', + type: ActionButtonType.SECONDARY, + handler: openCanvas, + }, + ]; + }, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.test.tsx new file mode 100644 index 0000000000000..54bb10dc913e0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import type { EntityAttachment } from './types'; +import type { SecurityCanvasEmbeddedBundle } from '../../components/security_redux_embedded_provider'; +import { EntityAttachmentCanvasContent } from './entity_attachment_canvas_content'; + +/** + * Canvas-content dispatcher tests. + * + * The component is a thin dispatcher that branches on the `normaliseEntityAttachment` output: + * - invalid / multi-entity payload → empty `EuiPanel` (no card, no flyout) + * - single non-flyout-capable entity (e.g. `generic`) → `EntityCard` fallback + * - single host/user/service → `SecurityReduxEmbeddedProvider` wrapping + * `EntityCardFlyoutOverviewCanvas` + * + * The heavy downstream components (`SecurityReduxEmbeddedProvider`, + * `EntityCardFlyoutOverviewCanvas`, `EntityCard`) each have their own test suites; mocking them + * here lets us focus on the dispatch-level branches without reproducing the Security Redux / + * sourcerer runtime. + */ + +jest.mock('../../components/security_redux_embedded_provider', () => ({ + SecurityReduxEmbeddedProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock('../../components/entity_card_flyout_overview_canvas', () => ({ + EntityCardFlyoutOverviewCanvas: (props: Record) => ( +
+ {JSON.stringify(props.identifier)} +
+ ), +})); + +jest.mock('./entity_card/entity_card', () => ({ + EntityCard: (props: Record) => ( +
{JSON.stringify(props.identifier)}
+ ), +})); + +const experimentalFeatures = { + entityAnalyticsWatchlistEnabled: false, + enableRiskScorePrivmonModifier: false, +} as unknown as ExperimentalFeatures; + +const applicationStub = { navigateToApp: jest.fn() } as unknown as ApplicationStart; +const resolveSecurityCanvasContext = jest.fn( + async () => ({} as unknown as SecurityCanvasEmbeddedBundle) +); + +const attachmentOf = (data: unknown): EntityAttachment => + ({ id: 'a', type: 'security.entity', data } as unknown as EntityAttachment); + +const renderCanvas = (data: unknown) => + render( + + + + ); + +describe('EntityAttachmentCanvasContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the flyout canvas inside SecurityReduxEmbeddedProvider for a single host entity', () => { + renderCanvas({ identifierType: 'host', identifier: 'host-1' }); + expect(screen.getByTestId('securityReduxEmbeddedProviderMock')).toBeInTheDocument(); + expect(screen.getByTestId('entityCardFlyoutOverviewCanvasMock')).toBeInTheDocument(); + expect(screen.getByTestId('entityCardFlyoutOverviewCanvasMock').textContent).toContain( + 'host-1' + ); + expect(screen.queryByTestId('entityCardMock')).not.toBeInTheDocument(); + }); + + it('renders the flyout canvas inside SecurityReduxEmbeddedProvider for single user / service entities', () => { + for (const identifierType of ['user', 'service'] as const) { + const { unmount } = renderCanvas({ identifierType, identifier: 'x' }); + expect(screen.getByTestId('securityReduxEmbeddedProviderMock')).toBeInTheDocument(); + expect(screen.getByTestId('entityCardFlyoutOverviewCanvasMock')).toBeInTheDocument(); + unmount(); + } + }); + + it('falls back to the EntityCard (no flyout provider) for a single generic entity', () => { + renderCanvas({ identifierType: 'generic', identifier: 'some-resource' }); + expect(screen.getByTestId('entityCardMock')).toBeInTheDocument(); + expect(screen.queryByTestId('securityReduxEmbeddedProviderMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityCardFlyoutOverviewCanvasMock')).not.toBeInTheDocument(); + }); + + it('does not render a card or flyout for a multi-entity payload', () => { + renderCanvas({ + entities: [ + { identifierType: 'host', identifier: 'a' }, + { identifierType: 'user', identifier: 'b' }, + ], + }); + expect(screen.queryByTestId('securityReduxEmbeddedProviderMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityCardFlyoutOverviewCanvasMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityCardMock')).not.toBeInTheDocument(); + }); + + it('does not render a card or flyout for an invalid payload', () => { + renderCanvas({ foo: 'bar' }); + expect(screen.queryByTestId('securityReduxEmbeddedProviderMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityCardFlyoutOverviewCanvasMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityCardMock')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.tsx new file mode 100644 index 0000000000000..eb66d5c384889 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_canvas_content.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { AttachmentRenderProps } from '@kbn/agent-builder-browser/attachments'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { ISessionService } from '@kbn/data-plugin/public'; +import { QueryClientProvider } from '@kbn/react-query'; +import { EuiPanel } from '@elastic/eui'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import type { EntityAttachment } from './types'; +import { isFlyoutCapableIdentifierType } from './types'; +import { normaliseEntityAttachment } from './payload'; +import { entityAttachmentQueryClient } from './query_client'; +import { EntityCard } from './entity_card/entity_card'; +import { + SecurityReduxEmbeddedProvider, + type SecurityCanvasEmbeddedBundle, +} from '../../components/security_redux_embedded_provider'; +import { EntityCardFlyoutOverviewCanvas } from '../../components/entity_card_flyout_overview_canvas'; +import type { SecurityAgentBuilderChrome } from '../entity_explore_navigation'; + +export interface EntityAttachmentCanvasContentProps + extends AttachmentRenderProps { + experimentalFeatures: ExperimentalFeatures; + application: ApplicationStart; + agentBuilder?: AgentBuilderPluginStart; + chrome?: SecurityAgentBuilderChrome; + resolveSecurityCanvasContext: () => Promise; + searchSession?: ISessionService; +} + +/** + * Canvas (Preview) view for single-entity `security.entity` attachments. Mounts the full + * Security expandable-flyout overview inside `SecurityReduxEmbeddedProvider` so the same hooks + * (`useGlobalTime`, `useRiskScore`, `useObservedHost`, sourcerer, …) that power the in-app flyout + * work on the Agent Builder canvas surface. + * + * Multi-entity attachments intentionally bypass the canvas and keep the inline `EntityTable` — + * per-row Explore links cover navigation without needing a separate canvas. + * + * This module is split into its own chunk (`security_entity_attachment_canvas`) via + * `React.lazy` in `entity_attachment_definition.tsx`, so the heavy flyout/Redux dependencies + * only load when the user clicks Preview. + */ +export const EntityAttachmentCanvasContent: React.FC = ({ + attachment, + experimentalFeatures, + application, + agentBuilder, + chrome, + resolveSecurityCanvasContext, + openSidebarConversation, + searchSession, +}) => { + const parsed = normaliseEntityAttachment(attachment); + const watchlistsEnabled = experimentalFeatures.entityAnalyticsWatchlistEnabled; + const privmonModifierEnabled = experimentalFeatures.enableRiskScorePrivmonModifier; + + if (!parsed || !parsed.isSingle) { + return ( + + + {parsed?.isSingle === false ? null : parsed ? ( + + ) : null} + + + ); + } + + const identifier = parsed.entities[0]; + if (!isFlyoutCapableIdentifierType(identifier.identifierType)) { + return ( + + + + + + ); + } + + return ( + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.test.tsx new file mode 100644 index 0000000000000..129e46a68b32a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.test.tsx @@ -0,0 +1,320 @@ +/* + * 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 { ReactElement } from 'react'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { ISessionService } from '@kbn/data-plugin/public'; +import { ActionButtonType } from '@kbn/agent-builder-browser/attachments'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import type { EntityAttachment } from './types'; +import { createEntityAttachmentDefinition } from './entity_attachment_definition'; +import { + navigateToEntityAnalyticsHomePageInApp, + navigateToEntityAnalyticsWithFlyoutInApp, +} from '../entity_explore_navigation'; + +jest.mock('../entity_explore_navigation', () => { + const actual = jest.requireActual('../entity_explore_navigation'); + return { + ...actual, + navigateToEntityAnalyticsWithFlyoutInApp: jest.fn(), + navigateToEntityAnalyticsHomePageInApp: jest.fn(), + }; +}); + +const experimentalFeatures = { + entityAnalyticsWatchlistEnabled: false, + enableRiskScorePrivmonModifier: false, +} as unknown as ExperimentalFeatures; + +const application = { navigateToApp: jest.fn() } as unknown as ApplicationStart; +const resolveSecurityCanvasContext = jest.fn(); + +const buildDefinition = ({ + withCanvas = true, + searchSession, +}: { withCanvas?: boolean; searchSession?: ISessionService } = {}) => + createEntityAttachmentDefinition({ + experimentalFeatures, + application: withCanvas ? application : undefined, + resolveSecurityCanvasContext: withCanvas ? resolveSecurityCanvasContext : undefined, + searchSession, + }); + +const attachmentOf = (data: unknown): EntityAttachment => + ({ id: 'a', type: 'security.entity', data } as unknown as EntityAttachment); + +describe('createEntityAttachmentDefinition', () => { + describe('getActionButtons (Preview button)', () => { + it('returns a single Preview button for a single host entity when Canvas is available', () => { + const def = buildDefinition(); + const openCanvas = jest.fn(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType: 'host', identifier: 'alpha' }), + isSidebar: false, + isCanvas: false, + updateOrigin: jest.fn(), + openCanvas, + }); + + expect(buttons).toHaveLength(1); + expect(buttons[0].icon).toBe('eye'); + expect(buttons[0].type).toBe(ActionButtonType.SECONDARY); + + buttons[0].handler!(); + expect(openCanvas).toHaveBeenCalledTimes(1); + }); + + it('returns a Preview button for single user and service entities', () => { + const def = buildDefinition(); + const openCanvas = jest.fn(); + + for (const identifierType of ['user', 'service'] as const) { + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType, identifier: 'x' }), + isSidebar: false, + isCanvas: false, + updateOrigin: jest.fn(), + openCanvas, + }); + expect(buttons).toHaveLength(1); + } + }); + + it('returns no buttons for a single generic entity (no live flyout available)', () => { + const def = buildDefinition(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType: 'generic', identifier: 'some-resource' }), + isSidebar: false, + isCanvas: false, + updateOrigin: jest.fn(), + openCanvas: jest.fn(), + }); + expect(buttons).toEqual([]); + }); + + it('returns no buttons for a multi-entity attachment', () => { + const def = buildDefinition(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ + entities: [ + { identifierType: 'host', identifier: 'a' }, + { identifierType: 'user', identifier: 'b' }, + ], + }), + isSidebar: false, + isCanvas: false, + updateOrigin: jest.fn(), + openCanvas: jest.fn(), + }); + expect(buttons).toEqual([]); + }); + + it('returns an "Open in Entity Analytics" action when rendered in Canvas (replaces Preview re-entry)', () => { + (navigateToEntityAnalyticsWithFlyoutInApp as jest.Mock).mockClear(); + (navigateToEntityAnalyticsHomePageInApp as jest.Mock).mockClear(); + const searchSession = { clear: jest.fn() } as unknown as ISessionService; + const def = buildDefinition({ searchSession }); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ + identifierType: 'host', + identifier: 'alpha', + entityStoreId: 'host:alpha@default', + }), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + openCanvas: jest.fn(), + }); + + expect(buttons).toHaveLength(1); + expect(buttons[0].label).toBe('Open in Entity Analytics'); + expect(buttons[0].icon).toBe('popout'); + expect(buttons[0].type).toBe(ActionButtonType.SECONDARY); + + buttons[0].handler!(); + + expect(navigateToEntityAnalyticsWithFlyoutInApp).toHaveBeenCalledTimes(1); + expect(navigateToEntityAnalyticsHomePageInApp).not.toHaveBeenCalled(); + expect(navigateToEntityAnalyticsWithFlyoutInApp).toHaveBeenCalledWith( + expect.objectContaining({ + searchSession, + flyout: { + preview: [], + right: { + id: 'host-panel', + params: { + contextID: 'agent-builder-entity-card', + scopeId: 'agent-builder-entity-card', + hostName: 'alpha', + entityId: 'host:alpha@default', + }, + }, + }, + }) + ); + }); + + it('falls back to the unfiltered Entity Analytics home when the flyout-capable identifier has no entityStoreId', () => { + (navigateToEntityAnalyticsWithFlyoutInApp as jest.Mock).mockClear(); + (navigateToEntityAnalyticsHomePageInApp as jest.Mock).mockClear(); + const searchSession = { clear: jest.fn() } as unknown as ISessionService; + const def = buildDefinition({ searchSession }); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType: 'host', identifier: 'alpha' }), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + openCanvas: jest.fn(), + }); + + expect(buttons).toHaveLength(1); + buttons[0].handler!(); + + expect(navigateToEntityAnalyticsHomePageInApp).toHaveBeenCalledTimes(1); + expect(navigateToEntityAnalyticsWithFlyoutInApp).not.toHaveBeenCalled(); + expect(navigateToEntityAnalyticsHomePageInApp).toHaveBeenCalledWith( + expect.objectContaining({ searchSession }) + ); + }); + + it('returns no buttons in Canvas mode for multi-entity attachments', () => { + const def = buildDefinition(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ + entities: [ + { identifierType: 'host', identifier: 'a' }, + { identifierType: 'user', identifier: 'b' }, + ], + }), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + }); + expect(buttons).toEqual([]); + }); + + it('returns no buttons in Canvas mode for a generic single entity (not flyout-capable)', () => { + const def = buildDefinition(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType: 'generic', identifier: 'some-resource' }), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + }); + expect(buttons).toEqual([]); + }); + + it('returns no buttons when openCanvas is not provided', () => { + const def = buildDefinition(); + const buttons = def.getActionButtons!({ + attachment: attachmentOf({ identifierType: 'host', identifier: 'alpha' }), + isSidebar: false, + isCanvas: false, + updateOrigin: jest.fn(), + // openCanvas intentionally omitted + }); + expect(buttons).toEqual([]); + }); + }); + + describe('canvas availability', () => { + it('omits renderCanvasContent + getActionButtons when application or resolver is missing', () => { + const def = buildDefinition({ withCanvas: false }); + expect(def.renderCanvasContent).toBeUndefined(); + expect(def.getActionButtons).toBeUndefined(); + expect(def.canvasWidth).toBeUndefined(); + }); + + it('registers renderCanvasContent + getActionButtons when both application and resolver are provided', () => { + const def = buildDefinition(); + expect(def.renderCanvasContent).toBeInstanceOf(Function); + expect(def.getActionButtons).toBeInstanceOf(Function); + }); + + it('sets a narrower canvasWidth so the entity flyout overview matches the in-app flyout rail', () => { + const def = buildDefinition(); + expect(def.canvasWidth).toBe('640px'); + }); + }); + + describe('searchSession threading', () => { + const renderProps = { + attachment: attachmentOf({ identifierType: 'host', identifier: 'alpha' }), + isSidebar: false, + isCanvas: true, + updateOrigin: jest.fn(), + } as unknown as Parameters< + NonNullable['renderInlineContent']> + >[0]; + const renderCallbacks = {} as unknown as Parameters< + NonNullable['renderCanvasContent']> + >[1]; + + it('forwards searchSession into the canvas content element', () => { + const searchSession = { clear: jest.fn() } as unknown as ISessionService; + const def = buildDefinition({ searchSession }); + + const suspenseElement = def.renderCanvasContent!( + renderProps, + renderCallbacks + ) as ReactElement<{ + children: ReactElement<{ searchSession?: ISessionService }>; + }>; + + expect(suspenseElement.props.children.props.searchSession).toBe(searchSession); + }); + + it('forwards searchSession into the inline content element', () => { + const searchSession = { clear: jest.fn() } as unknown as ISessionService; + const def = buildDefinition({ searchSession }); + + const suspenseElement = def.renderInlineContent!(renderProps) as ReactElement<{ + children: ReactElement<{ searchSession?: ISessionService }>; + }>; + + expect(suspenseElement.props.children.props.searchSession).toBe(searchSession); + }); + }); + + describe('getLabel', () => { + it('returns the default label for a single-entity payload', () => { + const def = buildDefinition(); + expect(def.getLabel(attachmentOf({ identifierType: 'host', identifier: 'alpha' }))).toBe( + 'Risk Entity' + ); + }); + + it('returns a pluralised label for a multi-entity payload', () => { + const def = buildDefinition(); + expect( + def.getLabel( + attachmentOf({ + entities: [ + { identifierType: 'host', identifier: 'a' }, + { identifierType: 'user', identifier: 'b' }, + { identifierType: 'service', identifier: 'c' }, + ], + }) + ) + ).toBe('3 Risk Entities'); + }); + + it('prefers an explicit attachmentLabel when provided', () => { + const def = buildDefinition(); + expect( + def.getLabel( + attachmentOf({ + identifierType: 'host', + identifier: 'alpha', + attachmentLabel: 'Custom Label', + }) + ) + ).toBe('Custom Label'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.tsx new file mode 100644 index 0000000000000..b48e63cae2323 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_definition.tsx @@ -0,0 +1,216 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { + AttachmentUIDefinition, + AttachmentRenderProps, +} from '@kbn/agent-builder-browser/attachments'; +import { ActionButtonType } from '@kbn/agent-builder-browser/attachments'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; +import type { ISessionService } from '@kbn/data-plugin/public'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSkeletonText } from '@elastic/eui'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import { APP_UI_ID } from '../../../../common/constants'; +import type { EntityAttachment } from './types'; +import { isFlyoutCapableIdentifierType } from './types'; +import { normaliseEntityAttachment } from './payload'; +import type { SecurityCanvasEmbeddedBundle } from '../../components/security_redux_embedded_provider'; +import { + buildEntityRightPanel, + navigateToEntityAnalyticsHomePageInApp, + navigateToEntityAnalyticsWithFlyoutInApp, + type SecurityAgentBuilderChrome, +} from '../entity_explore_navigation'; + +const DEFAULT_LABEL = i18n.translate( + 'xpack.securitySolution.agentBuilder.attachments.entity.label', + { defaultMessage: 'Risk Entity' } +); + +const DEFAULT_LABEL_PLURAL = (count: number) => + i18n.translate('xpack.securitySolution.agentBuilder.attachments.entity.labelPlural', { + defaultMessage: '{count} Risk Entities', + values: { count }, + }); + +const PREVIEW_LABEL = i18n.translate( + 'xpack.securitySolution.agentBuilder.attachments.entity.preview', + { defaultMessage: 'Preview' } +); + +const OPEN_IN_ENTITY_ANALYTICS_LABEL = i18n.translate( + 'xpack.securitySolution.agentBuilder.attachments.entity.openInEntityAnalytics', + { defaultMessage: 'Open in Entity Analytics' } +); + +/** + * Preferred width for the entity flyout canvas. The rendered content is the Security + * expandable-flyout overview (`HostPanelContent` / `UserPanelContent` / `ServicePanelContent`) + * which is designed for a narrow rail; at the default `50vw` the entity summary grid and + * highlights look over-stretched on wide monitors. Narrowing the canvas keeps parity with + * the in-app flyout look. + */ +const ENTITY_CANVAS_WIDTH = '640px'; + +/** + * Lazy-loaded inline renderer — pulls `EntityAttachmentInlineContent` (entity card / table) + * into its own chunk so the main `securitySolution` entry bundle doesn't pick up the entity + * analytics card + table dependencies until an attachment is actually rendered in the chat. + */ +const LazyEntityAttachmentInlineContent = React.lazy(() => + import( + /* webpackChunkName: "security_entity_attachment_inline" */ + './entity_attachment_inline_content' + ).then((m) => ({ default: m.EntityAttachmentInlineContent })) +); + +/** + * Lazy-loaded canvas renderer — pulls `SecurityReduxEmbeddedProvider`, + * `EntityCardFlyoutOverviewCanvas`, and the full Security expandable-flyout overview into a + * separate chunk. This chunk only downloads when the user clicks the `Preview` action button, + * keeping the page-load bundle lean for users who never open an entity canvas. + */ +const LazyEntityAttachmentCanvasContent = React.lazy(() => + import( + /* webpackChunkName: "security_entity_attachment_canvas" */ + './entity_attachment_canvas_content' + ).then((m) => ({ default: m.EntityAttachmentCanvasContent })) +); + +/** + * Builds the rich `AttachmentUIDefinition` for `security.entity` attachments. + * + * When `application` + `resolveSecurityCanvasContext` are provided, the definition also adds a + * Canvas (Preview) view for single host/user/service entities. Multi-entity attachments keep the + * inline-only rendering path; navigation is handled via per-row Explore icons in the table. + */ +export const createEntityAttachmentDefinition = ({ + experimentalFeatures, + application, + agentBuilder, + chrome, + resolveSecurityCanvasContext, + searchSession, +}: { + experimentalFeatures: ExperimentalFeatures; + application?: ApplicationStart; + agentBuilder?: AgentBuilderPluginStart; + chrome?: SecurityAgentBuilderChrome; + resolveSecurityCanvasContext?: () => Promise; + searchSession?: ISessionService; +}): AttachmentUIDefinition => { + const baseDefinition: AttachmentUIDefinition = { + getLabel: (attachment) => { + const customLabel = attachment?.data?.attachmentLabel; + if (customLabel) return customLabel; + const parsed = normaliseEntityAttachment(attachment); + if (!parsed) return DEFAULT_LABEL; + if (parsed.isSingle) return DEFAULT_LABEL; + return DEFAULT_LABEL_PLURAL(parsed.entities.length); + }, + getIcon: () => 'user', + renderInlineContent: (props) => ( + }> + + + ), + }; + + if (application == null || resolveSecurityCanvasContext == null) { + return baseDefinition; + } + + const resolvedApplication = application; + const resolvedResolveCanvasContext = resolveSecurityCanvasContext; + + return { + ...baseDefinition, + canvasWidth: ENTITY_CANVAS_WIDTH, + renderCanvasContent: (props: AttachmentRenderProps) => ( + + + + + + } + > + + + ), + getActionButtons: ({ attachment, isCanvas, openCanvas, openSidebarConversation }) => { + const parsed = normaliseEntityAttachment(attachment); + if (!parsed || !parsed.isSingle) { + return []; + } + const identifier = parsed.entities[0]; + if (!isFlyoutCapableIdentifierType(identifier.identifierType)) { + return []; + } + if (isCanvas) { + return [ + { + label: OPEN_IN_ENTITY_ANALYTICS_LABEL, + icon: 'popout', + type: ActionButtonType.SECONDARY, + handler: () => { + const rightPanel = buildEntityRightPanel(identifier); + if (rightPanel) { + navigateToEntityAnalyticsWithFlyoutInApp({ + application: resolvedApplication, + appId: APP_UI_ID, + flyout: { preview: [], right: rightPanel }, + agentBuilder, + chrome, + openSidebarConversation, + searchSession, + }); + return; + } + navigateToEntityAnalyticsHomePageInApp({ + application: resolvedApplication, + appId: APP_UI_ID, + agentBuilder, + chrome, + openSidebarConversation, + searchSession, + }); + }, + }, + ]; + } + if (!openCanvas) { + return []; + } + return [ + { + label: PREVIEW_LABEL, + icon: 'eye', + type: ActionButtonType.SECONDARY, + handler: openCanvas, + }, + ]; + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_inline_content.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_inline_content.test.tsx new file mode 100644 index 0000000000000..893becbfb97c1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/entity_attachment/entity_attachment_inline_content.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { ExperimentalFeatures } from '../../../../common/experimental_features'; +import type { EntityAttachment } from './types'; +import { + ENTITY_ATTACHMENT_ROOT_CLASS, + EntityAttachmentInlineContent, +} from './entity_attachment_inline_content'; + +jest.mock('./entity_card/entity_card', () => ({ + EntityCard: (props: Record) => ( +
+ {JSON.stringify(props.identifier)} +
+ ), +})); + +jest.mock('./entity_table/entity_table', () => ({ + EntityTable: (props: Record) => ( +
{`count:${(props.entities as unknown[]).length}`}
+ ), +})); + +const experimentalFeatures = { + entityAnalyticsWatchlistEnabled: true, + enableRiskScorePrivmonModifier: true, +} as unknown as ExperimentalFeatures; + +const attachment = (data: unknown): EntityAttachment => + ({ + id: 'a', + type: 'security.entity', + data: data as EntityAttachment['data'], + } as EntityAttachment); + +const renderDispatcher = (data: unknown) => + render( + + + + ); + +describe('EntityAttachmentInlineContent', () => { + it('renders the card for a legacy single-identifier payload', () => { + renderDispatcher({ identifierType: 'host', identifier: 'alpha' }); + expect(screen.getByTestId('entityCardMock')).toBeInTheDocument(); + expect(screen.queryByTestId('entityTableMock')).not.toBeInTheDocument(); + expect(screen.getByTestId('entityCardMock').textContent).toContain('alpha'); + }); + + it('renders the card for a single-element entities list', () => { + renderDispatcher({ entities: [{ identifierType: 'user', identifier: 'bob' }] }); + expect(screen.getByTestId('entityCardMock')).toBeInTheDocument(); + expect(screen.queryByTestId('entityTableMock')).not.toBeInTheDocument(); + }); + + it('renders the table for a multi-entity payload', () => { + renderDispatcher({ + entities: [ + { identifierType: 'host', identifier: 'alpha' }, + { identifierType: 'user', identifier: 'bob' }, + ], + }); + expect(screen.getByTestId('entityTableMock')).toBeInTheDocument(); + expect(screen.queryByTestId('entityCardMock')).not.toBeInTheDocument(); + expect(screen.getByTestId('entityTableMock').textContent).toContain('count:2'); + }); + + it('renders the graceful empty callout for unusable payloads', () => { + renderDispatcher({ foo: 'bar' }); + expect(screen.getByTestId('entityAttachmentEmpty')).toBeInTheDocument(); + expect(screen.queryByTestId('entityCardMock')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entityTableMock')).not.toBeInTheDocument(); + }); + + it('forwards watchlist and privmon modifier flags to the EntityCard', () => { + renderDispatcher({ identifierType: 'host', identifier: 'alpha' }); + const card = screen.getByTestId('entityCardMock'); + expect(card.getAttribute('data-watchlists-enabled')).toBe('true'); + expect(card.getAttribute('data-privmon-modifier-enabled')).toBe('true'); + }); + + it('forwards entityStoreId from a single-entity payload to the EntityCard identifier', () => { + renderDispatcher({ + identifierType: 'user', + identifier: "Lena Medhurst@Lena's MacBook Pro", + entityStoreId: "user:Lena Medhurst@Lena's MacBook Pro@local", + }); + const card = screen.getByTestId('entityCardMock'); + const serialized = JSON.parse(card.textContent ?? '{}'); + expect(serialized).toEqual({ + identifierType: 'user', + identifier: "Lena Medhurst@Lena's MacBook Pro", + entityStoreId: "user:Lena Medhurst@Lena's MacBook Pro@local", + }); + }); + + it('forwards entityStoreId from a single-element entities list to the EntityCard identifier', () => { + renderDispatcher({ + entities: [ + { + identifierType: 'user', + identifier: 'bob', + entityStoreId: 'user:bob@workstation@local', + }, + ], + }); + const card = screen.getByTestId('entityCardMock'); + const serialized = JSON.parse(card.textContent ?? '{}'); + expect(serialized.entityStoreId).toBe('user:bob@workstation@local'); + }); + + describe('outer-panel spacing workaround', () => { + it('wraps valid payloads in a div with the ENTITY_ATTACHMENT_ROOT_CLASS marker', () => { + const { container } = renderDispatcher({ identifierType: 'host', identifier: 'alpha' }); + const marker = container.querySelector(`.${ENTITY_ATTACHMENT_ROOT_CLASS}`); + expect(marker).not.toBeNull(); + expect(marker).toContainElement(screen.getByTestId('entityCardMock')); + }); + + it('wraps the empty callout in a div with the ENTITY_ATTACHMENT_ROOT_CLASS marker', () => { + const { container } = renderDispatcher({ foo: 'bar' }); + const marker = container.querySelector(`.${ENTITY_ATTACHMENT_ROOT_CLASS}`); + expect(marker).not.toBeNull(); + expect(marker).toContainElement(screen.getByTestId('entityAttachmentEmpty')); + }); + + it('mounts a global style that scopes the margin to .euiSplitPanel ancestors of the marker', () => { + renderDispatcher({ identifierType: 'host', identifier: 'alpha' }); + // Emotion's injects rules into `document.styleSheets` via + // `CSSStyleSheet.insertRule` in jsdom, so the