From 92b2ec62d60d5d335a1b85d7edfe16d4b166db66 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Sat, 29 Jun 2024 13:47:42 -0600 Subject: [PATCH] [Security solution] Attack discovery connector dropdown notification badges (#187209) --- .../kbn_elastic_assistant_common.devdocs.json | 6 +- .../attack_discovery/common_attributes.gen.ts | 54 ++++- .../common_attributes.schema.yaml | 51 ++++- .../get_attack_discovery_route.gen.ts | 6 +- .../get_attack_discovery_route.schema.yaml | 10 +- ...attack_discovery_status_indicator.test.tsx | 61 ++++++ .../attack_discovery_status_indicator.tsx | 71 ++++++ .../connector_selector/index.tsx | 30 ++- .../connector_selector/translations.ts | 26 +++ .../connector_selector_inline.tsx | 5 + .../impl/connectorland/translations.ts | 28 +-- .../__mocks__/attack_discovery_schema.mock.ts | 1 + .../server/__mocks__/data_clients.mock.ts | 1 + .../create_attack_discovery.ts | 1 + .../field_maps_configuration.ts | 9 +- .../find_all_attack_discoveries.ts | 64 ++++++ .../attack_discovery/index.ts | 23 +- .../attack_discovery/transforms.ts | 1 + .../attack_discovery/types.ts | 6 +- .../update_attack_discovery.test.ts | 1 + .../update_attack_discovery.ts | 9 +- .../get_attack_discovery.test.ts | 51 ++++- .../attack_discovery/get_attack_discovery.ts | 13 +- .../routes/attack_discovery/helpers.test.ts | 205 ++++++++++++++++++ .../server/routes/attack_discovery/helpers.ts | 56 +++++ .../plugins/elastic_assistant/tsconfig.json | 1 + .../hooks/use_poll_api.test.tsx | 74 ++++--- .../attack_discovery/hooks/use_poll_api.tsx | 50 ++++- .../mock/mock_use_attack_discovery.ts | 2 + .../pages/header/index.test.tsx | 7 + .../attack_discovery/pages/header/index.tsx | 7 +- .../pages/header/status_bell/index.tsx | 68 ++++++ .../pages/header/status_bell/translations.ts | 21 ++ .../public/attack_discovery/pages/index.tsx | 2 + .../use_attack_discovery/index.test.tsx | 13 +- .../use_attack_discovery/index.tsx | 17 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 39 files changed, 936 insertions(+), 124 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/attack_discovery_status_indicator.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/attack_discovery_status_indicator.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/translations.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/index.tsx create mode 100644 x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/translations.ts diff --git a/api_docs/kbn_elastic_assistant_common.devdocs.json b/api_docs/kbn_elastic_assistant_common.devdocs.json index 2e245eee05c74..96ae9d0c04f2d 100644 --- a/api_docs/kbn_elastic_assistant_common.devdocs.json +++ b/api_docs/kbn_elastic_assistant_common.devdocs.json @@ -983,7 +983,7 @@ "label": "AttackDiscoveryGetResponse", "description": [], "signature": [ - "{ entryExists: boolean; data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }" + "{ data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }" ], "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts", "deprecated": false, @@ -3387,7 +3387,7 @@ "label": "AttackDiscoveryGetResponse", "description": [], "signature": [ - "Zod.ZodObject<{ data: Zod.ZodOptional; updatedAt: Zod.ZodOptional; alertsContextCount: Zod.ZodOptional; createdAt: Zod.ZodString; replacements: Zod.ZodOptional, Zod.objectInputType<{}, Zod.ZodString, \"strip\">>>; users: Zod.ZodArray; name: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id?: string | undefined; name?: string | undefined; }, { id?: string | undefined; name?: string | undefined; }>, \"many\">; status: Zod.ZodEnum<[\"running\", \"succeeded\", \"failed\", \"canceled\"]>; attackDiscoveries: Zod.ZodArray; id: Zod.ZodOptional; detailsMarkdown: Zod.ZodString; entitySummaryMarkdown: Zod.ZodString; mitreAttackTactics: Zod.ZodOptional>; summaryMarkdown: Zod.ZodString; title: Zod.ZodString; timestamp: Zod.ZodString; }, \"strip\", Zod.ZodTypeAny, { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }, { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }>, \"many\">; apiConfig: Zod.ZodObject<{ connectorId: Zod.ZodString; actionTypeId: Zod.ZodString; defaultSystemPromptId: Zod.ZodOptional; provider: Zod.ZodOptional>; model: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }, { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }>; namespace: Zod.ZodString; backingIndex: Zod.ZodString; generationIntervals: Zod.ZodArray, \"many\">; averageIntervalMs: Zod.ZodNumber; failureReason: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; }, { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectInputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; }>>; entryExists: Zod.ZodBoolean; }, \"strip\", Zod.ZodTypeAny, { entryExists: boolean; data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }, { entryExists: boolean; data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectInputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }>" + "Zod.ZodObject<{ data: Zod.ZodOptional; updatedAt: Zod.ZodOptional; alertsContextCount: Zod.ZodOptional; createdAt: Zod.ZodString; replacements: Zod.ZodOptional, Zod.objectInputType<{}, Zod.ZodString, \"strip\">>>; users: Zod.ZodArray; name: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id?: string | undefined; name?: string | undefined; }, { id?: string | undefined; name?: string | undefined; }>, \"many\">; status: Zod.ZodEnum<[\"running\", \"succeeded\", \"failed\", \"canceled\"]>; attackDiscoveries: Zod.ZodArray; id: Zod.ZodOptional; detailsMarkdown: Zod.ZodString; entitySummaryMarkdown: Zod.ZodString; mitreAttackTactics: Zod.ZodOptional>; summaryMarkdown: Zod.ZodString; title: Zod.ZodString; timestamp: Zod.ZodString; }, \"strip\", Zod.ZodTypeAny, { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }, { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }>, \"many\">; apiConfig: Zod.ZodObject<{ connectorId: Zod.ZodString; actionTypeId: Zod.ZodString; defaultSystemPromptId: Zod.ZodOptional; provider: Zod.ZodOptional>; model: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }, { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }>; namespace: Zod.ZodString; backingIndex: Zod.ZodString; generationIntervals: Zod.ZodArray, \"many\">; averageIntervalMs: Zod.ZodNumber; failureReason: Zod.ZodOptional; }, \"strip\", Zod.ZodTypeAny, { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; }, { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectInputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; }>>; }, \"strip\", Zod.ZodTypeAny, { data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectOutputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }, { data?: { id: string; namespace: string; createdAt: string; status: \"running\" | \"succeeded\" | \"failed\" | \"canceled\"; users: { id?: string | undefined; name?: string | undefined; }[]; apiConfig: { connectorId: string; actionTypeId: string; defaultSystemPromptId?: string | undefined; provider?: \"OpenAI\" | \"Azure OpenAI\" | undefined; model?: string | undefined; }; attackDiscoveries: { timestamp: string; title: string; alertIds: string[]; detailsMarkdown: string; entitySummaryMarkdown: string; summaryMarkdown: string; id?: string | undefined; mitreAttackTactics?: string[] | undefined; }[]; backingIndex: string; generationIntervals: { date: string; durationMs: number; }[]; averageIntervalMs: number; timestamp?: string | undefined; updatedAt?: string | undefined; alertsContextCount?: number | undefined; replacements?: Zod.objectInputType<{}, Zod.ZodString, \"strip\"> | undefined; failureReason?: string | undefined; } | undefined; }>" ], "path": "x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts", "deprecated": false, @@ -4958,4 +4958,4 @@ } ] } -} \ No newline at end of file +} diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts index 533acefe02156..57b79b87fe4f9 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts @@ -87,6 +87,48 @@ export const GenerationInterval = z.object({ durationMs: z.number().int(), }); +/** + * Attack discovery stats + */ +export type AttackDiscoveryStat = z.infer; +export const AttackDiscoveryStat = z.object({ + /** + * Whether the user has viewed the results of the attack discovery run + */ + hasViewed: z.boolean(), + /** + * The number of attack discoveries for the connector + */ + count: z.number().int(), + /** + * The connector ID for the attack discovery + */ + connectorId: z.string(), + /** + * The status of the attack discovery. + */ + status: AttackDiscoveryStatus, +}); + +/** + * Stats on existing attack discovery documents + */ +export type AttackDiscoveryStats = z.infer; +export const AttackDiscoveryStats = z.object({ + /** + * The number of attack discoveries that have not yet been viewed + */ + newDiscoveriesCount: z.number().int(), + /** + * The number of connectors with new results that have not yet been viewed + */ + newConnectorResultsCount: z.number().int(), + /** + * Attack discovery stats per connector + */ + statsPerConnector: z.array(AttackDiscoveryStat), +}); + export type AttackDiscoveryResponse = z.infer; export const AttackDiscoveryResponse = z.object({ id: NonEmptyString, @@ -94,7 +136,11 @@ export const AttackDiscoveryResponse = z.object({ /** * The last time attack discovery was updated. */ - updatedAt: z.string().optional(), + updatedAt: z.string(), + /** + * The last time attack discovery was viewed in the browser. + */ + lastViewedAt: z.string(), /** * The number of alerts in the context. */ @@ -157,7 +203,7 @@ export const AttackDiscoveryUpdateProps = z.object({ /** * The status of the attack discovery. */ - status: AttackDiscoveryStatus, + status: AttackDiscoveryStatus.optional(), replacements: Replacements.optional(), /** * The most 5 recent generation intervals @@ -171,6 +217,10 @@ export const AttackDiscoveryUpdateProps = z.object({ * The reason for a status of failed. */ failureReason: z.string().optional(), + /** + * The last time attack discovery was viewed in the browser. + */ + lastViewedAt: z.string().optional(), }); export type AttackDiscoveryCreateProps = z.infer; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml index 634b5f0192a60..dcb72147f9408 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml @@ -74,6 +74,48 @@ components: description: The duration of the attack discovery generation type: integer + AttackDiscoveryStat: + type: object + description: Attack discovery stats + required: + - 'hasViewed' + - 'status' + - 'count' + - 'connectorId' + properties: + hasViewed: + description: Whether the user has viewed the results of the attack discovery run + type: boolean + count: + description: The number of attack discoveries for the connector + type: integer + connectorId: + description: The connector ID for the attack discovery + type: string + status: + $ref: '#/components/schemas/AttackDiscoveryStatus' + description: The status of the attack discovery. + + AttackDiscoveryStats: + type: object + description: Stats on existing attack discovery documents + required: + - 'newDiscoveriesCount' + - 'newConnectorResultsCount' + - 'statsPerConnector' + properties: + newDiscoveriesCount: + description: The number of attack discoveries that have not yet been viewed + type: integer + newConnectorResultsCount: + description: The number of connectors with new results that have not yet been viewed + type: integer + statsPerConnector: + type: array + description: Attack discovery stats per connector + items: + $ref: '#/components/schemas/AttackDiscoveryStat' + AttackDiscoveryResponse: type: object @@ -81,6 +123,8 @@ components: - apiConfig - id - createdAt + - updatedAt + - lastViewedAt - users - namespace - attackDiscoveries @@ -96,6 +140,9 @@ components: updatedAt: description: The last time attack discovery was updated. type: string + lastViewedAt: + description: The last time attack discovery was viewed in the browser. + type: string alertsContextCount: type: integer description: The number of alerts in the context. @@ -139,7 +186,6 @@ components: type: object required: - id - - status - backingIndex properties: id: @@ -169,6 +215,9 @@ components: failureReason: type: string description: The reason for a status of failed. + lastViewedAt: + description: The last time attack discovery was viewed in the browser. + type: string AttackDiscoveryCreateProps: type: object diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts index 3e58606df8298..00eb09809614b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/get_attack_discovery_route.gen.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; import { NonEmptyString } from '../common_attributes.gen'; -import { AttackDiscoveryResponse } from './common_attributes.gen'; +import { AttackDiscoveryResponse, AttackDiscoveryStat } from './common_attributes.gen'; export type AttackDiscoveryGetRequestParams = z.infer; export const AttackDiscoveryGetRequestParams = z.object({ @@ -32,7 +32,7 @@ export type AttackDiscoveryGetResponse = z.infer { + it('renders loading spinner when status is running and hasViewed is false', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('status-running')).toBeInTheDocument(); + }); + it('renders loading spinner when status is running and hasViewed is true', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('status-running')).toBeInTheDocument(); + }); + + it('renders null when status is not running hasViewed is true', () => { + const { queryByTestId } = render( + + ); + + expect(queryByTestId('status-succeeded')).not.toBeInTheDocument(); + }); + + it('renders succeeded count badge when status is succeeded and count is not null', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('status-succeeded')).toBeInTheDocument(); + }); + + it('renders failed badge when status is failed', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('status-failed')).toBeInTheDocument(); + }); + + it('renders null when status is canceled', () => { + const { queryByTestId } = render( + + ); + + expect(queryByTestId('status-running')).not.toBeInTheDocument(); + expect(queryByTestId('status-succeeded')).not.toBeInTheDocument(); + expect(queryByTestId('status-failed')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/attack_discovery_status_indicator.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/attack_discovery_status_indicator.tsx new file mode 100644 index 0000000000000..7041028eb1b61 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/attack_discovery_status_indicator.tsx @@ -0,0 +1,71 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { AttackDiscoveryStatus } from '@kbn/elastic-assistant-common'; +import { + EuiFlexItem, + EuiLoadingSpinner, + EuiIconTip, + EuiNotificationBadge, + EuiToolTip, +} from '@elastic/eui'; +import * as i18n from './translations'; + +interface Props { + hasViewed: boolean; + status: AttackDiscoveryStatus; + count: number; +} +export const AttackDiscoveryStatusIndicator: FunctionComponent = ({ + hasViewed, + status, + count, +}) => { + if (status === 'running') { + return ( + + + + + + ); + } + if (hasViewed) return null; + if (status === 'succeeded' && count != null) { + return ( + + + {count} + + + ); + } + if (status === 'failed') { + return ( + + + + ); + } + return null; +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx index a6070bc05a97a..410ee650c43ef 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/index.tsx @@ -12,6 +12,8 @@ import { ActionConnector, ActionType } from '@kbn/triggers-actions-ui-plugin/pub import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; import { some } from 'lodash'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; +import { AttackDiscoveryStatusIndicator } from './attack_discovery_status_indicator'; import { useLoadConnectors } from '../use_load_connectors'; import * as i18n from '../translations'; import { useLoadActionTypes } from '../use_load_action_types'; @@ -29,6 +31,7 @@ interface Props { displayFancy?: (displayText: string) => React.ReactNode; setIsOpen?: (isOpen: boolean) => void; isFlyoutMode: boolean; + stats?: AttackDiscoveryStats | null; } export type AIConnector = ActionConnector & { @@ -45,6 +48,7 @@ export const ConnectorSelector: React.FC = React.memo( onConnectorSelectionChange, setIsOpen, isFlyoutMode, + stats = null, }) => { const { actionTypeRegistry, http, assistantAvailability } = useAssistantContext(); // Connector Modal State @@ -91,23 +95,35 @@ export const ConnectorSelector: React.FC = React.memo( const connectorDetails = connector.isPreconfigured ? i18n.PRECONFIGURED_CONNECTOR : connectorTypeTitle; + const attackDiscoveryStats = + stats !== null + ? stats.statsPerConnector.find((s) => s.connectorId === connector.id) ?? null + : null; + return { value: connector.id, 'data-test-subj': connector.id, inputDisplay: displayFancy?.(connector.name) ?? connector.name, dropdownDisplay: ( - {connector.name} - {connectorDetails && ( - -

{connectorDetails}

-
- )} + + + {connector.name} + {connectorDetails && ( + +

{connectorDetails}

+
+ )} +
+ {attackDiscoveryStats && ( + + )} +
), }; }), - [actionTypeRegistry, aiConnectors, displayFancy] + [actionTypeRegistry, aiConnectors, displayFancy, stats] ); const connectorExists = useMemo( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/translations.ts new file mode 100644 index 0000000000000..2719db05b9d39 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SUCCESS_MESSAGE = (totalAttacks: number) => + i18n.translate('xpack.elasticAssistant.attackDiscovery.statusSuccess', { + values: { totalAttacks }, + defaultMessage: + 'The connector has updated with {totalAttacks} potential {totalAttacks, plural, =1 {attack} other {attacks}}', + }); + +export const IN_PROGRESS_MESSAGE = i18n.translate( + 'xpack.elasticAssistant.attackDiscovery.statusInProgress', + { + defaultMessage: 'Attack discovery generation in progress.', + } +); + +export const FAILURE_MESSAGE = i18n.translate('xpack.elasticAssistant.attackDiscovery.statusFail', { + defaultMessage: 'The connector encountered an error while generating attack discoveries.', +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx index 65e388862bd53..ebf762530af11 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_selector_inline/connector_selector_inline.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useState } from 'react'; import { css } from '@emotion/css'; import { euiThemeVars } from '@kbn/ui-theme'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { AIConnector, ConnectorSelector } from '../connector_selector'; import { Conversation } from '../../..'; import { useLoadConnectors } from '../use_load_connectors'; @@ -27,6 +28,7 @@ interface Props { isFlyoutMode: boolean; onConnectorIdSelected?: (connectorId: string) => void; onConnectorSelected?: (conversation: Conversation) => void; + stats?: AttackDiscoveryStats | null; } const inputContainerClassName = css` @@ -71,6 +73,7 @@ export const ConnectorSelectorInline: React.FC = React.memo( onConnectorIdSelected, onConnectorSelected, + stats = null, }) => { const [isOpen, setIsOpen] = useState(false); const { assistantAvailability, http } = useAssistantContext(); @@ -153,6 +156,7 @@ export const ConnectorSelectorInline: React.FC = React.memo( setIsOpen={setIsOpen} onConnectorSelectionChange={onChange} isFlyoutMode={isFlyoutMode} + stats={stats} /> @@ -182,6 +186,7 @@ export const ConnectorSelectorInline: React.FC = React.memo( setIsOpen={setIsOpen} onConnectorSelectionChange={onChange} isFlyoutMode={isFlyoutMode} + stats={stats} /> ) : ( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts index 1ce84fdb6e9b6..11d214afc4faf 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/translations.ts @@ -10,24 +10,14 @@ import { i18n } from '@kbn/i18n'; export const LOAD_ACTIONS_ERROR_MESSAGE = i18n.translate( 'xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage', { - defaultMessage: - 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ', + defaultMessage: 'An error occurred loading the Kibana Actions. ', } ); export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate( 'xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage', { - defaultMessage: - 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into your Elastic Life. ', - } -); - -export const WELCOME_SECURITY = i18n.translate( - 'xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt', - { - defaultMessage: - 'Welcome to your Elastic AI Assistant! I am your 100% open-source portal into Elastic Security. ', + defaultMessage: 'An error occurred loading the Kibana Connectors. ', } ); @@ -59,13 +49,6 @@ export const ADD_CONNECTOR = i18n.translate( } ); -export const INLINE_CONNECTOR_LABEL = i18n.translate( - 'xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorLabel', - { - defaultMessage: 'Connector:', - } -); - export const INLINE_CONNECTOR_PLACEHOLDER = i18n.translate( 'xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorPlaceholder', { @@ -129,13 +112,6 @@ export const CONNECTOR_SETUP_SKIP = i18n.translate( } ); -export const CONNECTOR_SETUP_COMPLETE = i18n.translate( - 'xpack.elasticAssistant.assistant.connectors.setup.complete', - { - defaultMessage: 'Connector setup complete!', - } -); - export const MISSING_CONNECTOR_CALLOUT_TITLE = i18n.translate( 'xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.calloutTitle', { diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts index 156011cfbae14..9e8a0b5d2ac90 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts @@ -68,6 +68,7 @@ export const getAttackDiscoverySearchEsMock = () => { }, ], updated_at: '2024-06-07T21:19:08.090Z', + last_viewed_at: '2024-06-07T21:19:08.090Z', replacements: [ { uuid: 'f19e1a0a-de3b-496c-8ace-dd91229e1084', diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts index a5196f93b6917..7e20e292a9868 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts @@ -38,6 +38,7 @@ export const conversationsDataClientMock: { const createAttackDiscoveryDataClientMock = (): AttackDiscoveryDataClientMock => ({ getAttackDiscovery: jest.fn(), createAttackDiscovery: jest.fn(), + findAllAttackDiscoveries: jest.fn(), findAttackDiscoveryByConnectorId: jest.fn(), updateAttackDiscovery: jest.fn(), getReader: jest.fn(), diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts index e7df1fbb020f9..7304ab3488529 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts @@ -95,6 +95,7 @@ export const transformToCreateScheme = ( timestamp: attackDiscovery.timestamp ?? createdAt, })), updated_at: createdAt, + last_viewed_at: createdAt, replacements: replacements ? Object.keys(replacements).map((key) => ({ uuid: key, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts index 51773489c4d6b..6b3383337e2d6 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts @@ -32,15 +32,20 @@ export const attackDiscoveryFieldMap: FieldMap = { array: false, required: true, }, + last_viewed_at: { + type: 'date', + array: false, + required: true, + }, updated_at: { type: 'date', array: false, - required: false, + required: true, }, created_at: { type: 'date', array: false, - required: false, + required: true, }, attack_discoveries: { type: 'nested', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts new file mode 100644 index 0000000000000..e80d1e4589838 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; +import { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { EsAttackDiscoverySchema } from './types'; +import { transformESSearchToAttackDiscovery } from './transforms'; +const MAX_ITEMS = 10000; +export interface FindAllAttackDiscoveriesParams { + esClient: ElasticsearchClient; + logger: Logger; + attackDiscoveryIndex: string; + user: AuthenticatedUser; +} + +export const findAllAttackDiscoveries = async ({ + esClient, + logger, + attackDiscoveryIndex, + user, +}: FindAllAttackDiscoveriesParams): Promise => { + const filterByUser = [ + { + nested: { + path: 'users', + query: { + bool: { + must: [ + { + match: user.profile_uid + ? { 'users.id': user.profile_uid } + : { 'users.name': user.username }, + }, + ], + }, + }, + }, + }, + ]; + try { + const response = await esClient.search({ + query: { + bool: { + must: [...filterByUser], + }, + }, + size: MAX_ITEMS, + _source: true, + ignore_unavailable: true, + index: attackDiscoveryIndex, + seq_no_primary_term: true, + }); + const attackDiscoveries = transformESSearchToAttackDiscovery(response); + return attackDiscoveries ?? []; + } catch (err) { + logger.error(`Error fetching attack discoveries: ${err}`); + throw err; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts index b8b1ef12b668c..ca053743c8035 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts @@ -11,6 +11,7 @@ import { AttackDiscoveryResponse, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; +import { findAllAttackDiscoveries } from './find_all_attack_discoveries'; import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; import { updateAttackDiscovery } from './update_attack_discovery'; import { createAttackDiscovery } from './create_attack_discovery'; @@ -78,7 +79,7 @@ export class AttackDiscoveryDataClient extends AIAssistantDataClient { * @param options * @param options.connectorId * @param options.authenticatedUser - * @returns The Attack Discovery created + * @returns The Attack Discovery found */ public findAttackDiscoveryByConnectorId = async ({ connectorId, @@ -97,6 +98,26 @@ export class AttackDiscoveryDataClient extends AIAssistantDataClient { }); }; + /** + * Finds all attack discovery for authenticated user + * @param options + * @param options.authenticatedUser + * @returns The Attack Discovery + */ + public findAllAttackDiscoveries = async ({ + authenticatedUser, + }: { + authenticatedUser: AuthenticatedUser; + }): Promise => { + const esClient = await this.options.elasticsearchClientPromise; + return findAllAttackDiscoveries({ + esClient, + logger: this.options.logger, + attackDiscoveryIndex: this.indexTemplateAndPattern.alias, + user: authenticatedUser, + }); + }; + /** * Updates an attack discovery * @param options diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts index d23757fd053d0..d9a37582f48b0 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts @@ -24,6 +24,7 @@ export const transformESSearchToAttackDiscovery = ( backingIndex: hit._index, createdAt: adSchema.created_at, updatedAt: adSchema.updated_at, + lastViewedAt: adSchema.last_viewed_at, users: adSchema.users?.map((user) => ({ id: user.id, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts index 6257be7f82431..4a17c50e06af4 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts @@ -34,7 +34,8 @@ export interface EsAttackDiscoverySchema { alerts_context_count?: number; replacements?: EsReplacementSchema[]; status: AttackDiscoveryStatus; - updated_at?: string; + updated_at: string; + last_viewed_at: string; users?: Array<{ id?: string; name?: string; @@ -71,6 +72,7 @@ export interface CreateAttackDiscoverySchema { id?: string; name?: string; }>; - updated_at?: string; + updated_at: string; + last_viewed_at: string; namespace: string; } diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts index 9664465c264b9..24deda445f320 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts @@ -68,6 +68,7 @@ const existingAttackDiscovery: AttackDiscoveryResponse = { connectorId: 'my-gpt4o-ai', }, attackDiscoveries: [], + lastViewedAt: '2024-06-07T21:19:08.090Z', updatedAt: '2024-06-07T21:19:08.090Z', replacements: { 'f19e1a0a-de3b-496c-8ace-dd91229e1084': 'root', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts index 667e5e36e82f5..73a386bbb4362 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts @@ -41,8 +41,9 @@ export interface UpdateAttackDiscoverySchema { average_interval_ms?: number; generation_intervals?: Array<{ date: string; duration_ms: number }>; replacements?: EsReplacementSchema[]; - status: AttackDiscoveryStatus; + status?: AttackDiscoveryStatus; updated_at?: string; + last_viewed_at?: string; failure_reason?: string; } @@ -96,6 +97,7 @@ export const transformToUpdateScheme = ( generationIntervals, id, replacements, + lastViewedAt, status, }: AttackDiscoveryUpdateProps ): UpdateAttackDiscoverySchema => { @@ -147,8 +149,9 @@ export const transformToUpdateScheme = ( value: replacements[key], })) : undefined, - status, - updated_at: updatedAt, + ...(status ? { status } : {}), + // only update updated_at time if this is not an update to last_viewed_at + ...(lastViewedAt ? { last_viewed_at: lastViewedAt } : { updated_at: updatedAt }), ...averageIntervalMsObj, }; }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts index ad5d3c4bb8d1b..74cf160c43ffe 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts @@ -15,8 +15,45 @@ import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attac import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; import { getAttackDiscoveryRequest } from '../../__mocks__/request'; +import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from './helpers'; jest.mock('./helpers'); +const mockStats = { + newConnectorResultsCount: 2, + newDiscoveriesCount: 4, + statsPerConnector: [ + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: true, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, + ], +}; const { clients, context } = requestContextMock.createTools(); const server: ReturnType = serverMock.create(); clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient(); @@ -28,9 +65,8 @@ const mockUser = { name: 'my_realm_name', }, } as AuthenticatedUser; -const findAttackDiscoveryByConnectorId = jest.fn(); const mockDataClient = { - findAttackDiscoveryByConnectorId, + findAttackDiscoveryByConnectorId: jest.fn(), updateAttackDiscovery: jest.fn(), createAttackDiscovery: jest.fn(), getAttackDiscovery: jest.fn(), @@ -43,7 +79,8 @@ describe('getAttackDiscoveryRoute', () => { context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(mockDataClient); getAttackDiscoveryRoute(server.router); - findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd); + (updateAttackDiscoveryLastViewedAt as jest.Mock).mockResolvedValue(mockCurrentAd); + (getAttackDiscoveryStats as jest.Mock).mockResolvedValue(mockStats); }); it('should handle successful request', async () => { @@ -54,7 +91,7 @@ describe('getAttackDiscoveryRoute', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({ data: mockCurrentAd, - entryExists: true, + stats: mockStats, }); }); @@ -87,19 +124,19 @@ describe('getAttackDiscoveryRoute', () => { }); it('should handle findAttackDiscoveryByConnectorId null response', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); + (updateAttackDiscoveryLastViewedAt as jest.Mock).mockResolvedValue(null); const response = await server.inject( getAttackDiscoveryRequest('connector-id'), requestContextMock.convertContext(context) ); expect(response.status).toEqual(200); expect(response.body).toEqual({ - entryExists: false, + stats: mockStats, }); }); it('should handle findAttackDiscoveryByConnectorId error', async () => { - findAttackDiscoveryByConnectorId.mockRejectedValue(new Error('Oh no!')); + (updateAttackDiscoveryLastViewedAt as jest.Mock).mockRejectedValue(new Error('Oh no!')); const response = await server.inject( getAttackDiscoveryRequest('connector-id'), requestContextMock.convertContext(context) diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts index 6f3a46130357b..09b2df98fe090 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts @@ -14,6 +14,7 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; +import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from './helpers'; import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../common/constants'; import { buildResponse } from '../../lib/build_response'; import { ElasticAssistantRequestHandlerContext } from '../../types'; @@ -62,20 +63,24 @@ export const getAttackDiscoveryRoute = (router: IRouter { }); }); }); + describe('getAttackDiscoveryStats', () => { + const mockDiscoveries = [ + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:47:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:46:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-12T19:54:50.428Z', + id: '745e005b-7248-4c08-b8b6-4cad263b4be0', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T19:54:50.428Z', + updatedAt: '2024-06-17T20:47:27.182Z', + lastViewedAt: '2024-06-17T20:27:27.182Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'running', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gen-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-13T17:50:59.409Z', + id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:50:59.409Z', + updatedAt: '2024-06-17T20:47:59.969Z', + lastViewedAt: '2024-06-17T20:47:35.227Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gpt4o-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T21:18:56.377Z', + id: '82fced1d-de48-42db-9f56-e45122dee017', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T21:18:56.377Z', + updatedAt: '2024-06-17T20:47:39.372Z', + lastViewedAt: '2024-06-17T20:47:39.372Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'canceled', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-bedrock', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T16:44:23.107Z', + id: 'a4709094-6116-484b-b096-1e8d151cb4b7', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T16:44:23.107Z', + updatedAt: '2024-06-17T20:48:16.961Z', + lastViewedAt: '2024-06-17T20:47:16.961Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 0, + apiConfig: { + connectorId: 'my-gen-a2i', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [ + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: 'steph threw an error', + }, + ]; + beforeEach(() => { + findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); + }); + it('returns the formatted stats object', async () => { + const stats = await getAttackDiscoveryStats({ + authenticatedUser: mockAuthenticatedUser, + dataClient: mockDataClient, + }); + expect(stats).toEqual([ + { + hasViewed: true, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: false, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts index 9dca7ee46cbda..c3665d1583a3f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts @@ -12,6 +12,7 @@ import { AttackDiscovery, AttackDiscoveryPostRequestBody, AttackDiscoveryResponse, + AttackDiscoveryStat, AttackDiscoveryStatus, ExecuteConnectorRequestBody, GenerationInterval, @@ -414,3 +415,58 @@ export const getAssistantTool = (getRegisteredTools: GetRegisteredTools, pluginN const assistantTools = getRegisteredTools(pluginName); return assistantTools.find((tool) => tool.id === 'attack-discovery'); }; + +export const updateAttackDiscoveryLastViewedAt = async ({ + connectorId, + authenticatedUser, + dataClient, +}: { + connectorId: string; + authenticatedUser: AuthenticatedUser; + dataClient: AttackDiscoveryDataClient; +}): Promise => { + const attackDiscovery = await dataClient.findAttackDiscoveryByConnectorId({ + connectorId, + authenticatedUser, + }); + + if (attackDiscovery == null) { + return null; + } + + // update lastViewedAt time as this is the function used for polling by connectorId + return dataClient.updateAttackDiscovery({ + attackDiscoveryUpdateProps: { + id: attackDiscovery.id, + lastViewedAt: new Date().toISOString(), + backingIndex: attackDiscovery.backingIndex, + }, + authenticatedUser, + }); +}; + +export const getAttackDiscoveryStats = async ({ + authenticatedUser, + dataClient, +}: { + authenticatedUser: AuthenticatedUser; + dataClient: AttackDiscoveryDataClient; +}): Promise => { + const attackDiscoveries = await dataClient.findAllAttackDiscoveries({ + authenticatedUser, + }); + + return attackDiscoveries.map((ad) => { + const updatedAt = moment(ad.updatedAt); + const lastViewedAt = moment(ad.lastViewedAt); + const timeSinceLastViewed = updatedAt.diff(lastViewedAt); + const hasViewed = timeSinceLastViewed <= 0; + const discoveryCount = ad.attackDiscoveries.length; + return { + hasViewed, + status: ad.status, + count: discoveryCount, + connectorId: ad.apiConfig.connectorId, + }; + }); +}; diff --git a/x-pack/plugins/elastic_assistant/tsconfig.json b/x-pack/plugins/elastic_assistant/tsconfig.json index f63a8da530196..8f546d6e5fe01 100644 --- a/x-pack/plugins/elastic_assistant/tsconfig.json +++ b/x-pack/plugins/elastic_assistant/tsconfig.json @@ -45,6 +45,7 @@ "@kbn/core-saved-objects-api-server", "@kbn/langchain", "@kbn/stack-connectors-plugin", + "@kbn/security-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx index e599bc8073425..a08dbcfd9a26b 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx @@ -49,6 +49,7 @@ const mockResponse = { }, ], backingIndex: '1234', + lastViewedAt: '2024-06-07T20:04:35.715Z', updatedAt: '2024-06-07T20:04:35.715Z', replacements: { 'c1f9889f-1f6b-4abc-8e65-02de89fe1054': 'root', @@ -70,6 +71,39 @@ const mockResponse = { id: '8e215edc-6318-4760-9566-d32f1844f9cb', }; +const mockStats = [ + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: true, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, +]; + describe('usePollApi', () => { beforeAll(() => { jest.useFakeTimers({ legacyFakeTimers: true }); @@ -101,8 +135,8 @@ describe('usePollApi', () => { test('should update didInitialFetch on connector change', async () => { http.fetch.mockResolvedValue({ - entryExists: true, data: mockResponse, + stats: mockStats, }); const { result, rerender } = renderHook((props) => usePollApi(props), { initialProps: defaultProps, @@ -127,10 +161,10 @@ describe('usePollApi', () => { expect(result.current.didInitialFetch).toEqual(true); }); - test('should update status and data on successful response', async () => { + test('should update stats, status, and data on successful response', async () => { http.fetch.mockResolvedValue({ - entryExists: true, data: mockResponse, + stats: mockStats, }); const { result } = renderHook(() => usePollApi(defaultProps)); @@ -143,37 +177,13 @@ describe('usePollApi', () => { expect(setApproximateFutureTime).toHaveBeenCalledWith( moment(mockResponse.updatedAt).add(mockResponse.averageIntervalMs, 'milliseconds').toDate() ); - }); - - test('should update status and data on running status and schedule next poll', async () => { - // @ts-ignore - jest.spyOn(global, 'setTimeout').mockImplementation((cb) => cb()); - http.fetch - .mockResolvedValueOnce({ - entryExists: true, - data: { ...mockResponse, attackDiscoveries: [], status: 'running' }, - }) - .mockResolvedValueOnce({ - entryExists: true, - data: { ...mockResponse, attackDiscoveries: [], status: 'running' }, - }) - .mockResolvedValueOnce({ - entryExists: true, - data: { ...mockResponse, attackDiscoveries: [], status: 'running' }, - }) - .mockResolvedValue({ - entryExists: true, - data: mockResponse, - }); - - const { result } = renderHook(() => usePollApi(defaultProps)); - - await act(async () => { - await result.current.pollApi(); + expect(result.current.stats).toEqual({ + newConnectorResultsCount: 2, + newDiscoveriesCount: 4, + statsPerConnector: mockStats, }); - // 3 from the mockResolvedValueOnce above - expect(setTimeout).toHaveBeenCalledTimes(3); }); + test('When no connectorId and pollApi is called, should update status and data to null on error and show toast', async () => { const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); const { result } = renderHook(() => diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx index d3821ab57f29b..874a4d1c99ded 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx @@ -7,7 +7,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import * as uuid from 'uuid'; -import type { AttackDiscoveryStatus, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; +import type { + AttackDiscoveryStats, + AttackDiscoveryStatus, + AttackDiscoveryResponse, +} from '@kbn/elastic-assistant-common'; import { AttackDiscoveryCancelResponse, AttackDiscoveryGetResponse, @@ -39,7 +43,9 @@ interface UsePollApi { didInitialFetch: boolean; status: AttackDiscoveryStatus | null; data: AttackDiscoveryData | null; + stats: AttackDiscoveryStats | null; pollApi: () => void; + setStatus: (status: AttackDiscoveryStatus | null) => void; } export const usePollApi = ({ @@ -49,14 +55,18 @@ export const usePollApi = ({ connectorId, }: Props): UsePollApi => { const [status, setStatus] = useState(null); + const [stats, setStats] = useState(null); const [data, setData] = useState(null); const timeoutIdRef = useRef | null>(null); + const connectorIdRef = useRef(undefined); const [didInitialFetch, setDidInitialFetch] = useState(false); useEffect(() => { + connectorIdRef.current = connectorId; setDidInitialFetch(false); return () => { + connectorIdRef.current = undefined; // when a connectorId changes, clear timeout if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current); }; @@ -110,7 +120,6 @@ export const usePollApi = ({ if (connectorId == null || connectorId === '') { throw new Error('Invalid connector id'); } - if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current); const rawResponse = await http.fetch( `/internal/elastic_assistant/attack_discovery/cancel/${connectorId}`, { @@ -138,6 +147,11 @@ export const usePollApi = ({ if (connectorId == null || connectorId === '') { throw new Error('Invalid connector id'); } + // edge case - clearTimeout does not always work in time + // so we need to check if the connectorId has changed + if (connectorId !== connectorIdRef.current) { + return; + } // call the internal API to generate attack discoveries: const rawResponse = await http.fetch( `/internal/elastic_assistant/attack_discovery/${connectorId}`, @@ -151,12 +165,34 @@ export const usePollApi = ({ if (!parsedResponse.success) { throw new Error('Failed to parse the attack discovery GET response'); } - handleResponse(parsedResponse.data.data ?? null); - if (parsedResponse?.data?.data?.status === attackDiscoveryStatus.running) { - // poll every 3 seconds if attack discovery is running + // ensure component did not unmount before setting state + if (connectorIdRef.current) { + handleResponse(parsedResponse.data.data ?? null); + const allStats = parsedResponse.data.stats.reduce( + (acc, ad) => { + return { + ...acc, + newConnectorResultsCount: + !ad.hasViewed && (ad.status === 'succeeded' || ad.status === 'failed') + ? acc.newConnectorResultsCount + 1 + : acc.newConnectorResultsCount, + newDiscoveriesCount: + !ad.hasViewed && ad.status === 'succeeded' + ? acc.newDiscoveriesCount + ad.count + : acc.newDiscoveriesCount, + }; + }, + { + newDiscoveriesCount: 0, + newConnectorResultsCount: 0, + statsPerConnector: parsedResponse.data.stats, + } + ); + setStats(allStats); + // poll every 5 seconds, regardless if current connector is running. Need stats object for connector dropdown stats timeoutIdRef.current = setTimeout(() => { pollApi(); - }, 3000); + }, 5000); } } catch (error) { setStatus(null); @@ -169,7 +205,7 @@ export const usePollApi = ({ } }, [connectorId, handleResponse, http, toasts]); - return { cancelAttackDiscovery, didInitialFetch, status, data, pollApi }; + return { cancelAttackDiscovery, didInitialFetch, status, data, pollApi, stats, setStatus }; }; export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts index 4f8be970f40a1..6c703d799d405 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts @@ -13,6 +13,7 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = ( alertsContextCount: 20, approximateFutureTime: null, isLoadingPost: false, + stats: null, didInitialFetch: true, failureReason: null, generationIntervals: [ @@ -189,6 +190,7 @@ export const getMockUseAttackDiscoveriesWithNoAttackDiscoveriesLoading = ( generationIntervals: undefined, attackDiscoveries: [], isLoadingPost: false, + stats: null, didInitialFetch: true, failureReason: null, lastUpdated: null, diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index 18dddaea3abdc..938da7f930d51 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -26,6 +26,7 @@ describe('Header', () => { render(
{ render(
{ render(
{ render(
{ render(
{ render(
{ render(
void; onCancel: () => void; onConnectorIdSelected: (connectorId: string) => void; + stats: AttackDiscoveryStats | null; } const HeaderComponent: React.FC = ({ @@ -33,6 +36,7 @@ const HeaderComponent: React.FC = ({ onGenerate, onConnectorIdSelected, onCancel, + stats, }) => { const isFlyoutMode = false; // always false for attack discovery const { hasAssistantPrivilege } = useAssistantAvailability(); @@ -67,7 +71,6 @@ const HeaderComponent: React.FC = ({ }, [isLoading, handleCancel, onGenerate] ); - return ( = ({ data-test-subj="header" gutterSize="none" > + {connectorsAreConfigured && ( = ({ onConnectorSelected={noop} onConnectorIdSelected={onConnectorIdSelected} selectedConnectorId={connectorId} + stats={stats} /> )} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/index.tsx new file mode 100644 index 0000000000000..a8d7013d2343d --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/index.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; +import { euiThemeVars } from '@kbn/ui-theme'; +import * as i18n from './translations'; + +interface Props { + stats: AttackDiscoveryStats | null; +} + +export const StatusBell: React.FC = ({ stats }) => { + if (stats && stats.newConnectorResultsCount > 0) { + return ( + + + + ); + } + return null; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/translations.ts new file mode 100644 index 0000000000000..b821634e9f746 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/status_bell/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ATTACK_DISCOVERY_STATS_MESSAGE = ({ + newConnectorResultsCount, + newDiscoveriesCount, +}: { + newConnectorResultsCount: number; + newDiscoveriesCount: number; +}) => + i18n.translate('xpack.securitySolution.attackDiscovery.pages.pageTitle.statusConnectors', { + values: { newConnectorResultsCount, newDiscoveriesCount }, + defaultMessage: + 'You have {newDiscoveriesCount} new {newDiscoveriesCount, plural, =1 {discovery} other {discoveries}} across {newConnectorResultsCount} {newConnectorResultsCount, plural, =1 {connector} other {connectors}} to view.', + }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index f43360ec17666..a3784311983cb 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -80,6 +80,7 @@ const AttackDiscoveryPageComponent: React.FC = () => { isLoadingPost, lastUpdated, replacements, + stats, } = useAttackDiscovery({ connectorId, setLoadingConnectorId, @@ -172,6 +173,7 @@ const AttackDiscoveryPageComponent: React.FC = () => { onConnectorIdSelected={onConnectorIdSelected} onGenerate={onGenerate} onCancel={onCancel} + stats={stats} /> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx index a6ba1570c61ee..ca6f2c8ca02df 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx @@ -53,6 +53,7 @@ const mockAttackDiscoveryPost = { backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', createdAt: '2024-06-13T17:50:59.409Z', updatedAt: '2024-06-17T15:00:39.680Z', + lastViewedAt: '2024-06-17T15:00:39.680Z', users: [ { id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', @@ -96,6 +97,7 @@ const mockAttackDiscoveries = [ }, ]; const setLoadingConnectorId = jest.fn(); +const setStatus = jest.fn(); describe('useAttackDiscovery', () => { const mockPollApi = { @@ -103,6 +105,9 @@ describe('useAttackDiscovery', () => { data: null, pollApi: jest.fn(), status: 'succeeded', + stats: null, + setStatus, + didInitialFetch: true, }; beforeEach(() => { @@ -144,9 +149,10 @@ describe('useAttackDiscovery', () => { version: '1', } ); - // called on mount, and after successful fetch - expect(mockPollApi.pollApi).toHaveBeenCalledTimes(2); - expect(result.current.isLoading).toBe(true); + // called on mount + expect(mockPollApi.pollApi).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith('running'); + expect(result.current.isLoadingPost).toBe(false); }); it('handles fetch errors correctly', async () => { @@ -165,6 +171,7 @@ describe('useAttackDiscovery', () => { text: errorMessage, }); expect(result.current.isLoading).toBe(false); + expect(result.current.isLoadingPost).toBe(false); }); it('sets loading state based on poll status', async () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx index d517d5d0cd4ab..87f0f4d9a5089 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx @@ -10,6 +10,7 @@ import type { AttackDiscoveries, Replacements, GenerationInterval, + AttackDiscoveryStats, } from '@kbn/elastic-assistant-common'; import { AttackDiscoveryPostResponse, @@ -37,6 +38,7 @@ export interface UseAttackDiscovery { lastUpdated: Date | null; onCancel: () => Promise; replacements: Replacements; + stats: AttackDiscoveryStats | null; } export const useAttackDiscovery = ({ @@ -64,7 +66,9 @@ export const useAttackDiscovery = ({ data: pollData, pollApi, status: pollStatus, + setStatus: setPollStatus, didInitialFetch, + stats, } = usePollApi({ http, setApproximateFutureTime, toasts, connectorId }); // loading boilerplate: @@ -114,8 +118,9 @@ export const useAttackDiscovery = ({ setReplacements({}); setAttackDiscoveries([]); setGenerationIntervals([]); + setPollStatus(null); } - }, [pollApi, connectorId, setLoadingConnectorId]); + }, [pollApi, connectorId, setLoadingConnectorId, setPollStatus]); useEffect(() => { if (pollStatus === 'running') { @@ -152,7 +157,8 @@ export const useAttackDiscovery = ({ throw new Error(CONNECTOR_ERROR); } setLoadingConnectorId?.(connectorId ?? null); - setIsLoading(true); + // sets isLoading to true + setPollStatus('running'); setIsLoadingPost(true); setApproximateFutureTime(null); // call the internal API to generate attack discoveries: @@ -167,10 +173,6 @@ export const useAttackDiscovery = ({ if (!parsedResponse.success) { throw new Error('Failed to parse the response'); } - - if (parsedResponse.data.status === 'running') { - pollApi(); - } } catch (error) { setIsLoadingPost(false); setIsLoading(false); @@ -179,7 +181,7 @@ export const useAttackDiscovery = ({ text: getErrorToastText(error), }); } - }, [connectorId, http, pollApi, requestBody, setLoadingConnectorId, toasts]); + }, [connectorId, http, requestBody, setLoadingConnectorId, setPollStatus, toasts]); return { alertsContextCount, @@ -194,5 +196,6 @@ export const useAttackDiscovery = ({ lastUpdated, onCancel: cancelAttackDiscovery, replacements, + stats, }; }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 606be2f72914c..a1871e039fbb3 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -13331,10 +13331,8 @@ "xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.conversationSettingsLink": "Paramètres de conversation", "xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel": "Sélecteur de conversation", "xpack.elasticAssistant.assistant.connectors.connectorSelector.newConnectorOptions": "Ajouter un nouveau connecteur...", - "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorLabel": "Connecteur :", "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorPlaceholder": "Sélectionner un connecteur", "xpack.elasticAssistant.assistant.connectors.preconfiguredTitle": "Préconfiguré", - "xpack.elasticAssistant.assistant.connectors.setup.complete": "Configuration du connecteur terminée !", "xpack.elasticAssistant.assistant.connectors.setup.skipTitle": "Cliquez pour ignorer...", "xpack.elasticAssistant.assistant.connectors.setup.timestampAtTitle": "à", "xpack.elasticAssistant.assistant.connectors.setup.userAssistantTitle": "Assistant", @@ -13503,7 +13501,6 @@ "xpack.elasticAssistant.connectors.models.modelSelector.placeholderText": "Sélectionnez ou saisissez pour créer une nouvelle...", "xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage": "Bienvenue sur votre assistant d’intelligence artificielle Elastic. Je suis votre portail 100 % open source vers votre vie Elastic. ", "xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage": "Bienvenue sur votre assistant d’intelligence artificielle Elastic. Je suis votre portail 100 % open source vers votre vie Elastic. ", - "xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt": "Bienvenue sur votre assistant d’intelligence artificielle Elastic. Je suis votre portail 100 % open source vers Elastic Security. ", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph1": "Les champs ci-dessous sont permis par défaut", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph2": "Activer optionnellement l’anonymisation pour ces champs", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutTitle": "Valeur par défaut de l'anonymisation", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe26b29ebc559..7b1fb5c238f52 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13310,10 +13310,8 @@ "xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.conversationSettingsLink": "会話設定", "xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel": "会話セレクター", "xpack.elasticAssistant.assistant.connectors.connectorSelector.newConnectorOptions": "新しいコネクターを追加...", - "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorLabel": "コネクター:", "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorPlaceholder": "コネクターを選択", "xpack.elasticAssistant.assistant.connectors.preconfiguredTitle": "構成済み", - "xpack.elasticAssistant.assistant.connectors.setup.complete": "コネクターのセットアップが完了しました!", "xpack.elasticAssistant.assistant.connectors.setup.skipTitle": "クリックしてスキップ....", "xpack.elasticAssistant.assistant.connectors.setup.timestampAtTitle": "に", "xpack.elasticAssistant.assistant.connectors.setup.userAssistantTitle": "アシスタント", @@ -13482,7 +13480,6 @@ "xpack.elasticAssistant.connectors.models.modelSelector.placeholderText": "選択するか、入力して新規作成...", "xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage": "Elastic AI Assistantへようこそ!Elasticを活用するための100%オープンソースのポータルです。", "xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage": "Elastic AI Assistantへようこそ!Elasticを活用するための100%オープンソースのポータルです。", - "xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt": "Elastic AI Assistantへようこそ!Elasticセキュリティを活用するための100%オープンソースのポータルです。", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph1": "このフィールドはデフォルトで許可されています", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph2": "任意で、これらのフィールドの匿名化を有効にします", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutTitle": "匿名化デフォルト", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7dc4eaa5adcb2..c783af0ea5060 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13336,10 +13336,8 @@ "xpack.elasticAssistant.assistant.connectors.connectorMissingCallout.conversationSettingsLink": "对话设置", "xpack.elasticAssistant.assistant.connectors.connectorSelector.ariaLabel": "对话选择器", "xpack.elasticAssistant.assistant.connectors.connectorSelector.newConnectorOptions": "添加新连接器……", - "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorLabel": "连接器:", "xpack.elasticAssistant.assistant.connectors.connectorSelectorInline.connectorPlaceholder": "选择连接器", "xpack.elasticAssistant.assistant.connectors.preconfiguredTitle": "预配置", - "xpack.elasticAssistant.assistant.connectors.setup.complete": "连接器设置完成!", "xpack.elasticAssistant.assistant.connectors.setup.skipTitle": "单击以跳过……", "xpack.elasticAssistant.assistant.connectors.setup.timestampAtTitle": "处于", "xpack.elasticAssistant.assistant.connectors.setup.userAssistantTitle": "助手", @@ -13508,7 +13506,6 @@ "xpack.elasticAssistant.connectors.models.modelSelector.placeholderText": "选择或键入以新建……", "xpack.elasticAssistant.connectors.useLoadActionTypes.errorMessage": "欢迎使用 Elastic AI 助手!我是您的 100% 开源门户,可帮助您熟练使用 Elastic。", "xpack.elasticAssistant.connectors.useLoadConnectors.errorMessage": "欢迎使用 Elastic AI 助手!我是您的 100% 开源门户,可帮助您熟练使用 Elastic。", - "xpack.elasticAssistant.content.prompts.welcome.welcomeSecurityPrompt": "欢迎使用 Elastic AI 助手!我是您的 100% 开源门户,可帮助您熟练使用 Elastic Security。", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph1": "默认允许使用以下字段", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutParagraph2": "(可选)对这些字段启用匿名处理", "xpack.elasticAssistant.dataAnonymization.settings.anonymizationSettings.calloutTitle": "匿名处理默认设置",