From 39ac875b764019e2c570b175f3cc57e0c02b20ec Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Tue, 1 Oct 2024 13:30:47 -0500 Subject: [PATCH 01/11] [Security Solution][Notes] - move notes management page under manage section instead of timeline (#194250) --- packages/deeplinks/security/deep_links.ts | 2 +- .../security_solution/common/constants.ts | 2 +- .../solution_navigation/links/app_links.ts | 21 ++- .../links/sections/investigations_links.ts | 26 ++- .../sections/investigations_translations.ts | 7 + .../public/app/translations.ts | 2 +- .../security_solution/public/app_links.ts | 3 + .../link_to/redirect_to_timelines.tsx | 2 +- .../public/management/common/breadcrumbs.ts | 2 + .../public/management/common/constants.ts | 1 + .../public/management/links.ts | 22 +++ .../public/management/pages/index.tsx | 18 ++ .../public/management/pages/notes/index.tsx | 21 +++ .../public/management/types.ts | 1 + .../public/notes/components/translations.ts | 53 ------ .../security_solution/public/notes/links.ts | 25 +++ .../notes/pages/note_management_page.tsx | 39 +++-- .../public/notes/pages/translations.ts | 73 ++++++++ .../security_solution/public/notes/routes.tsx | 42 +++++ .../components/open_timeline/index.tsx | 4 +- .../open_timeline/open_timeline.tsx | 164 +++++++++--------- .../components/open_timeline/types.ts | 1 - .../open_timeline/use_timeline_types.tsx | 28 +-- .../public/timelines/links.ts | 11 +- .../public/timelines/pages/index.tsx | 2 +- .../public/timelines/pages/timelines_page.tsx | 5 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 29 files changed, 372 insertions(+), 211 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/notes/index.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/links.ts create mode 100644 x-pack/plugins/security_solution/public/notes/pages/translations.ts create mode 100644 x-pack/plugins/security_solution/public/notes/routes.tsx diff --git a/packages/deeplinks/security/deep_links.ts b/packages/deeplinks/security/deep_links.ts index c7d5b54fb202a..54b18dcaf9206 100644 --- a/packages/deeplinks/security/deep_links.ts +++ b/packages/deeplinks/security/deep_links.ts @@ -87,5 +87,5 @@ export enum SecurityPageName { entityAnalyticsManagement = 'entity_analytics-management', entityAnalyticsAssetClassification = 'entity_analytics-asset-classification', coverageOverview = 'coverage-overview', - notesManagement = 'notes-management', + notes = 'notes', } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 5da9b87a4e267..e2b85fd123f91 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -133,7 +133,7 @@ export const APP_HOST_ISOLATION_EXCEPTIONS_PATH = export const APP_BLOCKLIST_PATH = `${APP_PATH}${BLOCKLIST_PATH}` as const; export const APP_RESPONSE_ACTIONS_HISTORY_PATH = `${APP_PATH}${RESPONSE_ACTIONS_HISTORY_PATH}` as const; -export const NOTES_MANAGEMENT_PATH = `/notes_management` as const; +export const NOTES_PATH = `${MANAGEMENT_PATH}/notes` as const; // cloud logs to exclude from default index pattern export const EXCLUDE_ELASTIC_CLOUD_INDICES = ['-*elastic-cloud-logs-*']; diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts index 3107bb93de269..f6a51f1d25f4f 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/app_links.ts @@ -6,9 +6,13 @@ */ import { SecurityPageName } from '@kbn/security-solution-navigation'; -import { cloneDeep, remove } from 'lodash'; +import { cloneDeep, remove, find } from 'lodash'; import type { AppLinkItems, LinkItem } from '../../../common/links/types'; -import { createInvestigationsLinkFromTimeline } from './sections/investigations_links'; +import { + createInvestigationsLinkFromNotes, + createInvestigationsLinkFromTimeline, + updateInvestigationsLinkFromNotes, +} from './sections/investigations_links'; import { mlAppLink } from './sections/ml_links'; import { createAssetsLinkFromManage } from './sections/assets_links'; import { createSettingsLinksFromManage } from './sections/settings_links'; @@ -26,6 +30,19 @@ export const solutionAppLinksSwitcher = (appLinks: AppLinkItems): AppLinkItems = solutionAppLinks.push(createInvestigationsLinkFromTimeline(timelineLinkItem)); } + // Remove note link + const investigationsLinkItem = find(solutionAppLinks, { id: SecurityPageName.investigations }); + const [noteLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.notes }); + if (noteLinkItem) { + if (!investigationsLinkItem) { + solutionAppLinks.push(createInvestigationsLinkFromNotes(noteLinkItem)); + } else { + solutionAppLinks.push( + updateInvestigationsLinkFromNotes(investigationsLinkItem, noteLinkItem) + ); + } + } + // Remove manage link const [manageLinkItem] = remove(solutionAppLinks, { id: SecurityPageName.administration }); diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts index 33bc73ee3ef77..3c4d059474993 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_links.ts @@ -6,6 +6,7 @@ */ import { ExternalPageName, SecurityPageName } from '@kbn/security-solution-navigation'; +import IconFilebeatChart from './icons/filebeat_chart'; import { INVESTIGATIONS_PATH } from '../../../../../common/constants'; import { SERVER_APP_ID } from '../../../../../common'; import type { LinkItem } from '../../../../common/links/types'; @@ -21,7 +22,7 @@ const investigationsAppLink: LinkItem = { capabilities: [`${SERVER_APP_ID}.show`], hideTimeline: true, skipUrlState: true, - links: [], // timeline link are added in createInvestigationsLinkFromTimeline + links: [], // timeline and note links are added via the methods below }; export const createInvestigationsLinkFromTimeline = (timelineLink: LinkItem): LinkItem => { @@ -33,6 +34,29 @@ export const createInvestigationsLinkFromTimeline = (timelineLink: LinkItem): Li }; }; +export const createInvestigationsLinkFromNotes = (noteLink: LinkItem): LinkItem => { + return { + ...investigationsAppLink, + links: [{ ...noteLink, description: i18n.NOTE_DESCRIPTION, landingIcon: IconTimelineLazy }], + }; +}; + +export const updateInvestigationsLinkFromNotes = ( + investigationsLink: LinkItem, + noteLink: LinkItem +): LinkItem => { + const currentLinks = investigationsLink.links ?? []; + currentLinks.push({ + ...noteLink, + description: i18n.NOTE_DESCRIPTION, + landingIcon: IconFilebeatChart, + }); + return { + ...investigationsLink, + links: currentLinks, + }; +}; + // navLinks define the navigation links for the Security Solution pages and External pages as well export const investigationsNavLinks: SolutionNavLink[] = [ { diff --git a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts index 55c6fe74f846d..d70717783870a 100644 --- a/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts +++ b/x-pack/plugins/security_solution/public/app/solution_navigation/links/sections/investigations_translations.ts @@ -21,6 +21,13 @@ export const TIMELINE_DESCRIPTION = i18n.translate( } ); +export const NOTE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.navLinks.investigations.note.title', + { + defaultMessage: 'Oversee, revise and revisit the annotations within each document and timeline', + } +); + export const OSQUERY_TITLE = i18n.translate( 'xpack.securitySolution.navLinks.investigations.osquery.title', { diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index dcadd74245f24..97f07ee6706b9 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -25,7 +25,7 @@ export const ENTITY_ANALYTICS_RISK_SCORE = i18n.translate( } ); -export const NOTES = i18n.translate('xpack.securitySolution.navigation.notesManagement', { +export const NOTES = i18n.translate('xpack.securitySolution.navigation.notes', { defaultMessage: 'Notes', }); diff --git a/x-pack/plugins/security_solution/public/app_links.ts b/x-pack/plugins/security_solution/public/app_links.ts index 4140f6bfcd322..334209b744580 100644 --- a/x-pack/plugins/security_solution/public/app_links.ts +++ b/x-pack/plugins/security_solution/public/app_links.ts @@ -6,6 +6,7 @@ */ import type { CoreStart } from '@kbn/core/public'; +import { links as notesLink } from './notes/links'; import { links as attackDiscoveryLinks } from './attack_discovery/links'; import type { AppLinkItems } from './common/links/types'; import { indicatorsLinks } from './threat_intelligence/links'; @@ -35,6 +36,7 @@ export const appLinks: AppLinkItems = Object.freeze([ rulesLinks, gettingStartedLinks, managementLinks, + notesLink, ]); export const getFilteredLinks = async ( @@ -55,5 +57,6 @@ export const getFilteredLinks = async ( rulesLinks, gettingStartedLinks, managementFilteredLinks, + notesLink, ]); }; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 3fa8cd2be11bc..07ff05f507029 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import type { TimelineType } from '../../../../common/api/timeline'; import { appendSearch } from './helpers'; -export const getTimelineTabsUrl = (tabName: TimelineType | 'notes', search?: string) => +export const getTimelineTabsUrl = (tabName: TimelineType, search?: string) => `/${tabName}${appendSearch(search)}`; export const getTimelineUrl = (id: string, graphEventId?: string) => diff --git a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts index aca8f865fce8a..82b321abdcd6e 100644 --- a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts @@ -14,6 +14,7 @@ import { BLOCKLIST, RESPONSE_ACTIONS_HISTORY, PROTECTION_UPDATES, + NOTES, } from '../../app/translations'; const TabNameMappedToI18nKey: Record = { @@ -25,6 +26,7 @@ const TabNameMappedToI18nKey: Record = { [AdministrationSubTab.blocklist]: BLOCKLIST, [AdministrationSubTab.responseActionsHistory]: RESPONSE_ACTIONS_HISTORY, [AdministrationSubTab.protectionUpdates]: PROTECTION_UPDATES, + [AdministrationSubTab.notes]: NOTES, }; export function getTrailingBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] { diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 4b050266f3ac3..b319adbd0faeb 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -18,6 +18,7 @@ export const MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH = `${MANAGEMEN export const MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/hostIsolationExceptions`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/blocklists`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_PROTECTION_UPDATES_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/protectionUpdates`; +export const MANAGEMENT_ROUTING_NOTES_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.notes})`; /** @deprecated use the paths defined above instead */ export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.trustedApps})`; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 91bf4e958f6fb..7247c45c366f4 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -8,6 +8,7 @@ import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import IconFilebeatChart from '../app/solution_navigation/links/sections/icons/filebeat_chart'; import { checkArtifactHasData } from './services/exceptions_list/check_artifact_has_data'; import { calculateEndpointAuthz, @@ -22,6 +23,7 @@ import { EVENT_FILTERS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, MANAGE_PATH, + NOTES_PATH, POLICIES_PATH, RESPONSE_ACTIONS_HISTORY_PATH, SecurityPageName, @@ -39,6 +41,7 @@ import { TRUSTED_APPLICATIONS, ENTITY_ANALYTICS_RISK_SCORE, ASSET_CRITICALITY, + NOTES, } from '../app/translations'; import { licenseService } from '../common/hooks/use_license'; import type { LinkItem } from '../common/links/types'; @@ -85,6 +88,12 @@ const categories = [ }), linkIds: [SecurityPageName.cloudDefendPolicies], }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.investigations', { + defaultMessage: 'Investigations', + }), + linkIds: [SecurityPageName.notes], + }, ]; export const links: LinkItem = { @@ -215,6 +224,19 @@ export const links: LinkItem = { hideTimeline: true, }, cloudDefendLink, + { + id: SecurityPageName.notes, + title: NOTES, + description: i18n.translate('xpack.securitySolution.appLinks.notesDescription', { + defaultMessage: + 'Oversee, revise and revisit the annotations within each document and timeline.', + }), + landingIcon: IconFilebeatChart, + path: NOTES_PATH, + skipUrlState: true, + hideTimeline: true, + experimentalKey: 'securitySolutionNotesEnabled', + }, ], }; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 144ae26815f18..dc8314acc276c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -11,6 +11,8 @@ import { Routes, Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { NotesContainer } from './notes'; import { ManagementEmptyStateWrapper } from '../components/management_empty_state_wrapper'; import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, @@ -20,6 +22,7 @@ import { MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_BLOCKLIST_PATH, MANAGEMENT_ROUTING_RESPONSE_ACTIONS_HISTORY_PATH, + MANAGEMENT_ROUTING_NOTES_PATH, } from '../common/constants'; import { NotFoundPage } from '../../app/404'; import { EndpointsContainer } from './endpoint_hosts'; @@ -77,7 +80,18 @@ const ResponseActionsTelemetry = () => ( ); +const NotesTelemetry = () => ( + + + + +); + export const ManagementContainer = memo(() => { + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); + const { loading, canReadPolicyManagement, @@ -148,6 +162,10 @@ export const ManagementContainer = memo(() => { hasPrivilege={canReadActionsLogManagement} /> + {securitySolutionNotesEnabled && ( + + )} + {canReadEndpointList && ( diff --git a/x-pack/plugins/security_solution/public/management/pages/notes/index.tsx b/x-pack/plugins/security_solution/public/management/pages/notes/index.tsx new file mode 100644 index 0000000000000..5c57509432a93 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/notes/index.tsx @@ -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 { Routes, Route } from '@kbn/shared-ux-router'; +import React from 'react'; +import { NoteManagementPage } from '../../../notes'; +import { NotFoundPage } from '../../../app/404'; +import { MANAGEMENT_ROUTING_NOTES_PATH } from '../../common/constants'; + +export const NotesContainer = () => { + return ( + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index 94f671ac09e2f..ef0e3e56c7285 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -33,6 +33,7 @@ export enum AdministrationSubTab { blocklist = 'blocklist', responseActionsHistory = 'response_actions_history', protectionUpdates = 'protection_updates', + notes = 'notes', } /** diff --git a/x-pack/plugins/security_solution/public/notes/components/translations.ts b/x-pack/plugins/security_solution/public/notes/components/translations.ts index f2846d6daab62..8d7a5b4262815 100644 --- a/x-pack/plugins/security_solution/public/notes/components/translations.ts +++ b/x-pack/plugins/security_solution/public/notes/components/translations.ts @@ -14,56 +14,10 @@ export const BATCH_ACTIONS = i18n.translate( } ); -export const CREATED_COLUMN = i18n.translate( - 'xpack.securitySolution.notes.management.createdColumnTitle', - { - defaultMessage: 'Created', - } -); - -export const CREATED_BY_COLUMN = i18n.translate( - 'xpack.securitySolution.notes.management.createdByColumnTitle', - { - defaultMessage: 'Created by', - } -); - -export const EVENT_ID_COLUMN = i18n.translate( - 'xpack.securitySolution.notes.management.eventIdColumnTitle', - { - defaultMessage: 'View Document', - } -); - -export const TIMELINE_ID_COLUMN = i18n.translate( - 'xpack.securitySolution.notes.management.timelineColumnTitle', - { - defaultMessage: 'Timeline', - } -); - -export const NOTE_CONTENT_COLUMN = i18n.translate( - 'xpack.securitySolution.notes.management.noteContentColumnTitle', - { - defaultMessage: 'Note content', - } -); - export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', { defaultMessage: 'Delete', }); -export const DELETE_SINGLE_NOTE_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.notes.management.deleteDescription', - { - defaultMessage: 'Delete this note', - } -); - -export const TABLE_ERROR = i18n.translate('xpack.securitySolution.notes.management.tableError', { - defaultMessage: 'Unable to load notes', -}); - export const DELETE_NOTES_MODAL_TITLE = i18n.translate( 'xpack.securitySolution.notes.management.deleteNotesModalTitle', { @@ -96,13 +50,6 @@ export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.r defaultMessage: 'Refresh', }); -export const OPEN_TIMELINE = i18n.translate( - 'xpack.securitySolution.notes.management.openTimeline', - { - defaultMessage: 'Open timeline', - } -); - export const VIEW_EVENT_IN_TIMELINE = i18n.translate( 'xpack.securitySolution.notes.management.viewEventInTimeline', { diff --git a/x-pack/plugins/security_solution/public/notes/links.ts b/x-pack/plugins/security_solution/public/notes/links.ts new file mode 100644 index 0000000000000..ef6c691b6246a --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/links.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NOTES_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; +import { NOTES } from '../app/translations'; +import type { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.notes, + title: NOTES, + path: NOTES_PATH, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.notes', { + defaultMessage: 'Notes', + }), + ], + links: [], + experimentalKey: 'securitySolutionNotesEnabled', +}; diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 574501b7d03c0..ddfed3fbb6287 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -7,8 +7,11 @@ import React, { useCallback, useMemo, useEffect } from 'react'; import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBasicTable, EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { EuiBasicTable, EuiEmptyPrompt, EuiLink, EuiSpacer } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; +import { Title } from '../../common/components/header_page/title'; // TODO unify this type from the api with the one in public/common/lib/note import type { Note } from '../../../common/api/timeline'; import { FormattedRelativePreferenceDate } from '../../common/components/formatted_date'; @@ -32,12 +35,11 @@ import type { NotesState } from '..'; import { SearchRow } from '../components/search_row'; import { NotesUtilityBar } from '../components/utility_bar'; import { DeleteConfirmModal } from '../components/delete_confirm_modal'; -import * as i18n from '../components/translations'; -import type { OpenTimelineProps } from '../../timelines/components/open_timeline/types'; +import * as i18n from './translations'; import { OpenEventInTimeline } from '../components/open_event_in_timeline'; const columns: ( - onOpenTimeline: OpenTimelineProps['onOpenTimeline'] + onOpenTimeline: (timelineId: string) => void ) => Array> = (onOpenTimeline) => { return [ { @@ -61,9 +63,7 @@ const columns: ( name: i18n.TIMELINE_ID_COLUMN, render: (timelineId: Note['timelineId']) => timelineId ? ( - onOpenTimeline({ timelineId, duplicate: false })}> - {i18n.OPEN_TIMELINE} - + onOpenTimeline(timelineId)}>{i18n.OPEN_TIMELINE} ) : null, }, { @@ -80,11 +80,7 @@ const pageSizeOptions = [10, 25, 50, 100]; * This component uses the same slices of state as the notes functionality of the rest of the Security Solution applicaiton. * Therefore, changes made in this page (like fetching or deleting notes) will have an impact everywhere. */ -export const NoteManagementPage = ({ - onOpenTimeline, -}: { - onOpenTimeline: OpenTimelineProps['onOpenTimeline']; -}) => { +export const NoteManagementPage = () => { const dispatch = useDispatch(); const notes = useSelector(selectAllNotes); const pagination = useSelector(selectNotesPagination); @@ -152,6 +148,19 @@ export const NoteManagementPage = ({ return item.noteId; }, []); + const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( + 'unifiedComponentsInTimelineDisabled' + ); + const queryTimelineById = useQueryTimelineById(); + const openTimeline = useCallback( + (timelineId: string) => + queryTimelineById({ + timelineId, + unifiedComponentsInTimelineDisabled, + }), + [queryTimelineById, unifiedComponentsInTimelineDisabled] + ); + const columnWithActions = useMemo(() => { const actions: Array> = [ { @@ -164,13 +173,13 @@ export const NoteManagementPage = ({ }, ]; return [ - ...columns(onOpenTimeline), + ...columns(openTimeline), { name: 'actions', actions, }, ]; - }, [selectRowForDeletion, onOpenTimeline]); + }, [selectRowForDeletion, openTimeline]); const currentPagination = useMemo(() => { return { @@ -207,6 +216,8 @@ export const NoteManagementPage = ({ return ( <> + + <EuiSpacer size="m" /> <SearchRow /> <NotesUtilityBar /> <EuiBasicTable diff --git a/x-pack/plugins/security_solution/public/notes/pages/translations.ts b/x-pack/plugins/security_solution/public/notes/pages/translations.ts new file mode 100644 index 0000000000000..5f5d9b19477e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/pages/translations.ts @@ -0,0 +1,73 @@ +/* + * 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 NOTES = i18n.translate('xpack.securitySolution.notes.management.title', { + defaultMessage: 'Notes', +}); + +export const CREATED_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.createdColumnTitle', + { + defaultMessage: 'Created', + } +); + +export const CREATED_BY_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.createdByColumnTitle', + { + defaultMessage: 'Created by', + } +); + +export const EVENT_ID_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.eventIdColumnTitle', + { + defaultMessage: 'View Document', + } +); + +export const TIMELINE_ID_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.timelineColumnTitle', + { + defaultMessage: 'Timeline', + } +); + +export const NOTE_CONTENT_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.noteContentColumnTitle', + { + defaultMessage: 'Note content', + } +); + +export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', { + defaultMessage: 'Delete', +}); + +export const DELETE_SINGLE_NOTE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.notes.management.deleteDescription', + { + defaultMessage: 'Delete this note', + } +); + +export const TABLE_ERROR = i18n.translate('xpack.securitySolution.notes.management.tableError', { + defaultMessage: 'Unable to load notes', +}); + +export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.refresh', { + defaultMessage: 'Refresh', +}); + +export const OPEN_TIMELINE = i18n.translate( + 'xpack.securitySolution.notes.management.openTimeline', + { + defaultMessage: 'Open timeline', + } +); diff --git a/x-pack/plugins/security_solution/public/notes/routes.tsx b/x-pack/plugins/security_solution/public/notes/routes.tsx new file mode 100644 index 0000000000000..7bd17c2b012ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/routes.tsx @@ -0,0 +1,42 @@ +/* + * 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 { Switch } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { NoteManagementPage } from './pages/note_management_page'; +import { SpyRoute } from '../common/utils/route/spy_routes'; +import { NotFoundPage } from '../app/404'; +import { NOTES_PATH, SecurityPageName } from '../../common/constants'; +import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; + +const NotesManagementTelemetry = () => ( + <PluginTemplateWrapper> + <TrackApplicationView viewId={SecurityPageName.notes}> + <NoteManagementPage /> + <SpyRoute pageName={SecurityPageName.notes} /> + </TrackApplicationView> + </PluginTemplateWrapper> +); + +const NotesManagementContainer: React.FC = React.memo(() => { + return ( + <Switch> + <Route path={NOTES_PATH} exact component={NotesManagementTelemetry} /> + <Route component={NotFoundPage} /> + </Switch> + ); +}); +NotesManagementContainer.displayName = 'NotesManagementContainer'; + +export const routes = [ + { + path: NOTES_PATH, + component: NotesManagementContainer, + }, +]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index ea86d5eaa54fb..cdb61ecf61f6e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -70,7 +70,7 @@ interface OwnProps<TCache = object> { export type OpenTimelineOwnProps = OwnProps & Pick< OpenTimelineProps, - 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' | 'tabName' + 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' >; /** Returns a collection of selected timeline ids */ @@ -131,7 +131,6 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( importDataModalToggle, onOpenTimeline, setImportDataModalToggle, - tabName, title, }) => { const dispatch = useDispatch(); @@ -423,7 +422,6 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - tabName={tabName} templateTimelineFilter={templateTimelineFilter} timelineType={timelineType} timelineStatus={timelineStatus} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 524d3bee9640a..5a1a9155bb5c1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -30,7 +30,6 @@ import { TimelinesTable } from './timelines_table'; import * as i18n from './translations'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from './types'; -import { NoteManagementPage } from '../../../notes'; const QueryText = styled.span` white-space: normal; @@ -64,7 +63,6 @@ export const OpenTimeline = React.memo<OpenTimelineProps>( sortDirection, setImportDataModalToggle, sortField, - tabName, timelineType = TimelineTypeEnum.default, timelineStatus, timelineFilter, @@ -228,92 +226,86 @@ export const OpenTimeline = React.memo<OpenTimelineProps>( <div data-test-subj="timelines-page-container" className={OPEN_TIMELINE_CLASS_NAME}> {!!timelineFilter && timelineFilter} - {tabName !== 'notes' ? ( - <> - <SearchRow - data-test-subj="search-row" - favoriteCount={favoriteCount} - onlyFavorites={onlyFavorites} - onQueryChange={onQueryChange} - onToggleOnlyFavorites={onToggleOnlyFavorites} - query={query} - timelineType={timelineType} - > - {SearchRowContent} - </SearchRow> + <> + <SearchRow + data-test-subj="search-row" + favoriteCount={favoriteCount} + onlyFavorites={onlyFavorites} + onQueryChange={onQueryChange} + onToggleOnlyFavorites={onToggleOnlyFavorites} + query={query} + timelineType={timelineType} + > + {SearchRowContent} + </SearchRow> - <UtilityBar border> - <UtilityBarSection> - <UtilityBarGroup> - <UtilityBarText data-test-subj="query-message"> - <> - {i18n.SHOWING}{' '} - {timelineType === TimelineTypeEnum.template ? nTemplates : nTimelines} - </> - </UtilityBarText> - </UtilityBarGroup> - <UtilityBarGroup> - {timelineStatus !== TimelineStatusEnum.immutable && ( - <> - <UtilityBarText data-test-subj="selected-count"> - {timelineType === TimelineTypeEnum.template - ? i18n.SELECTED_TEMPLATES((selectedItems || []).length) - : i18n.SELECTED_TIMELINES((selectedItems || []).length)} - </UtilityBarText> - <UtilityBarAction - dataTestSubj="batchActions" - iconSide="right" - iconType="arrowDown" - popoverContent={getBatchItemsPopoverContent} - data-test-subj="utility-bar-action" - > - <span data-test-subj="utility-bar-action-button"> - {i18n.BATCH_ACTIONS} - </span> - </UtilityBarAction> - </> - )} - <UtilityBarAction - dataTestSubj="refreshButton" - iconSide="right" - iconType="refresh" - onClick={onRefreshBtnClick} - > - {i18n.REFRESH} - </UtilityBarAction> - </UtilityBarGroup> - </UtilityBarSection> - </UtilityBar> + <UtilityBar border> + <UtilityBarSection> + <UtilityBarGroup> + <UtilityBarText data-test-subj="query-message"> + <> + {i18n.SHOWING}{' '} + {timelineType === TimelineTypeEnum.template ? nTemplates : nTimelines} + </> + </UtilityBarText> + </UtilityBarGroup> + <UtilityBarGroup> + {timelineStatus !== TimelineStatusEnum.immutable && ( + <> + <UtilityBarText data-test-subj="selected-count"> + {timelineType === TimelineTypeEnum.template + ? i18n.SELECTED_TEMPLATES((selectedItems || []).length) + : i18n.SELECTED_TIMELINES((selectedItems || []).length)} + </UtilityBarText> + <UtilityBarAction + dataTestSubj="batchActions" + iconSide="right" + iconType="arrowDown" + popoverContent={getBatchItemsPopoverContent} + data-test-subj="utility-bar-action" + > + <span data-test-subj="utility-bar-action-button">{i18n.BATCH_ACTIONS}</span> + </UtilityBarAction> + </> + )} + <UtilityBarAction + dataTestSubj="refreshButton" + iconSide="right" + iconType="refresh" + onClick={onRefreshBtnClick} + > + {i18n.REFRESH} + </UtilityBarAction> + </UtilityBarGroup> + </UtilityBarSection> + </UtilityBar> - <TimelinesTable - actionTimelineToShow={actionTimelineToShow} - data-test-subj="timelines-table" - deleteTimelines={deleteTimelines} - defaultPageSize={defaultPageSize} - loading={isLoading} - itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} - enableExportTimelineDownloader={enableExportTimelineDownloader} - onCreateRule={onCreateRule} - onCreateRuleFromEql={onCreateRuleFromEql} - onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} - onOpenTimeline={onOpenTimeline} - onSelectionChange={onSelectionChange} - onTableChange={onTableChange} - onToggleShowNotes={onToggleShowNotes} - pageIndex={pageIndex} - pageSize={pageSize} - searchResults={searchResults} - showExtendedColumns={true} - sortDirection={sortDirection} - sortField={sortField} - timelineType={timelineType} - totalSearchResultsCount={totalSearchResultsCount} - tableRef={tableRef} - /> - </> - ) : ( - <NoteManagementPage onOpenTimeline={onOpenTimeline} /> - )} + <TimelinesTable + actionTimelineToShow={actionTimelineToShow} + data-test-subj="timelines-table" + deleteTimelines={deleteTimelines} + defaultPageSize={defaultPageSize} + loading={isLoading} + itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + enableExportTimelineDownloader={enableExportTimelineDownloader} + onCreateRule={onCreateRule} + onCreateRuleFromEql={onCreateRuleFromEql} + onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} + onOpenTimeline={onOpenTimeline} + onSelectionChange={onSelectionChange} + onTableChange={onTableChange} + onToggleShowNotes={onToggleShowNotes} + pageIndex={pageIndex} + pageSize={pageSize} + searchResults={searchResults} + showExtendedColumns={true} + sortDirection={sortDirection} + sortField={sortField} + timelineType={timelineType} + totalSearchResultsCount={totalSearchResultsCount} + tableRef={tableRef} + /> + </> </div> </> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 14ddedf5b9688..d750ec08c24b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -206,7 +206,6 @@ export interface OpenTimelineProps { totalSearchResultsCount: number; /** Hide action on timeline if needed it */ hideActions?: ActionTimelineToShow[]; - tabName?: string; } export interface ResolveTimelineConfig { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 45dd7d9e95bbf..e6ad5ad0e1f11 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -8,7 +8,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui'; - import { noop } from 'lodash/fp'; import { type TimelineType, TimelineTypeEnum } from '../../../../common/api/timeline'; import { SecurityPageName } from '../../../app/types'; @@ -17,7 +16,7 @@ import * as i18n from './translations'; import type { TimelineTab } from './types'; import { TimelineTabsStyle } from './types'; import { useKibana } from '../../../common/lib/kibana'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; + export interface UseTimelineTypesArgs { defaultTimelineCount?: number | null; templateTimelineCount?: number | null; @@ -42,8 +41,6 @@ export const useTimelineTypes = ({ : TimelineTypeEnum.default ); - const notesEnabled = useIsExperimentalFeatureEnabled('securitySolutionNotesEnabled'); - const timelineUrl = useMemo(() => { return formatUrl(getTimelineTabsUrl(TimelineTypeEnum.default, urlSearch)); }, [formatUrl, urlSearch]); @@ -51,10 +48,6 @@ export const useTimelineTypes = ({ return formatUrl(getTimelineTabsUrl(TimelineTypeEnum.template, urlSearch)); }, [formatUrl, urlSearch]); - const notesUrl = useMemo(() => { - return formatUrl(getTimelineTabsUrl('notes', urlSearch)); - }, [formatUrl, urlSearch]); - const goToTimeline = useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -71,14 +64,6 @@ export const useTimelineTypes = ({ [navigateToUrl, templateUrl] ); - const goToNotes = useCallback( - (ev: React.SyntheticEvent) => { - ev.preventDefault(); - navigateToUrl(notesUrl); - }, - [navigateToUrl, notesUrl] - ); - const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback( (timelineTabsStyle: TimelineTabsStyle) => [ { @@ -132,17 +117,6 @@ export const useTimelineTypes = ({ {tab.name} </EuiTab> ))} - {notesEnabled && ( - <EuiTab - data-test-subj="timeline-notes" - isSelected={tabName === 'notes'} - key="timeline-notes" - href={notesUrl} - onClick={goToNotes} - > - {'Notes'} - </EuiTab> - )} </EuiTabs> <EuiSpacer size="m" /> </> diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 169ef6da01910..9315417d97646 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { SecurityPageName, SERVER_APP_ID, TIMELINES_PATH } from '../../common/constants'; -import { TIMELINES, NOTES } from '../app/translations'; +import { TIMELINES } from '../app/translations'; import type { LinkItem } from '../common/links/types'; export const links: LinkItem = { @@ -30,14 +30,5 @@ export const links: LinkItem = { path: `${TIMELINES_PATH}/template`, sideNavDisabled: true, }, - { - id: SecurityPageName.notesManagement, - title: NOTES, - description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', { - defaultMessage: 'Visualize and delete notes.', - }), - path: `${TIMELINES_PATH}/notes`, - experimentalKey: 'securitySolutionNotesEnabled', - }, ], }; diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index c7e8cb9887efe..2151a2624aeb4 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -17,7 +17,7 @@ import { appendSearch } from '../../common/components/link_to/helpers'; import { TIMELINES_PATH } from '../../../common/constants'; -const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineTypeEnum.default}|${TimelineTypeEnum.template}|notes)`; +const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineTypeEnum.default}|${TimelineTypeEnum.template})`; const timelinesDefaultPath = `${TIMELINES_PATH}/${TimelineTypeEnum.default}`; export const Timelines = React.memo(() => ( diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 9d3c05c97b685..08cf46b41ad35 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -41,7 +41,7 @@ export const TimelinesPage = React.memo(() => { {indicesExist ? ( <SecuritySolutionPageWrapper> <HeaderPage title={i18n.PAGE_TITLE}> - {capabilitiesCanUserCRUD && tabName !== 'notes' ? ( + {capabilitiesCanUserCRUD && ( <EuiFlexGroup gutterSize="s" alignItems="center"> <EuiFlexItem> <EuiButton @@ -56,7 +56,7 @@ export const TimelinesPage = React.memo(() => { <NewTimelineButton type={timelineType} /> </EuiFlexItem> </EuiFlexGroup> - ) : null} + )} </HeaderPage> <StatefulOpenTimeline @@ -66,7 +66,6 @@ export const TimelinesPage = React.memo(() => { setImportDataModalToggle={setImportDataModal} title={i18n.ALL_TIMELINES_PANEL_TITLE} data-test-subj="stateful-open-timeline" - tabName={tabName} /> </SecuritySolutionPageWrapper> ) : ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 67324e7024bfc..f993da6cd3985 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -35253,7 +35253,6 @@ "xpack.securitySolution.appLinks.network.flows": "Flux", "xpack.securitySolution.appLinks.network.http": "HTTP", "xpack.securitySolution.appLinks.network.tls": "TLS", - "xpack.securitySolution.appLinks.notesManagementDescription": "Visualisez et supprimez des notes.", "xpack.securitySolution.appLinks.overview": "Aperçu", "xpack.securitySolution.appLinks.overviewDescription": "Résumé de votre activité d'environnement de sécurité, y compris les alertes, les événements, les éléments récents et un fil d'actualités !", "xpack.securitySolution.appLinks.policiesDescription": "Utilisez les politiques pour personnaliser les protections des points de terminaison et de charge de travail cloud, et d'autres configurations.", @@ -39508,7 +39507,6 @@ "xpack.securitySolution.navigation.manage": "Gérer", "xpack.securitySolution.navigation.network": "Réseau", "xpack.securitySolution.navigation.newRuleTitle": "Créer une nouvelle règle", - "xpack.securitySolution.navigation.notesManagement": "Notes", "xpack.securitySolution.navigation.overview": "Aperçu", "xpack.securitySolution.navigation.protectionUpdates": "Mises à jour de la protection", "xpack.securitySolution.navigation.responseActionsHistory": "Historique des actions de réponse", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ae40b40dd5573..14b59b1f54b8b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34997,7 +34997,6 @@ "xpack.securitySolution.appLinks.network.flows": "Flow", "xpack.securitySolution.appLinks.network.http": "HTTP", "xpack.securitySolution.appLinks.network.tls": "TLS", - "xpack.securitySolution.appLinks.notesManagementDescription": "メモを可視化して、削除します。", "xpack.securitySolution.appLinks.overview": "概要", "xpack.securitySolution.appLinks.overviewDescription": "アラート、イベント、最近のアイテム、ニュースフィードを含む、セキュリティ環境アクティビティの概要。", "xpack.securitySolution.appLinks.policiesDescription": "ポリシーを使用して、エンドポイントおよびクラウドワークロード保護、ならびに他の構成をカスタマイズします。", @@ -39251,7 +39250,6 @@ "xpack.securitySolution.navigation.manage": "管理", "xpack.securitySolution.navigation.network": "ネットワーク", "xpack.securitySolution.navigation.newRuleTitle": "新規ルールを作成", - "xpack.securitySolution.navigation.notesManagement": "メモ", "xpack.securitySolution.navigation.overview": "概要", "xpack.securitySolution.navigation.protectionUpdates": "保護更新", "xpack.securitySolution.navigation.responseActionsHistory": "対応アクション履歴", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 130727459f54d..e55a9107d7c4d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -35041,7 +35041,6 @@ "xpack.securitySolution.appLinks.network.flows": "流", "xpack.securitySolution.appLinks.network.http": "HTTP", "xpack.securitySolution.appLinks.network.tls": "TLS", - "xpack.securitySolution.appLinks.notesManagementDescription": "可视化并删除备注。", "xpack.securitySolution.appLinks.overview": "概览", "xpack.securitySolution.appLinks.overviewDescription": "您的安全环境活动摘要,包括告警、事件、最近项和新闻源!", "xpack.securitySolution.appLinks.policiesDescription": "使用策略定制终端和云工作负载防护及其他配置。", @@ -39297,7 +39296,6 @@ "xpack.securitySolution.navigation.manage": "管理", "xpack.securitySolution.navigation.network": "网络", "xpack.securitySolution.navigation.newRuleTitle": "创建新规则", - "xpack.securitySolution.navigation.notesManagement": "备注", "xpack.securitySolution.navigation.overview": "概览", "xpack.securitySolution.navigation.protectionUpdates": "防护更新", "xpack.securitySolution.navigation.responseActionsHistory": "响应操作历史记录", From 6742f770497a946de2d21aa39985243eec2b9f7b Mon Sep 17 00:00:00 2001 From: Lola <omolola.akinleye@elastic.co> Date: Tue, 1 Oct 2024 14:38:07 -0400 Subject: [PATCH 02/11] [Cloud Security] Agentless integration deletion flow (#191557) ## Summary Summarize your PR. If it involves visual changes include a screenshot or gif. This PR is completes the deletion flow for Agentless CSPM. **Current Agentless Integraton deletion flow**: 1. Successfully delete integration policy 2. Successfully unenrolls agent from agent policy 3. Successfully revokes enrollment token 4. Successfully deletes agentless deployment 5. Successfully deletes agent policy 6. Successful notification shows when deleted integration policy is successful ## Agentless Agent API - Unenrolls agent and revokes token first to avoid 404 save object client error. - Update `is_managed` property to no longer check for `agentPolicy.supports_agentless`. Agentless policies will now be a regular policy. - Adds logging for DELETE agentless Agent API endpoint - Adds agentless API deleteendpoint using try & catch. No errors will be thrown. Agent status will become offline after deployment deletion - If agentless deployment api fails, then we will continue to delete the agent policy ## UI Changes **CSPM Integration** - Updates Agent Policy Error toast notification title - Updates Agent Policy Error toast notification message <img width="1612" alt="image" src="https://github.com/user-attachments/assets/0003ce04-c53c-4e11-8363-ddc25ba342a7"> **Edit Mode** - Adds back the Agentless selector in Edit Integration <img width="1316" alt="image" src="https://github.com/user-attachments/assets/0d2f20ce-32fc-421c-a15a-48ca6226b67f"> **Integration Policies Page** - Removes automatic navigation to agent policies page when deleting an integration. In 8.17, we have a ticket to [hide the agentless agent policies.](https://github.com/elastic/security-team/issues/9857) - Enables delete button when deleting package policy with agents for agentless policies - Disables Upgrade Action - Removes Add Agent Action <img width="1717" alt="image" src="https://github.com/user-attachments/assets/1b7ac4c7-e8bc-41b8-836f-4d3c79a449dd"> <img width="670" alt="image" src="https://github.com/user-attachments/assets/0ab6a4c4-d7c6-43ea-9537-67e7fbcca2b0"> **Agent Policies Page** - Updates messaging when deleting the agentless policy from agent policy page. Warning users that deleting agentless policy will also delete the integration and unenroll agent. - Enables delete button when deleting agentless policy with agents for agentless policies - Removes Add agent menu action - Removes Upgrade policy menu action - Removes Uninstall agent action - Removes Copy policy menu action <img width="1595" alt="image" src="https://github.com/user-attachments/assets/2f195da2-4594-4f54-8f8d-7995e829a5ac"> <img width="1365" alt="image" src="https://github.com/user-attachments/assets/4915642d-41e8-4e83-80f9-f334cb879506"> **Agent Policy Settings** For agent policy that are agentless, we disabled the following [fleet actions:](https://www.elastic.co/guide/en/fleet/current/agent-policy.html#agent-policy-types) - Disables Agent monitoring - Disables Inactivity timeout - Disables Fleet Server - Disables Output for integrations - Disables Output for agent monitoring - Disables Agent binary download - Disables Host name format - Disables Inactive agent unenrollment timeout - Disables Advanced Settings - Limit CPU usage - Disables HTTP monitoring endpoint - Disables Agent Logging <img width="1569" alt="image" src="https://github.com/user-attachments/assets/2639be9f-ea10-4d42-b379-a13c4c2b08a1"> <img width="1517" alt="image" src="https://github.com/user-attachments/assets/ae6f3e10-8c2b-42fe-8f27-7e8621d373c0"> **Agents Page** - Disables Assign to Policy action - Disables Upgrade Policy action - Removes Unassign agent action - Removes agentless policies where user can add agent to agentless policy <img width="1710" alt="image" src="https://github.com/user-attachments/assets/61bf2d06-d337-45dd-8255-499db1e1ed42"> <img width="1723" alt="image" src="https://github.com/user-attachments/assets/cc76787f-d6a2-44fb-9289-7f1f643620ec"> ### How to test in Serverless Use vault access and open the security Project in [build ]([Buildkite Build](https://buildkite.com/elastic/kibana-pull-request/builds/234438)) ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../components/actions_menu.test.tsx | 66 +++++++ .../agent_policy/components/actions_menu.tsx | 167 ++++++++++------- .../agent_policy_advanced_fields/index.tsx | 26 ++- .../agent_policy_delete_provider.tsx | 50 ++++-- .../components/agent_policy_form.tsx | 2 +- .../steps/components/agent_policy_options.tsx | 58 +++--- .../single_page_layout/hooks/form.tsx | 20 ++- .../hooks/setup_technology.ts | 1 - .../components/header/right_content.tsx | 2 +- .../edit_package_policy_page/index.test.tsx | 23 ++- .../edit_package_policy_page/index.tsx | 17 +- .../components/actions_menu.tsx | 2 +- .../components/table_row_actions.tsx | 11 +- .../components/agent_policy_debugger.tsx | 1 + .../agent_policy_selection.tsx | 12 +- .../package_policy_actions_menu.test.tsx | 43 ++--- .../package_policy_actions_menu.tsx | 22 +-- .../package_policy_delete_provider.tsx | 77 ++++++-- .../public/hooks/use_request/agent_policy.ts | 18 +- .../plugins/fleet/server/errors/handlers.ts | 4 + x-pack/plugins/fleet/server/errors/index.ts | 6 + .../server/services/agent_policy.test.ts | 8 +- .../fleet/server/services/agent_policy.ts | 51 +++++- .../server/services/agent_policy_create.ts | 5 +- .../server/services/agents/agentless_agent.ts | 168 +++++++++++++----- .../fleet/server/services/package_policy.ts | 9 +- .../fleet/server/services/utils/agentless.ts | 19 ++ .../agentless/create_agent.ts | 56 ++++++ .../add_cis_integration_form_page.ts | 7 + .../apis/package_policy/delete.ts | 2 +- ...config.cloud_security_posture.agentless.ts | 16 +- .../agentless/cis_integration_aws.ts | 11 +- .../agentless/cis_integration_gcp.ts | 9 +- 33 files changed, 712 insertions(+), 277 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.test.tsx index 9781270d9dd81..4df4b4fe912d9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.test.tsx @@ -109,6 +109,39 @@ describe('AgentPolicyActionMenu', () => { const deleteButton = result.getByTestId('agentPolicyActionMenuDeleteButton'); expect(deleteButton).toHaveAttribute('disabled'); }); + + it('is disabled when agent policy support agentless is true', () => { + const testRenderer = createFleetTestRendererMock(); + const agentlessPolicy: AgentPolicy = { + ...baseAgentPolicy, + supports_agentless: true, + package_policies: [ + { + id: 'test-package-policy', + is_managed: false, + created_at: new Date().toISOString(), + created_by: 'test', + enabled: true, + inputs: [], + name: 'test-package-policy', + namespace: 'default', + policy_id: 'test', + policy_ids: ['test'], + revision: 1, + updated_at: new Date().toISOString(), + updated_by: 'test', + }, + ], + }; + + const result = testRenderer.render(<AgentPolicyActionMenu agentPolicy={agentlessPolicy} />); + + const agentActionsButton = result.getByTestId('agentActionsBtn'); + agentActionsButton.click(); + + const deleteButton = result.getByTestId('agentPolicyActionMenuDeleteButton'); + expect(deleteButton).not.toHaveAttribute('disabled'); + }); }); describe('add agent', () => { @@ -176,6 +209,39 @@ describe('AgentPolicyActionMenu', () => { const addButton = result.getByTestId('agentPolicyActionMenuAddAgentButton'); expect(addButton).toHaveAttribute('disabled'); }); + + it('should remove add agent button when agent policy support agentless is true', () => { + const testRenderer = createFleetTestRendererMock(); + const agentlessPolicy: AgentPolicy = { + ...baseAgentPolicy, + supports_agentless: true, + package_policies: [ + { + id: 'test-package-policy', + is_managed: false, + created_at: new Date().toISOString(), + created_by: 'test', + enabled: true, + inputs: [], + name: 'test-package-policy', + namespace: 'default', + policy_id: 'test', + policy_ids: ['test'], + revision: 1, + updated_at: new Date().toISOString(), + updated_by: 'test', + }, + ], + }; + + const result = testRenderer.render(<AgentPolicyActionMenu agentPolicy={agentlessPolicy} />); + + const agentActionsButton = result.getByTestId('agentActionsBtn'); + agentActionsButton.click(); + + const addAgentActionButton = result.queryByTestId('agentPolicyActionMenuAddAgentButton'); + expect(addAgentActionButton).toBeNull(); + }); }); describe('add fleet server', () => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx index 48f391a4e545d..bfb364abf8a5d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/actions_menu.tsx @@ -100,82 +100,106 @@ export const AgentPolicyActionMenu = memo<{ </EuiContextMenuItem> ); - const menuItems = agentPolicy?.is_managed - ? [viewPolicyItem] - : [ + const deletePolicyItem = ( + <AgentPolicyDeleteProvider + hasFleetServer={policyHasFleetServer(agentPolicy as AgentPolicy)} + key="deletePolicy" + agentPolicy={agentPolicy} + packagePolicies={agentPolicy.package_policies} + > + {(deleteAgentPolicyPrompt) => ( <EuiContextMenuItem - icon="plusInCircle" - disabled={ - (isFleetServerPolicy && !authz.fleet.addFleetServers) || - (!isFleetServerPolicy && !authz.fleet.addAgents) + data-test-subj="agentPolicyActionMenuDeleteButton" + disabled={!authz.fleet.allAgentPolicies || hasManagedPackagePolicy} + toolTipContent={ + hasManagedPackagePolicy ? ( + <FormattedMessage + id="xpack.fleet.policyForm.deletePolicyActionText.disabled" + defaultMessage="Agent policy with managed package policies cannot be deleted." + data-test-subj="agentPolicyActionMenuDeleteButtonDisabledTooltip" + /> + ) : undefined } - data-test-subj="agentPolicyActionMenuAddAgentButton" - onClick={() => { - setIsContextMenuOpen(false); - setIsEnrollmentFlyoutOpen(true); - }} - key="enrollAgents" - > - {isFleetServerPolicy ? ( - <FormattedMessage - id="xpack.fleet.agentPolicyActionMenu.addFleetServerActionText" - defaultMessage="Add Fleet Server" - /> - ) : ( - <FormattedMessage - id="xpack.fleet.agentPolicyActionMenu.enrollAgentActionText" - defaultMessage="Add agent" - /> - )} - </EuiContextMenuItem>, - viewPolicyItem, - <EuiContextMenuItem - disabled={!authz.integrations.writeIntegrationPolicies} - icon="copy" + icon="trash" onClick={() => { - setIsContextMenuOpen(false); - copyAgentPolicyPrompt(agentPolicy, onCopySuccess); + deleteAgentPolicyPrompt(agentPolicy.id); }} - key="copyPolicy" > <FormattedMessage - id="xpack.fleet.agentPolicyActionMenu.copyPolicyActionText" - defaultMessage="Duplicate policy" + id="xpack.fleet.agentPolicyActionMenu.deletePolicyActionText" + defaultMessage="Delete policy" /> - </EuiContextMenuItem>, - <AgentPolicyDeleteProvider - hasFleetServer={policyHasFleetServer(agentPolicy as AgentPolicy)} - key="deletePolicy" - packagePolicies={agentPolicy.package_policies} - > - {(deleteAgentPolicyPrompt) => ( - <EuiContextMenuItem - data-test-subj="agentPolicyActionMenuDeleteButton" - disabled={!authz.fleet.allAgentPolicies || hasManagedPackagePolicy} - toolTipContent={ - hasManagedPackagePolicy ? ( - <FormattedMessage - id="xpack.fleet.policyForm.deletePolicyActionText.disabled" - defaultMessage="Agent policy with managed package policies cannot be deleted." - data-test-subj="agentPolicyActionMenuDeleteButtonDisabledTooltip" - /> - ) : undefined - } - icon="trash" - onClick={() => { - deleteAgentPolicyPrompt(agentPolicy.id); - }} - > - <FormattedMessage - id="xpack.fleet.agentPolicyActionMenu.deletePolicyActionText" - defaultMessage="Delete policy" - /> - </EuiContextMenuItem> - )} - </AgentPolicyDeleteProvider>, - ]; + </EuiContextMenuItem> + )} + </AgentPolicyDeleteProvider> + ); + + const copyPolicyItem = ( + <EuiContextMenuItem + data-test-subj="agentPolicyActionMenuCopyButton" + disabled={!authz.integrations.writeIntegrationPolicies} + icon="copy" + onClick={() => { + setIsContextMenuOpen(false); + copyAgentPolicyPrompt(agentPolicy, onCopySuccess); + }} + key="copyPolicy" + > + <FormattedMessage + id="xpack.fleet.agentPolicyActionMenu.copyPolicyActionText" + defaultMessage="Duplicate policy" + /> + </EuiContextMenuItem> + ); + + const managedMenuItems = [viewPolicyItem]; + const agentBasedMenuItems = [ + <EuiContextMenuItem + icon="plusInCircle" + disabled={ + (isFleetServerPolicy && !authz.fleet.addFleetServers) || + (!isFleetServerPolicy && !authz.fleet.addAgents) + } + data-test-subj="agentPolicyActionMenuAddAgentButton" + onClick={() => { + setIsContextMenuOpen(false); + setIsEnrollmentFlyoutOpen(true); + }} + key="enrollAgents" + > + {isFleetServerPolicy ? ( + <FormattedMessage + id="xpack.fleet.agentPolicyActionMenu.addFleetServerActionText" + defaultMessage="Add Fleet Server" + /> + ) : ( + <FormattedMessage + id="xpack.fleet.agentPolicyActionMenu.enrollAgentActionText" + defaultMessage="Add agent" + /> + )} + </EuiContextMenuItem>, + viewPolicyItem, + copyPolicyItem, + deletePolicyItem, + ]; + const agentlessMenuItems = [viewPolicyItem, deletePolicyItem]; + + let menuItems; + + if (agentPolicy?.is_managed) { + menuItems = managedMenuItems; + } else if (agentPolicy?.supports_agentless) { + menuItems = agentlessMenuItems; + } else { + menuItems = agentBasedMenuItems; + } - if (authz.fleet.allAgents && !agentPolicy?.is_managed) { + if ( + authz.fleet.allAgents && + !agentPolicy?.is_managed && + !agentPolicy?.supports_agentless + ) { menuItems.push( <EuiContextMenuItem icon="refresh" @@ -193,7 +217,12 @@ export const AgentPolicyActionMenu = memo<{ ); } - if (authz.fleet.allAgents && agentTamperProtectionEnabled && !agentPolicy?.is_managed) { + if ( + authz.fleet.allAgents && + agentTamperProtectionEnabled && + !agentPolicy?.is_managed && + !agentPolicy?.supports_agentless + ) { menuItems.push( <EuiContextMenuItem icon="minusInCircle" diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index fc16d56107ccd..841bd756d8687 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -121,6 +121,8 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = const licenseService = useLicense(); const [isUninstallCommandFlyoutOpen, setIsUninstallCommandFlyoutOpen] = useState(false); const policyHasElasticDefend = useMemo(() => hasElasticDefend(agentPolicy), [agentPolicy]); + const isManagedorAgentlessPolicy = + agentPolicy.is_managed === true || agentPolicy?.supports_agentless === true; const AgentTamperProtectionSectionContent = useMemo( () => ( @@ -196,7 +198,12 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = ); const AgentTamperProtectionSection = useMemo(() => { - if (agentTamperProtectionEnabled && licenseService.isPlatinum() && !agentPolicy.is_managed) { + if ( + agentTamperProtectionEnabled && + licenseService.isPlatinum() && + !agentPolicy.is_managed && + !agentPolicy.supports_agentless + ) { if (AgentTamperProtectionWrapper) { return ( <Suspense fallback={null}> @@ -214,6 +221,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = agentPolicy.is_managed, AgentTamperProtectionWrapper, AgentTamperProtectionSectionContent, + agentPolicy.supports_agentless, ]); return ( @@ -405,7 +413,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = > <EuiSpacer size="l" /> <EuiCheckboxGroup - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} options={[ { id: `${dataTypes.Logs}_${monitoringCheckboxIdSuffix}`, @@ -541,7 +549,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = > <EuiFieldNumber fullWidth - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} value={agentPolicy.inactivity_timeout || ''} min={0} onChange={(e) => { @@ -582,7 +590,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = isInvalid={Boolean(touchedFields.fleet_server_host_id && validation.fleet_server_host_id)} > <EuiSuperSelect - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} valueOfSelected={agentPolicy.fleet_server_host_id || DEFAULT_SELECT_VALUE} fullWidth isLoading={isLoadingFleetServerHostsOption} @@ -623,7 +631,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = isDisabled={disabled} > <EuiSuperSelect - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} valueOfSelected={agentPolicy.data_output_id || DEFAULT_SELECT_VALUE} fullWidth isLoading={isLoadingOptions} @@ -664,7 +672,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = isDisabled={disabled} > <EuiSuperSelect - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} valueOfSelected={agentPolicy.monitoring_output_id || DEFAULT_SELECT_VALUE} fullWidth isLoading={isLoadingOptions} @@ -706,7 +714,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = isDisabled={disabled} > <EuiSuperSelect - disabled={disabled} + disabled={disabled || agentPolicy?.supports_agentless === true} valueOfSelected={agentPolicy.download_source_id || DEFAULT_SELECT_VALUE} fullWidth isLoading={isLoadingDownloadSources} @@ -739,7 +747,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = > <EuiFormRow fullWidth isDisabled={disabled}> <EuiRadioGroup - disabled={disabled} + disabled={disabled || agentPolicy?.supports_agentless === true} options={[ { id: 'hostname', @@ -834,7 +842,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent<Props> = > <EuiFieldNumber fullWidth - disabled={disabled || agentPolicy.is_managed === true} + disabled={disabled || isManagedorAgentlessPolicy} value={agentPolicy.unenroll_timeout || ''} min={0} onChange={(e) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx index 58b764ed68add..32c350108bccf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -23,12 +23,13 @@ import { sendGetAgents, } from '../../../hooks'; -import type { PackagePolicy } from '../../../types'; +import type { AgentPolicy, PackagePolicy } from '../../../types'; interface Props { children: (deleteAgentPolicy: DeleteAgentPolicy) => React.ReactElement; hasFleetServer: boolean; packagePolicies?: PackagePolicy[]; + agentPolicy: AgentPolicy; } export type DeleteAgentPolicy = (agentPolicy: string, onSuccess?: OnSuccessCallback) => void; @@ -39,12 +40,13 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ children, hasFleetServer, packagePolicies, + agentPolicy, }) => { const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); - const [agentPolicy, setAgentPolicy] = useState<string>(); + const [agentPolicyId, setAgentPolicyId] = useState<string>(); const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isLoadingAgentsCount, setIsLoadingAgentsCount] = useState<boolean>(false); const [agentsCount, setAgentsCount] = useState<number>(0); @@ -56,20 +58,20 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ const { canUseMultipleAgentPolicies } = useMultipleAgentPolicies(); const deleteAgentPolicyPrompt: DeleteAgentPolicy = ( - agentPolicyToDelete, + agentPolicyIdToDelete, onSuccess = () => undefined ) => { - if (!agentPolicyToDelete) { + if (!agentPolicyIdToDelete) { throw new Error('No agent policy specified for deletion'); } setIsModalOpen(true); - setAgentPolicy(agentPolicyToDelete); - fetchAgentsCount(agentPolicyToDelete); + setAgentPolicyId(agentPolicyIdToDelete); + fetchAgentsCount(agentPolicyIdToDelete); onSuccessCallback.current = onSuccess; }; const closeModal = () => { - setAgentPolicy(undefined); + setAgentPolicyId(undefined); setIsLoading(false); setIsLoadingAgentsCount(false); setIsModalOpen(false); @@ -80,7 +82,7 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ try { const { data } = await deleteAgentPolicyMutation.mutateAsync({ - agentPolicyId: agentPolicy!, + agentPolicyId: agentPolicyId!, }); if (data) { @@ -91,13 +93,13 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ }) ); if (onSuccessCallback.current) { - onSuccessCallback.current(agentPolicy!); + onSuccessCallback.current(agentPolicyId!); } } else { notifications.toasts.addDanger( i18n.translate('xpack.fleet.deleteAgentPolicy.failureSingleNotificationTitle', { defaultMessage: "Error deleting agent policy ''{id}''", - values: { id: agentPolicy }, + values: { id: agentPolicyId }, }) ); } @@ -173,7 +175,9 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ ) } buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} + confirmButtonDisabled={ + isLoading || isLoadingAgentsCount || (!agentPolicy?.supports_agentless && !!agentsCount) + } > {packagePoliciesWithMultiplePolicies && ( <> @@ -206,13 +210,23 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent<Props> = ({ } )} > - <FormattedMessage - id="xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage" - defaultMessage="{agentsCount, plural, one {# agent is} other {# agents are}} assigned to this agent policy. Unassign these agents before deleting this policy. This might include inactive agents." - values={{ - agentsCount, - }} - /> + {agentPolicy?.supports_agentless ? ( + <FormattedMessage + id="xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentlessMessage" + defaultMessage="Deleting this agent policy will automatically delete integrations assign to {name} and unenroll elastic agent." + values={{ + name: <strong>{agentPolicy.name}</strong>, + }} + /> + ) : ( + <FormattedMessage + id="xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage" + defaultMessage="{agentsCount, plural, one {# agent is} other {# agents are}} assigned to this agent policy. Unassign these agents before deleting this policy. This might include inactive agents." + values={{ + agentsCount, + }} + /> + )} </EuiCallOut> ) : hasFleetServer ? ( <FormattedMessage diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx index 878089bbde8e2..b437d61f64c58 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_form.tsx @@ -189,7 +189,7 @@ export const AgentPolicyForm: React.FunctionComponent<Props> = ({ <EuiSpacer size="m" /> <ConfiguredSettings configuredSettings={AGENT_POLICY_ADVANCED_SETTINGS} - disabled={isDisabled} + disabled={isDisabled || !!agentPolicy?.supports_agentless} /> </> ) : null} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx index c39466d779548..29722bbc42850 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx @@ -127,34 +127,36 @@ export function useAgentPoliciesOptions(packageInfo?: PackageInfo) { const agentPolicyMultiOptions: Array<EuiComboBoxOptionOption<string>> = useMemo( () => packageInfo && !isOutputLoading && !isAgentPoliciesLoading && !isLoadingPackagePolicies - ? agentPolicies.map((policy) => { - const isLimitedPackageAlreadyInPolicy = - isPackageLimited(packageInfo!) && - packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; - - const isAPMPackageAndDataOutputIsLogstash = - packageInfo?.name === FLEET_APM_PACKAGE && - getDataOutputForPolicy(policy)?.type === outputType.Logstash; - - return { - append: isAPMPackageAndDataOutputIsLogstash ? ( - <EuiToolTip - content={ - <FormattedMessage - id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" - defaultMessage="Logstash output for integrations is not supported with APM" - /> - } - > - <EuiIcon size="s" type="warningFilled" /> - </EuiToolTip> - ) : null, - key: policy.id, - label: policy.name, - disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, - 'data-test-subj': 'agentPolicyMultiItem', - }; - }) + ? agentPolicies + .filter((policy) => policy.supports_agentless !== true) + .map((policy) => { + const isLimitedPackageAlreadyInPolicy = + isPackageLimited(packageInfo!) && + packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo?.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(policy)?.type === outputType.Logstash; + + return { + append: isAPMPackageAndDataOutputIsLogstash ? ( + <EuiToolTip + content={ + <FormattedMessage + id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyDisabledAPMLogstashOuputText" + defaultMessage="Logstash output for integrations is not supported with APM" + /> + } + > + <EuiIcon size="s" type="warningFilled" /> + </EuiToolTip> + ) : null, + key: policy.id, + label: policy.name, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyMultiItem', + }; + }) : [], [ packageInfo, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx index ca96066facba3..2bae962f48e7c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx @@ -315,7 +315,11 @@ export function useOnSubmit({ if ( (agentCount !== 0 || (agentPolicies.length === 0 && selectedPolicyTab !== SelectedPolicyTab.NEW)) && - !(isAgentlessIntegration(packageInfo) || isAgentlessPackagePolicy(packagePolicy)) && + !( + isAgentlessIntegration(packageInfo) || + isAgentlessPackagePolicy(packagePolicy) || + isAgentlessAgentPolicy(overrideCreatedAgentPolicy) + ) && formState !== 'CONFIRM' ) { setFormState('CONFIRM'); @@ -339,10 +343,18 @@ export function useOnSubmit({ } } catch (e) { setFormState('VALID'); + const agentlessPolicy = agentPolicies.find( + (policy) => policy?.supports_agentless === true + ); + notifications.toasts.addError(e, { - title: i18n.translate('xpack.fleet.createAgentPolicy.errorNotificationTitle', { - defaultMessage: 'Unable to create agent policy', - }), + title: agentlessPolicy?.supports_agentless + ? i18n.translate('xpack.fleet.createAgentlessPolicy.errorNotificationTitle', { + defaultMessage: 'Unable to create integration', + }) + : i18n.translate('xpack.fleet.createAgentPolicy.errorNotificationTitle', { + defaultMessage: 'Unable to create agent policy', + }), }); return; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts index 141622410076d..fb6aefcf7583e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts @@ -174,7 +174,6 @@ export function useSetupTechnology({ setNewAgentPolicy({ ...newAgentBasedPolicy.current, supports_agentless: false, - is_managed: false, }); setSelectedPolicyTab(SelectedPolicyTab.NEW); updateAgentPolicies([newAgentBasedPolicy.current] as AgentPolicy[]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/header/right_content.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/header/right_content.tsx index 5dd391450b0cb..6603698a80186 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/header/right_content.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/header/right_content.tsx @@ -106,7 +106,7 @@ export const HeaderRightContent: React.FunctionComponent<HeaderRightContentProps ), }, { isDivider: true }, - ...(authz.fleet.readAgents + ...(authz.fleet.readAgents && !agentPolicy?.supports_agentless ? [ { label: i18n.translate('xpack.fleet.policyDetails.summary.usedBy', { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 384920b252a5a..94bcf380f7086 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -495,7 +495,14 @@ describe('edit package policy page', () => { }, }); (sendGetOneAgentPolicy as MockFn).mockResolvedValue({ - data: { item: { id: 'agentless', name: 'Agentless policy', namespace: 'default' } }, + data: { + item: { + id: 'agentless', + name: 'Agentless policy', + namespace: 'default', + supports_agentless: true, + }, + }, }); render(); @@ -514,6 +521,20 @@ describe('edit package policy page', () => { expect(sendUpdatePackagePolicy).toHaveBeenCalled(); }); + it('should hide the multiselect agent policies when agent policy is agentless', async () => { + (useGetAgentPolicies as MockFn).mockReturnValue({ + data: { + items: [{ id: 'agent-policy-1', name: 'Agent policy 1', supports_agentless: true }], + }, + isLoading: false, + }); + + await act(async () => { + render(); + }); + expect(renderResult.queryByTestId('agentPolicyMultiSelect')).not.toBeInTheDocument(); + }); + describe('modify agent policies', () => { beforeEach(() => { useMultipleAgentPoliciesMock.mockReturnValue({ canUseMultipleAgentPolicies: true }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 2bfdd40a9df2f..e448d1376b2fe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -46,8 +46,6 @@ import { StepConfigurePackagePolicy, StepDefinePackagePolicy, } from '../create_package_policy_page/components'; - -import { AGENTLESS_POLICY_ID } from '../../../../../../common/constants'; import type { AgentPolicy, PackagePolicyEditExtensionComponentProps } from '../../../types'; import { pkgKeyFromPackageInfo } from '../../../services'; @@ -75,7 +73,6 @@ export const EditPackagePolicyPage = memo(() => { } = useRouteMatch<{ policyId: string; packagePolicyId: string }>(); const packagePolicy = useGetOnePackagePolicy(packagePolicyId); - const extensionView = useUIExtension( packagePolicy.data?.item?.package?.name ?? '', 'package-policy-edit' @@ -106,8 +103,7 @@ export const EditPackagePolicyForm = memo<{ } = useConfig(); const { getHref } = useLink(); const { canUseMultipleAgentPolicies } = useMultipleAgentPolicies(); - const { isAgentlessPackagePolicy } = useAgentless(); - + const { isAgentlessAgentPolicy } = useAgentless(); const { // data agentPolicies: existingAgentPolicies, @@ -130,7 +126,14 @@ export const EditPackagePolicyForm = memo<{ } = usePackagePolicyWithRelatedData(packagePolicyId, { forceUpgrade, }); - const hasAgentlessAgentPolicy = packagePolicy.policy_ids.includes(AGENTLESS_POLICY_ID); + + const hasAgentlessAgentPolicy = useMemo( + () => + existingAgentPolicies.length === 1 + ? existingAgentPolicies.some((policy) => isAgentlessAgentPolicy(policy)) + : false, + [existingAgentPolicies, isAgentlessAgentPolicy] + ); const canWriteIntegrationPolicies = useAuthz().integrations.writeIntegrationPolicies; useSetIsReadOnly(!canWriteIntegrationPolicies); @@ -451,7 +454,7 @@ export const EditPackagePolicyForm = memo<{ onChange={handleExtensionViewOnChange} validationResults={validationResults} isEditPage={true} - isAgentlessEnabled={isAgentlessPackagePolicy(packagePolicy)} + isAgentlessEnabled={hasAgentlessAgentPolicy} /> </ExtensionWrapper> ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index feefebf4fa2d6..231b10782eca7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -71,7 +71,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ onClick={() => { setIsReassignFlyoutOpen(true); }} - disabled={!agent.active && !agentPolicy} + disabled={(!agent.active && !agentPolicy) || agentPolicy?.supports_agentless === true} key="reassignPolicy" > <FormattedMessage diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx index d8f88514aaaf3..6ef1c5b83c104 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx @@ -76,7 +76,7 @@ export const TableRowActions: React.FunctionComponent<{ onClick={() => { onReassignClick(); }} - disabled={!agent.active} + disabled={!agent.active || agentPolicy?.supports_agentless === true} key="reassignPolicy" > <FormattedMessage @@ -107,7 +107,7 @@ export const TableRowActions: React.FunctionComponent<{ <EuiContextMenuItem key="agentUpgradeBtn" icon="refresh" - disabled={!isAgentUpgradeable(agent)} + disabled={!isAgentUpgradeable(agent) || agentPolicy?.supports_agentless === true} onClick={() => { onUpgradeClick(); }} @@ -138,7 +138,12 @@ export const TableRowActions: React.FunctionComponent<{ ); } - if (authz.fleet.allAgents && agentTamperProtectionEnabled && agent.policy_id) { + if ( + authz.fleet.allAgents && + agentTamperProtectionEnabled && + agent.policy_id && + !agentPolicy?.supports_agentless + ) { menuItems.push( <EuiContextMenuItem icon="minusInCircle" diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/debug/components/agent_policy_debugger.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/debug/components/agent_policy_debugger.tsx index 78be94ef07048..98eed3cb63b4d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/debug/components/agent_policy_debugger.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/debug/components/agent_policy_debugger.tsx @@ -128,6 +128,7 @@ export const AgentPolicyDebugger: React.FunctionComponent = () => { {selectedPolicyId && ( <AgentPolicyDeleteProvider + agentPolicy={selectedAgentPolicy as AgentPolicy} hasFleetServer={policyHasFleetServer(selectedAgentPolicy as AgentPolicy)} > {(deleteAgentPolicyPrompt) => { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index e7f951f9c4270..0aea38990f06b 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -33,7 +33,7 @@ const AgentPolicyFormRow = styled(EuiFormRow)` `; type Props = { - agentPolicies: Array<Pick<AgentPolicy, 'id' | 'name'>>; + agentPolicies: Array<Pick<AgentPolicy, 'id' | 'name' | 'supports_agentless'>>; selectedPolicyId?: string; setSelectedPolicyId: (agentPolicyId?: string) => void; excludeFleetServer?: boolean; @@ -115,10 +115,12 @@ export const AgentPolicySelection: React.FC<Props> = (props) => { <EuiSelect fullWidth isLoading={!agentPolicies} - options={agentPolicies.map((agentPolicy) => ({ - value: agentPolicy.id, - text: agentPolicy.name, - }))} + options={agentPolicies + .filter((policy) => !policy?.supports_agentless) + .map((agentPolicy) => ({ + value: agentPolicy.id, + text: agentPolicy.name, + }))} value={selectedPolicyId} onChange={onChangeCallback} aria-label={i18n.translate( diff --git a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.test.tsx b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.test.tsx index 90e680c2ff845..dbf3969ffc226 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.test.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; -import { act, fireEvent } from '@testing-library/react'; +import { act } from '@testing-library/react'; import type { AgentPolicy, InMemoryPackagePolicy } from '../types'; import { createIntegrationsTestRendererMock } from '../mock'; -import { useMultipleAgentPolicies, useStartServices, useLink } from '../hooks'; +import { useMultipleAgentPolicies, useLink } from '../hooks'; import { PackagePolicyActionsMenu } from './package_policy_actions_menu'; @@ -135,6 +135,17 @@ describe('PackagePolicyActionsMenu', () => { }); }); + it('Should not enable upgrade button if package has upgrade and agentless policy is enabled', async () => { + const agentPolicies = createMockAgentPolicies({ supports_agentless: true }); + const packagePolicy = createMockPackagePolicy({ hasUpgrade: true }); + const { utils } = renderMenu({ agentPolicies, packagePolicy }); + + await act(async () => { + const upgradeButton = utils.getByTestId('PackagePolicyActionsUpgradeItem'); + expect(upgradeButton).toBeDisabled(); + }); + }); + it('Should not be able to delete integration from a managed policy', async () => { const agentPolicies = createMockAgentPolicies({ is_managed: true }); const packagePolicy = createMockPackagePolicy(); @@ -154,7 +165,7 @@ describe('PackagePolicyActionsMenu', () => { }); it('Should be able to delete integration from a managed agentless policy', async () => { - const agentPolicies = createMockAgentPolicies({ is_managed: true, supports_agentless: true }); + const agentPolicies = createMockAgentPolicies({ is_managed: false, supports_agentless: true }); const packagePolicy = createMockPackagePolicy(); const { utils } = renderMenu({ agentPolicies, packagePolicy }); await act(async () => { @@ -162,23 +173,6 @@ describe('PackagePolicyActionsMenu', () => { }); }); - it('Should navigate on delete integration when having an agentless policy', async () => { - const agentPolicies = createMockAgentPolicies({ is_managed: true, supports_agentless: true }); - const packagePolicy = createMockPackagePolicy(); - const { utils } = renderMenu({ agentPolicies, packagePolicy }); - - await act(async () => { - fireEvent.click(utils.getByTestId('PackagePolicyActionsDeleteItem')); - }); - await act(async () => { - fireEvent.click(utils.getByTestId('confirmModalConfirmButton')); - }); - expect(useStartServices().application.navigateToApp as jest.Mock).toHaveBeenCalledWith( - 'fleet', - { path: '/policies' } - ); - }); - it('Should show add button if the policy is not managed and showAddAgent=true', async () => { const agentPolicies = createMockAgentPolicies(); const packagePolicy = createMockPackagePolicy({ hasUpgrade: true }); @@ -197,6 +191,15 @@ describe('PackagePolicyActionsMenu', () => { }); }); + it('Should not show add button if the policy is agentless and showAddAgent=true', async () => { + const agentPolicies = createMockAgentPolicies({ supports_agentless: true }); + const packagePolicy = createMockPackagePolicy({ hasUpgrade: true }); + const { utils } = renderMenu({ agentPolicies, packagePolicy, showAddAgent: true }); + await act(async () => { + expect(utils.queryByText('Add agent')).toBeNull(); + }); + }); + it('Should show Edit integration with correct href when agentPolicy is defined', async () => { const agentPolicies = createMockAgentPolicies(); const packagePolicy = createMockPackagePolicy(); diff --git a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx index fcebfcb2f2475..4da1711b28313 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx @@ -10,11 +10,9 @@ import { EuiContextMenuItem, EuiPortal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { AgentPolicy, InMemoryPackagePolicy } from '../types'; -import { useAgentPolicyRefresh, useAuthz, useLink, useStartServices } from '../hooks'; +import { useAgentPolicyRefresh, useAuthz, useLink } from '../hooks'; import { policyHasFleetServer } from '../services'; -import { PLUGIN_ID, pagePathGetters } from '../constants'; - import { AgentEnrollmentFlyout } from './agent_enrollment_flyout'; import { ContextMenuActions } from './context_menu_actions'; import { DangerEuiContextMenuItem } from './danger_eui_context_menu_item'; @@ -38,9 +36,6 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); const { getHref } = useLink(); const authz = useAuthz(); - const { - application: { navigateToApp }, - } = useStartServices(); const agentPolicy = agentPolicies.length > 0 ? agentPolicies[0] : undefined; // TODO: handle multiple agent policies const canWriteIntegrationPolicies = authz.integrations.writeIntegrationPolicies; @@ -54,7 +49,8 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ const agentPolicyIsManaged = Boolean(agentPolicy?.is_managed); const isOrphanedPolicy = !agentPolicy && packagePolicy.policy_ids.length === 0; - const isAddAgentVisible = showAddAgent && agentPolicy && !agentPolicyIsManaged; + const isAddAgentVisible = + showAddAgent && agentPolicy && !agentPolicyIsManaged && !agentPolicy?.supports_agentless; const onEnrollmentFlyoutClose = useMemo(() => { return () => setIsEnrollmentFlyoutOpen(false); @@ -115,7 +111,10 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ <EuiContextMenuItem data-test-subj="PackagePolicyActionsUpgradeItem" disabled={ - !packagePolicy.hasUpgrade || !canWriteIntegrationPolicies || !upgradePackagePolicyHref + !packagePolicy.hasUpgrade || + !canWriteIntegrationPolicies || + !upgradePackagePolicyHref || + agentPolicy?.supports_agentless === true } icon="refresh" href={upgradePackagePolicyHref} @@ -150,12 +149,7 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ onClick={() => { deletePackagePoliciesPrompt([packagePolicy.id], () => { setIsActionsMenuOpen(false); - if (agentPolicy?.supports_agentless) { - // go back to all agent policies - navigateToApp(PLUGIN_ID, { path: pagePathGetters.policies_list()[1] }); - } else { - refreshAgentPolicy(); - } + refreshAgentPolicy(); }); }} > diff --git a/x-pack/plugins/fleet/public/components/package_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/components/package_policy_delete_provider.tsx index 6369d344a2d9f..7d71915fda252 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_delete_provider.tsx @@ -10,7 +10,12 @@ import { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiIconTip } from '@elastic/eui import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useStartServices, sendDeletePackagePolicy, useConfig } from '../hooks'; +import { + useStartServices, + sendDeletePackagePolicy, + sendDeleteAgentPolicy, + useConfig, +} from '../hooks'; import { AGENTS_PREFIX } from '../../common/constants'; import type { AgentPolicy } from '../types'; import { sendGetAgents, useMultipleAgentPolicies } from '../hooks'; @@ -126,6 +131,26 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ defaultMessage: "Deleted integration ''{id}''", values: { id: successfulResults[0].name || successfulResults[0].id }, }); + + const agentlessPolicy = agentPolicies?.find( + (policy) => policy.supports_agentless === true + ); + + if (!!agentlessPolicy) { + try { + await sendDeleteAgentPolicy({ agentPolicyId: agentlessPolicy.id }); + } catch (e) { + notifications.toasts.addDanger( + i18n.translate( + 'xpack.fleet.deletePackagePolicy.fatalErrorAgentlessNotificationTitle', + { + defaultMessage: 'Error deleting agentless deployment', + } + ) + ); + } + } + notifications.toasts.addSuccess(successMessage); } @@ -155,10 +180,14 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ } closeModal(); }, - [closeModal, packagePolicies, notifications.toasts] + [closeModal, packagePolicies, notifications.toasts, agentPolicies] ); const renderModal = () => { + const isAgentlessPolicy = agentPolicies?.find((policy) => policy?.supports_agentless === true); + const packagePolicy = agentPolicies?.[0]?.package_policies?.find( + (policy) => policy.id === packagePolicies[0] + ); if (!isModalOpen) { return null; } @@ -166,11 +195,18 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ return ( <EuiConfirmModal title={ - <FormattedMessage - id="xpack.fleet.deletePackagePolicy.confirmModal.deleteMultipleTitle" - defaultMessage="Delete {count, plural, one {integration} other {# integrations}}?" - values={{ count: packagePolicies.length }} - /> + isAgentlessPolicy ? ( + <FormattedMessage + id="xpack.fleet.deletePackagePolicy.confirmModal.agentlessTitle" + defaultMessage="You’re about to delete an integration" + /> + ) : ( + <FormattedMessage + id="xpack.fleet.deletePackagePolicy.confirmModal.deleteMultipleTitle" + defaultMessage="Delete {count, plural, one {integration} other {# integrations}}?" + values={{ count: packagePolicies.length }} + /> + ) } onCancel={closeModal} onConfirm={deletePackagePolicies} @@ -224,14 +260,16 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ <EuiCallOut color="danger" title={ - <FormattedMessage - id="xpack.fleet.deletePackagePolicy.confirmModal.affectedAgentsTitle" - defaultMessage="This action will affect {agentsCount} {agentsCount, plural, one {agent} other {agents}}." - values={{ agentsCount }} - /> + !isAgentlessPolicy && ( + <FormattedMessage + id="xpack.fleet.deletePackagePolicy.confirmModal.affectedAgentsTitle" + defaultMessage="This action will affect {agentsCount} {agentsCount, plural, one {agent} other {agents}}." + values={{ agentsCount }} + /> + ) } > - {hasMultipleAgentPolicies ? ( + {hasMultipleAgentPolicies && !isAgentlessPolicy && ( <FormattedMessage id="xpack.fleet.deletePackagePolicy.confirmModal.affectedAgentPoliciesMessage" defaultMessage="Fleet has detected that the related agent policies {toolTip} are already in use by some of your agents." @@ -254,7 +292,8 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ ), }} /> - ) : ( + )}{' '} + {!hasMultipleAgentPolicies && !isAgentlessPolicy && ( <FormattedMessage id="xpack.fleet.deletePackagePolicy.confirmModal.affectedAgentsMessage" defaultMessage="Fleet has detected that {agentPolicyName} is already in use by some of your agents." @@ -263,10 +302,20 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent<Props> = ({ }} /> )} + {!hasMultipleAgentPolicies && isAgentlessPolicy && ( + <FormattedMessage + id="xpack.fleet.deletePackagePolicy.agentless.confirmModal.message" + defaultMessage="Deleting {packagePolicyName} integration will stop data ingestion." + values={{ + packagePolicyName: <strong>{packagePolicy?.name}</strong>, + }} + /> + )} </EuiCallOut> <EuiSpacer size="l" /> </> ) : null} + {!isLoadingAgentsCount && ( <FormattedMessage id="xpack.fleet.deletePackagePolicy.confirmModal.generalMessage" diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts b/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts index b1a76f5a334f2..9e4fb2344fc29 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agent_policy.ts @@ -164,18 +164,20 @@ export const sendCopyAgentPolicy = ( }); }; +export const sendDeleteAgentPolicy = (body: DeleteAgentPolicyRequest['body']) => { + return sendRequest<DeleteAgentPolicyResponse>({ + path: agentPolicyRouteService.getDeletePath(), + method: 'post', + body: JSON.stringify(body), + version: API_VERSIONS.public.v1, + }); +}; + export function useDeleteAgentPolicyMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: function sendDeleteAgentPolicy(body: DeleteAgentPolicyRequest['body']) { - return sendRequest<DeleteAgentPolicyResponse>({ - path: agentPolicyRouteService.getDeletePath(), - method: 'post', - body: JSON.stringify(body), - version: API_VERSIONS.public.v1, - }); - }, + mutationFn: sendDeleteAgentPolicy, onSuccess: () => { return queryClient.invalidateQueries(['agentPolicies']); }, diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index dfd92951c8919..d8971948397d3 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -44,6 +44,7 @@ import { FleetNotFoundError, PackageSavedObjectConflictError, FleetTooManyRequestsError, + AgentlessPolicyExistsRequestError, } from '.'; type IngestErrorHandler = ( @@ -111,6 +112,9 @@ const getHTTPResponseCode = (error: FleetError): number => { if (error instanceof PackageAlreadyInstalledError) { return 409; } + if (error instanceof AgentlessPolicyExistsRequestError) { + return 409; + } // Unsupported Media Type if (error instanceof PackageUnsupportedMediaTypeError) { return 415; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 80d8116baaaa3..09b387e7a5cee 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -57,6 +57,12 @@ export class AgentlessAgentCreateError extends FleetError { } } +export class AgentlessPolicyExistsRequestError extends AgentPolicyError { + constructor(message: string) { + super(`Unable to create integration. ${message}`); + } +} + export class AgentPolicyNameExistsError extends AgentPolicyError {} export class AgentReassignmentError extends FleetError {} export class PackagePolicyIneligibleForUpgradeError extends FleetError {} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 9f42746c9c5fa..00bc01aa1f2cb 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -363,7 +363,7 @@ describe('Agent policy', () => { ); }); - it('should create a policy with is_managed true if agentless feature flag is set and in serverless env', async () => { + it('should create a policy agentless feature flag is set and in serverless env', async () => { jest .spyOn(appContextService, 'getExperimentalFeatures') .mockReturnValue({ agentless: true } as any); @@ -392,7 +392,7 @@ describe('Agent policy', () => { namespace: 'default', supports_agentless: true, status: 'active', - is_managed: true, + is_managed: false, revision: 1, updated_at: expect.anything(), updated_by: 'system', @@ -401,7 +401,7 @@ describe('Agent policy', () => { }); }); - it('should create a policy with is_managed true if agentless feature flag is set and in cloud env', async () => { + it('should create a policy if agentless feature flag is set and in cloud env', async () => { jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: true } as any); jest.spyOn(appContextService, 'getConfig').mockReturnValue({ agentless: { enabled: true }, @@ -428,7 +428,7 @@ describe('Agent policy', () => { namespace: 'default', supports_agentless: true, status: 'active', - is_managed: true, + is_managed: false, revision: 1, updated_at: expect.anything(), updated_by: 'system', diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 57514ec30052b..ffdb2c4162d52 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -84,6 +84,7 @@ import { FleetUnauthorizedError, HostedAgentPolicyRestrictionRelatedError, PackagePolicyRestrictionRelatedError, + AgentlessPolicyExistsRequestError, } from '../errors'; import type { FullAgentConfigMap } from '../../common/types/models/agent_cm'; @@ -113,6 +114,7 @@ import { createSoFindIterable } from './utils/create_so_find_iterable'; import { isAgentlessEnabled } from './utils/agentless'; import { validatePolicyNamespaceForSpace } from './spaces/policy_namespaces'; import { isSpaceAwarenessEnabled } from './spaces/helpers'; +import { agentlessAgentService } from './agents/agentless_agent'; import { scheduleDeployAgentPoliciesTask } from './agent_policies/deploy_agent_policies_task'; const KEY_EDITABLE_FOR_MANAGED_POLICIES = ['namespace']; @@ -387,7 +389,7 @@ class AgentPolicyService { { ...agentPolicy, status: 'active', - is_managed: (agentPolicy.is_managed || agentPolicy?.supports_agentless) ?? false, + is_managed: agentPolicy.is_managed ?? false, revision: 1, updated_at: new Date().toISOString(), updated_by: options?.user?.username || 'system', @@ -411,7 +413,7 @@ class AgentPolicyService { public async requireUniqueName( soClient: SavedObjectsClientContract, - givenPolicy: { id?: string; name: string } + givenPolicy: { id?: string; name: string; supports_agentless?: boolean | null } ) { const savedObjectType = await getAgentPolicySavedObjectType(); @@ -423,7 +425,11 @@ class AgentPolicyService { const idsWithName = results.total && results.saved_objects.map(({ id }) => id); if (Array.isArray(idsWithName)) { const isEditingSelf = givenPolicy.id && idsWithName.includes(givenPolicy.id); - if (!givenPolicy.id || !isEditingSelf) { + + if ( + (!givenPolicy?.supports_agentless && !givenPolicy.id) || + (!givenPolicy?.supports_agentless && !isEditingSelf) + ) { const isSinglePolicy = idsWithName.length === 1; const existClause = isSinglePolicy ? `Agent Policy '${idsWithName[0]}' already exists` @@ -431,6 +437,13 @@ class AgentPolicyService { throw new AgentPolicyNameExistsError(`${existClause} with name '${givenPolicy.name}'`); } + + if (givenPolicy?.supports_agentless && !givenPolicy.id) { + const integrationName = givenPolicy.name.split(' ').pop(); + throw new AgentlessPolicyExistsRequestError( + `${givenPolicy.name} already exist. Please rename the integration name ${integrationName}.` + ); + } } } @@ -661,6 +674,7 @@ class AgentPolicyService { await this.requireUniqueName(soClient, { id, name: agentPolicy.name, + supports_agentless: agentPolicy?.supports_agentless, }); } if (agentPolicy.namespace) { @@ -1141,6 +1155,7 @@ class AgentPolicyService { if (agentPolicy.is_managed && !options?.force) { throw new HostedAgentPolicyRestrictionRelatedError(`Cannot delete hosted agent policy ${id}`); } + // Prevent deleting policy when assigned agents are inactive const { total } = await getAgentsByKuery(esClient, soClient, { showInactive: true, @@ -1149,12 +1164,32 @@ class AgentPolicyService { kuery: `${AGENTS_PREFIX}.policy_id:${id}`, }); - if (total > 0) { + if (total > 0 && !agentPolicy?.supports_agentless) { throw new FleetError( 'Cannot delete an agent policy that is assigned to any active or inactive agents' ); } + if (agentPolicy?.supports_agentless) { + logger.debug(`Starting unenrolling agent from agentless policy ${id}`); + // unenroll offline agents for agentless policies first to avoid 404 Save Object error + await this.triggerAgentPolicyUpdatedEvent(esClient, 'deleted', id, { + spaceId: soClient.getCurrentNamespace(), + }); + try { + // Deleting agentless deployment + await agentlessAgentService.deleteAgentlessAgent(id); + logger.debug( + `[Agentless API] Successfully deleted agentless deployment for single agent policy id ${id}` + ); + } catch (error) { + logger.error( + `[Agentless API] Error deleting agentless deployment for single agent policy id ${id}` + ); + logger.error(error); + } + } + const packagePolicies = await packagePolicyService.findAllForAgentPolicy(soClient, id); if (packagePolicies.length) { @@ -1216,9 +1251,11 @@ class AgentPolicyService { await soClient.delete(savedObjectType, id, { force: true, // need to delete through multiple space }); - await this.triggerAgentPolicyUpdatedEvent(esClient, 'deleted', id, { - spaceId: soClient.getCurrentNamespace(), - }); + if (!agentPolicy?.supports_agentless) { + await this.triggerAgentPolicyUpdatedEvent(esClient, 'deleted', id, { + spaceId: soClient.getCurrentNamespace(), + }); + } // cleanup .fleet-policies docs on delete await this.deleteFleetServerPoliciesForPolicyId(esClient, id); diff --git a/x-pack/plugins/fleet/server/services/agent_policy_create.ts b/x-pack/plugins/fleet/server/services/agent_policy_create.ts index 4d22820b9aa1c..f370867fc493b 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy_create.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy_create.ts @@ -21,12 +21,11 @@ import { import type { AgentPolicy, NewAgentPolicy } from '../types'; -import { agentlessAgentService } from './agents/agentless_agent'; - import { agentPolicyService, packagePolicyService } from '.'; import { incrementPackageName } from './package_policies'; import { bulkInstallPackages } from './epm/packages'; import { ensureDefaultEnrollmentAPIKeyForAgentPolicy } from './api_keys'; +import { agentlessAgentService } from './agents/agentless_agent'; const FLEET_SERVER_POLICY_ID = 'fleet-server-policy'; @@ -84,7 +83,7 @@ async function createPackagePolicy( user: options.user, bumpRevision: false, authorizationHeader: options.authorizationHeader, - force: options.force || agentPolicy.supports_agentless === true, + force: options.force, }); } diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts index c98a5b63e0356..3bf21c3bec0d1 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts @@ -24,7 +24,12 @@ import { appContextService } from '../app_context'; import { listEnrollmentApiKeys } from '../api_keys'; import { listFleetServerHosts } from '../fleet_server_host'; -import { prependAgentlessApiBasePathToEndpoint, isAgentlessApiEnabled } from '../utils/agentless'; +import type { AgentlessConfig } from '../utils/agentless'; +import { + prependAgentlessApiBasePathToEndpoint, + isAgentlessApiEnabled, + getDeletionEndpointPath, +} from '../utils/agentless'; class AgentlessAgentService { public async createAgentlessAgent( @@ -42,23 +47,22 @@ class AgentlessAgentService { }; const logger = appContextService.getLogger(); - logger.debug(`Creating agentless agent ${agentlessAgentPolicy.id}`); + logger.debug(`[Agentless API] Creating agentless agent ${agentlessAgentPolicy.id}`); if (!isAgentlessApiEnabled) { logger.error( - 'Creating agentless agent not supported in non-cloud or non-serverless environments', - errorMetadata + '[Agentless API] Creating agentless agent not supported in non-cloud or non-serverless environments' ); throw new AgentlessAgentCreateError('Agentless agent not supported'); } if (!agentlessAgentPolicy.supports_agentless) { - logger.error('Agentless agent policy does not have agentless enabled'); + logger.error('[Agentless API] Agentless agent policy does not have agentless enabled'); throw new AgentlessAgentCreateError('Agentless agent policy does not have agentless enabled'); } const agentlessConfig = appContextService.getConfig()?.agentless; if (!agentlessConfig) { - logger.error('Missing agentless configuration', errorMetadata); + logger.error('[Agentless API] Missing agentless configuration', errorMetadata); throw new AgentlessAgentCreateError('missing agentless configuration'); } @@ -70,24 +74,16 @@ class AgentlessAgentService { ); logger.debug( - `Creating agentless agent with fleet_url: ${fleetUrl} and fleet_token: [REDACTED]` + `[Agentless API] Creating agentless agent with fleetUrl ${fleetUrl} and fleet_token: [REDACTED]` ); logger.debug( - `Creating agentless agent with TLS cert: ${ + `[Agentless API] Creating agentless agent with TLS cert: ${ agentlessConfig?.api?.tls?.certificate ? '[REDACTED]' : 'undefined' } and TLS key: ${agentlessConfig?.api?.tls?.key ? '[REDACTED]' : 'undefined'} and TLS ca: ${agentlessConfig?.api?.tls?.ca ? '[REDACTED]' : 'undefined'}` ); - - const tlsConfig = new SslConfig( - sslSchema.validate({ - enabled: true, - certificate: agentlessConfig?.api?.tls?.certificate, - key: agentlessConfig?.api?.tls?.key, - certificateAuthorities: agentlessConfig?.api?.tls?.ca, - }) - ); + const tlsConfig = this.createTlsConfig(agentlessConfig); const requestConfig: AxiosRequestConfig = { url: prependAgentlessApiBasePathToEndpoint(agentlessConfig, '/deployments'), @@ -114,33 +110,17 @@ class AgentlessAgentService { requestConfig.data.stack_version = appContextService.getKibanaVersion(); } - const requestConfigDebug = { - ...requestConfig, - data: { - ...requestConfig.data, - fleet_token: '[REDACTED]', - }, - httpsAgent: { - ...requestConfig.httpsAgent, - options: { - ...requestConfig.httpsAgent.options, - cert: requestConfig.httpsAgent.options.cert ? '[REDACTED]' : undefined, - key: requestConfig.httpsAgent.options.key ? '[REDACTED]' : undefined, - ca: requestConfig.httpsAgent.options.ca ? '[REDACTED]' : undefined, - }, - }, - }; - - const requestConfigDebugToString = JSON.stringify(requestConfigDebug); - - logger.debug(`Creating agentless agent with request config ${requestConfigDebugToString}`); + const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig); + logger.debug( + `[Agentless API] Creating agentless agent with request config ${requestConfigDebugStatus}` + ); const errorMetadataWithRequestConfig: LogMeta = { ...errorMetadata, http: { request: { id: traceId, - body: requestConfigDebug.data, + body: requestConfig.data, }, }, }; @@ -149,7 +129,7 @@ class AgentlessAgentService { (error: Error | AxiosError) => { if (!axios.isAxiosError(error)) { logger.error( - `Creating agentless failed with an error ${error} ${requestConfigDebugToString}`, + `[Agentless API] Creating agentless failed with an error ${error} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); throw new AgentlessAgentCreateError(withRequestIdMessage(error.message)); @@ -160,9 +140,9 @@ class AgentlessAgentService { if (error.response) { // The request was made and the server responded with a status code and error data logger.error( - `Creating agentless failed because the Agentless API responding with a status code that falls out of the range of 2xx: ${JSON.stringify( + `[Agentless API] Creating agentless failed because the Agentless API responding with a status code that falls out of the range of 2xx: ${JSON.stringify( error.response.status - )}} ${JSON.stringify(error.response.data)}} ${requestConfigDebugToString}`, + )}} ${JSON.stringify(error.response.data)}} ${requestConfigDebugStatus}`, { ...errorMetadataWithRequestConfig, http: { @@ -180,7 +160,7 @@ class AgentlessAgentService { } else if (error.request) { // The request was made but no response was received logger.error( - `Creating agentless agent failed while sending the request to the Agentless API: ${errorLogCodeCause} ${requestConfigDebugToString}`, + `[Agentless API] Creating agentless agent failed while sending the request to the Agentless API: ${errorLogCodeCause} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); throw new AgentlessAgentCreateError( @@ -189,7 +169,7 @@ class AgentlessAgentService { } else { // Something happened in setting up the request that triggered an Error logger.error( - `Creating agentless agent failed to be created ${errorLogCodeCause} ${requestConfigDebugToString}`, + `[Agentless API] Creating agentless agent failed to be created ${errorLogCodeCause} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); throw new AgentlessAgentCreateError( @@ -199,10 +179,110 @@ class AgentlessAgentService { } ); - logger.debug(`Created an agentless agent ${response}`); + logger.debug(`[Agentless API] Created an agentless agent ${response}`); + return response; + } + + public async deleteAgentlessAgent(agentlessPolicyId: string) { + const logger = appContextService.getLogger(); + const agentlessConfig = appContextService.getConfig()?.agentless; + const tlsConfig = this.createTlsConfig(agentlessConfig); + const requestConfig = { + url: getDeletionEndpointPath(agentlessConfig, `/deployments/${agentlessPolicyId}`), + method: 'DELETE', + headers: { + 'Content-type': 'application/json', + }, + httpsAgent: new https.Agent({ + rejectUnauthorized: tlsConfig.rejectUnauthorized, + cert: tlsConfig.certificate, + key: tlsConfig.key, + ca: tlsConfig.certificateAuthorities, + }), + }; + + const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig); + + logger.debug( + `[Agentless API] Start deleting agentless agent for agent policy ${requestConfigDebugStatus}` + ); + + if (!isAgentlessApiEnabled) { + logger.error( + '[Agentless API] Agentless API is not supported. Deleting agentless agent is not supported in non-cloud or non-serverless environments' + ); + } + + if (!agentlessConfig) { + logger.error('[Agentless API] kibana.yml is currently missing Agentless API configuration'); + } + + logger.debug(`[Agentless API] Deleting agentless agent with TLS config with certificate`); + + logger.debug( + `[Agentless API] Deleting agentless deployment with request config ${requestConfigDebugStatus}` + ); + + const response = await axios(requestConfig).catch((error: AxiosError) => { + const errorLogCodeCause = `${error.code} ${this.convertCauseErrorsToString(error)}`; + + if (!axios.isAxiosError(error)) { + logger.error( + `[Agentless API] Deleting agentless deployment failed with an error ${JSON.stringify( + error + )} ${requestConfigDebugStatus}` + ); + } + if (error.response) { + logger.error( + `[Agentless API] Deleting Agentless deployment Failed Response Error: ${JSON.stringify( + error.response.status + )}} ${JSON.stringify(error.response.data)}} ${requestConfigDebugStatus} ` + ); + } else if (error.request) { + logger.error( + `[Agentless API] Deleting agentless deployment failed to receive a response from the Agentless API ${errorLogCodeCause} ${requestConfigDebugStatus}` + ); + } else { + logger.error( + `[Agentless API] Deleting agentless deployment failed to delete the request ${errorLogCodeCause} ${requestConfigDebugStatus}` + ); + } + }); + return response; } + private createTlsConfig(agentlessConfig: AgentlessConfig | undefined) { + return new SslConfig( + sslSchema.validate({ + enabled: true, + certificate: agentlessConfig?.api?.tls?.certificate, + key: agentlessConfig?.api?.tls?.key, + certificateAuthorities: agentlessConfig?.api?.tls?.ca, + }) + ); + } + + private createRequestConfigDebug(requestConfig: AxiosRequestConfig<any>) { + return JSON.stringify({ + ...requestConfig, + data: { + ...requestConfig.data, + fleet_token: '[REDACTED]', + }, + httpsAgent: { + ...requestConfig.httpsAgent, + options: { + ...requestConfig.httpsAgent.options, + cert: requestConfig.httpsAgent.options.cert ? 'REDACTED' : undefined, + key: requestConfig.httpsAgent.options.key ? 'REDACTED' : undefined, + ca: requestConfig.httpsAgent.options.ca ? 'REDACTED' : undefined, + }, + }, + }); + } + private convertCauseErrorsToString = (error: AxiosError) => { if (error.cause instanceof AggregateError) { return error.cause.errors.map((e: Error) => e.message); diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 4a7b6c2e2ee70..86d81f3df9b1a 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -1444,12 +1444,6 @@ class PackagePolicyClientImpl implements PackagePolicyClient { }); } - if (agentlessAgentPolicies.length > 0) { - for (const agentPolicyId of agentlessAgentPolicies) { - await agentPolicyService.delete(soClient, esClient, agentPolicyId, { force: true }); - } - } - if (!options?.skipUnassignFromAgentPolicies) { let uniquePolicyIdsR = [ ...new Set( @@ -3021,8 +3015,7 @@ async function validateIsNotHostedPolicy( throw new AgentPolicyNotFoundError('Agent policy not found'); } - const isManagedPolicyWithoutServerlessSupport = - agentPolicy.is_managed && !agentPolicy.supports_agentless && !force; + const isManagedPolicyWithoutServerlessSupport = agentPolicy.is_managed && !force; if (isManagedPolicyWithoutServerlessSupport) { throw new HostedAgentPolicyRestrictionRelatedError( diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.ts b/x-pack/plugins/fleet/server/services/utils/agentless.ts index 5c544b1907b25..c85e9cc991a6c 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.ts @@ -28,6 +28,18 @@ const AGENTLESS_SERVERLESS_API_BASE_PATH = '/api/v1/serverless'; type AgentlessApiEndpoints = '/deployments' | `/deployments/${string}`; +export interface AgentlessConfig { + enabled?: boolean; + api?: { + url?: string; + tls?: { + certificate?: string; + key?: string; + ca?: string; + }; + }; +} + export const prependAgentlessApiBasePathToEndpoint = ( agentlessConfig: FleetConfigType['agentless'], endpoint: AgentlessApiEndpoints @@ -38,3 +50,10 @@ export const prependAgentlessApiBasePathToEndpoint = ( : AGENTLESS_ESS_API_BASE_PATH; return `${agentlessConfig.api.url}${endpointPrefix}${endpoint}`; }; + +export const getDeletionEndpointPath = ( + agentlessConfig: FleetConfigType['agentless'], + endpoint: AgentlessApiEndpoints +) => { + return `${agentlessConfig.api.url}${AGENTLESS_ESS_API_BASE_PATH}${endpoint}`; +}; diff --git a/x-pack/test/cloud_security_posture_functional/agentless/create_agent.ts b/x-pack/test/cloud_security_posture_functional/agentless/create_agent.ts index 1b1497140875e..2065d1307fbda 100644 --- a/x-pack/test/cloud_security_posture_functional/agentless/create_agent.ts +++ b/x-pack/test/cloud_security_posture_functional/agentless/create_agent.ts @@ -73,6 +73,62 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); }); + it(`should show setup technology selector in edit mode`, async () => { + const integrationPolicyName = `cloud_security_posture-${new Date().toISOString()}`; + await cisIntegration.navigateToAddIntegrationCspmWithVersionPage( + CLOUD_CREDENTIALS_PACKAGE_VERSION + ); + + await cisIntegration.clickOptionButton(CIS_AWS_OPTION_TEST_ID); + await cisIntegration.clickOptionButton(AWS_SINGLE_ACCOUNT_TEST_ID); + + await cisIntegration.inputIntegrationName(integrationPolicyName); + + await cisIntegration.selectSetupTechnology('agentless'); + await cisIntegration.selectAwsCredentials('direct'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + await cisIntegration.clickSaveButton(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegrationAws.showPostInstallCloudFormationModal()).to.be(false); + + await cisIntegration.navigateToIntegrationCspList(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + await cisIntegration.navigateToEditIntegrationPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegration.showSetupTechnologyComponent()).to.be(true); + }); + + it(`should hide setup technology selector in edit mode`, async () => { + const integrationPolicyName = `cloud_security_posture1-${new Date().toISOString()}`; + await cisIntegration.navigateToAddIntegrationCspmWithVersionPage( + CLOUD_CREDENTIALS_PACKAGE_VERSION + ); + + await cisIntegration.clickOptionButton(CIS_AWS_OPTION_TEST_ID); + await cisIntegration.clickOptionButton(AWS_SINGLE_ACCOUNT_TEST_ID); + + await cisIntegration.inputIntegrationName(integrationPolicyName); + await cisIntegration.selectSetupTechnology('agent-based'); + await pageObjects.header.waitUntilLoadingHasFinished(); + + await cisIntegration.clickSaveButton(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegrationAws.showPostInstallCloudFormationModal()).to.be(true); + + await cisIntegration.navigateToIntegrationCspList(); + await pageObjects.header.waitUntilLoadingHasFinished(); + await cisIntegration.navigateToEditIntegrationPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegration.showSetupTechnologyComponent()).to.be(false); + }); + it(`should create default agent-based agent`, async () => { const integrationPolicyName = `cloud_security_posture-${new Date().toISOString()}`; diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts index 8732f0ba5b012..e3ef420055196 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/add_cis_integration_form_page.ts @@ -285,6 +285,11 @@ export function AddCisIntegrationFormPageProvider({ ); await agentOption.click(); }; + + const showSetupTechnologyComponent = async () => { + return await testSubjects.exists(SETUP_TECHNOLOGY_SELECTOR_ACCORDION_TEST_SUBJ); + }; + const selectAwsCredentials = async (credentialType: 'direct' | 'temporary') => { await clickOptionButton(AWS_CREDENTIAL_SELECTOR); await selectValue( @@ -544,5 +549,7 @@ export function AddCisIntegrationFormPageProvider({ getFirstCspmIntegrationPageAgent, getAgentBasedPolicyValue, showSuccessfulToast, + showSetupTechnologyComponent, + navigateToEditIntegrationPage, }; } diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts index ebe7a91019094..fddf71eaf98a1 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -166,7 +166,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .expect(200); - await supertest.get(`/api/fleet/agent_policies/${agentPolicy.id}`).expect(404); + await supertest.get(`/api/fleet/agent_policies/${agentPolicy.id}`).expect(200); }); }); describe('Delete bulk', () => { diff --git a/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless.ts b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless.ts index 948a418279ac9..692ae096265fb 100644 --- a/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless.ts +++ b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless.ts @@ -6,8 +6,10 @@ */ import { CLOUD_CREDENTIALS_PACKAGE_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { createTestConfig } from '../../config.base'; +// TODO: Remove the agentless default config once Serverless API is merged and default policy is deleted export default createTestConfig({ serverlessProject: 'security', junit: { @@ -16,13 +18,23 @@ export default createTestConfig({ kbnServerArgs: [ `--xpack.fleet.packages.0.name=cloud_security_posture`, `--xpack.fleet.packages.0.version=${CLOUD_CREDENTIALS_PACKAGE_VERSION}`, + `--xpack.fleet.agentless.enabled=true`, + `--xpack.fleet.agents.fleet_server.hosts=["https://ftr.kibana:8220"]`, + `--xpack.fleet.internal.fleetServerStandalone=true`, - // Agentless Configuration based on Serverless Security Dev Yaml - config/serverless.security.dev.yml - `--xpack.fleet.enableExperimental.0=agentless`, + // Agentless Configuration based on Serverless Default policy`, `--xpack.fleet.agentPolicies.0.id=agentless`, `--xpack.fleet.agentPolicies.0.name=agentless`, `--xpack.fleet.agentPolicies.0.package_policies=[]`, `--xpack.cloud.serverless.project_id=some_fake_project_id`, + `--xpack.fleet.agentPolicies.0.is_default=true`, + `--xpack.fleet.agentPolicies.0.is_default_fleet_server=true`, + + // Serverless Agentless API + `--xpack.fleet.agentless.api.url=http://localhost:8089`, + `--xpack.fleet.agentless.api.tls.certificate=${KBN_CERT_PATH}`, + `--xpack.fleet.agentless.api.tls.key=${KBN_KEY_PATH}`, + `--xpack.fleet.agentless.api.tls.ca=${CA_CERT_PATH}`, ], // load tests in the index file testFiles: [require.resolve('./ftr/cloud_security_posture/agentless')], diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts index 6adbbac3cdc57..90991304936ea 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_aws.ts @@ -6,9 +6,11 @@ */ import { CLOUD_CREDENTIALS_PACKAGE_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; import expect from '@kbn/expect'; - +import * as http from 'http'; import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { setupMockServer } from '../agentless_api/mock_agentless_api'; export default function ({ getPageObjects, getService }: FtrProviderContext) { + const mockAgentlessApiService = setupMockServer(); const pageObjects = getPageObjects([ 'settings', 'common', @@ -24,9 +26,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { let cisIntegration: typeof pageObjects.cisAddIntegration; let cisIntegrationAws: typeof pageObjects.cisAddIntegration.cisAws; let testSubjectIds: typeof pageObjects.cisAddIntegration.testSubjectIds; + let mockApiServer: http.Server; const previousPackageVersion = '1.9.0'; before(async () => { + mockApiServer = mockAgentlessApiService.listen(8089); await pageObjects.svlCommonPage.loginAsAdmin(); cisIntegration = pageObjects.cisAddIntegration; cisIntegrationAws = pageObjects.cisAddIntegration.cisAws; @@ -41,6 +45,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .send({ force: true }) .expect(200); + mockApiServer.close(); }); describe('Serverless - Agentless CIS_AWS Single Account Launch Cloud formation', () => { @@ -110,7 +115,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('Serverless - Agentless CIS_AWS edit flow', () => { + // TODO: Migrate test after Serverless default agentless policy is deleted. + describe.skip('Serverless - Agentless CIS_AWS edit flow', () => { it(`user should save and edit agentless integration policy`, async () => { const newDirectAccessKeyId = `newDirectAccessKey`; @@ -142,7 +148,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ).to.be('true'); }); }); - // FLAKY: https://github.com/elastic/kibana/issues/191017 describe.skip('Serverless - Agentless CIS_AWS Create flow', () => { it(`user should save agentless integration policy when there are no api or validation errors and button is not disabled`, async () => { diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_gcp.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_gcp.ts index cd9e5b2168d1a..85a45f67bf9cc 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_gcp.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless/cis_integration_gcp.ts @@ -7,7 +7,9 @@ import expect from '@kbn/expect'; import { CLOUD_CREDENTIALS_PACKAGE_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; +import * as http from 'http'; import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { setupMockServer } from '../agentless_api/mock_agentless_api'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['common', 'svlCommonPage', 'cisAddIntegration', 'header']); @@ -21,7 +23,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { let cisIntegrationGcp: typeof pageObjects.cisAddIntegration.cisGcp; let testSubjectIds: typeof pageObjects.cisAddIntegration.testSubjectIds; + const mockAgentlessApiService = setupMockServer(); + let mockApiServer: http.Server; + before(async () => { + mockApiServer = mockAgentlessApiService.listen(8089); await pageObjects.svlCommonPage.loginAsAdmin(); cisIntegration = pageObjects.cisAddIntegration; cisIntegrationGcp = pageObjects.cisAddIntegration.cisGcp; @@ -36,6 +42,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .send({ force: true }) .expect(200); + mockApiServer.close(); }); describe('Agentless CIS_GCP Single Account Launch Cloud shell', () => { @@ -93,7 +100,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('Serverless - Agentless CIS_GCP edit flow', () => { + describe.skip('Serverless - Agentless CIS_GCP edit flow', () => { it(`user should save and edit agentless integration policy`, async () => { const newCredentialsJSON = 'newJson'; await cisIntegration.createAgentlessIntegration({ From 748a0a3c34f4655c59eca5cd75704ab7015e10f2 Mon Sep 17 00:00:00 2001 From: Ido Cohen <90558359+CohenIdo@users.noreply.github.com> Date: Tue, 1 Oct 2024 21:46:39 +0300 Subject: [PATCH 03/11] [Cloud Security] Ignore Unavailable Index --- .../configurations/latest_findings/use_grouped_findings.tsx | 1 + .../pages/configurations/latest_findings/use_latest_findings.ts | 2 +- .../pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx | 1 + .../pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx index 75234c0495f51..6d901a76a29c3 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx @@ -70,6 +70,7 @@ export interface FindingsGroupingAggregation { export const getGroupedFindingsQuery = (query: GroupingQuery) => ({ ...query, index: CDR_MISCONFIGURATIONS_INDEX_PATTERN, + ignore_unavailable: true, size: 0, }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index f8cd0238ef0bb..f6f27e15ee7a4 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -51,7 +51,7 @@ export const getFindingsQuery = ( sort: getMultiFieldsSort(sort), size: MAX_FINDINGS_TO_LOAD, aggs: getFindingsCountAggQuery(), - ignore_unavailable: false, + ignore_unavailable: true, query: { ...query, bool: { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx index 580926340438f..9fddf97e28482 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx @@ -57,6 +57,7 @@ export type VulnerabilitiesRootGroupingAggregation = export const getGroupedVulnerabilitiesQuery = (query: GroupingQuery) => ({ ...query, index: CDR_VULNERABILITIES_INDEX_PATTERN, + ignore_unavailable: true, size: 0, }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx index 3e5bd646e7993..0d0ea9ba5a22f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx @@ -57,6 +57,7 @@ export const getVulnerabilitiesQuery = ( pageParam: number ) => ({ index: CDR_VULNERABILITIES_INDEX_PATTERN, + ignore_unavailable: true, sort: getMultiFieldsSort(sort), size: MAX_FINDINGS_TO_LOAD, query: { From 5da67c71aeb4d7f1d989d57515c86ee8bc3e4652 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 2 Oct 2024 04:52:20 +1000 Subject: [PATCH 04/11] skip failing test suite (#194043) --- .../context_awareness/extensions/_get_row_indicator_provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/functional/apps/discover/context_awareness/extensions/_get_row_indicator_provider.ts b/test/functional/apps/discover/context_awareness/extensions/_get_row_indicator_provider.ts index f3f4cd40145e3..9f46b86daf3c4 100644 --- a/test/functional/apps/discover/context_awareness/extensions/_get_row_indicator_provider.ts +++ b/test/functional/apps/discover/context_awareness/extensions/_get_row_indicator_provider.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const dataViews = getService('dataViews'); + // Failing: See https://github.com/elastic/kibana/issues/194043 // Failing: See https://github.com/elastic/kibana/issues/194043 describe.skip('extension getRowIndicatorProvider', () => { before(async () => { From 3f901562cffd9b8d7ff4f4e872c4e7ccf8575719 Mon Sep 17 00:00:00 2001 From: Tim Sullivan <tsullivan@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:22:29 -0700 Subject: [PATCH 05/11] [Spaces and Roles] Updates for finalized contents and UX (#193923) ## Summary Follows https://github.com/elastic/kibana/pull/191795 * Minor content updates to Spaces Management * [spaces grid] More space for "description" column in Spaces Grid * [create space and edit space] Add "New" badge to Solution View picker * [create space and edit space] Move avatar section down * [create space] Remove the edit/update functionality from the Create Space page * [create space] Only show the Feature Visibility section if the selected solution is `classic` * [edit space] Rearrange the footer icons in the General tab * [edit space] Show callout when classic is selected by default * [edit space] Update the action icons shown on hover on the Assigned Roles table ### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [X] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [X] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../kibana_privilege_table/feature_table.tsx | 6 +- .../customize_space_avatar.test.tsx.snap | 0 .../customize_avatar/customize_avatar.tsx | 106 +++++++ .../customize_space_avatar.test.tsx | 0 .../customize_space_avatar.tsx | 0 .../components/customize_avatar/index.ts | 8 + .../customize_space.test.tsx.snap | 53 +--- .../customize_space/customize_space.tsx | 65 +--- .../enabled_features.test.tsx.snap | 17 +- .../enabled_features/enabled_features.tsx | 37 +-- .../solution_view/solution_view.tsx | 81 +++-- .../create_space/create_space_page.test.tsx | 280 +----------------- .../create_space/create_space_page.tsx | 133 ++------- .../management/edit_space/edit_space.tsx | 4 +- .../edit_space/edit_space_features_tab.tsx | 77 ----- .../edit_space/edit_space_general_tab.tsx | 27 +- .../public/management/edit_space/footer.tsx | 96 +++--- .../space_assign_role_privilege_form.tsx | 9 +- .../component/space_assigned_roles_table.tsx | 31 +- .../public/management/lib/validate_space.ts | 2 +- .../spaces_grid/spaces_grid_page.test.tsx | 4 +- .../spaces_grid/spaces_grid_page.tsx | 74 +++-- .../translations/translations/fr-FR.json | 7 - .../translations/translations/ja-JP.json | 7 - .../translations/translations/zh-CN.json | 7 - 25 files changed, 372 insertions(+), 759 deletions(-) rename x-pack/plugins/spaces/public/management/components/{customize_space => customize_avatar}/__snapshots__/customize_space_avatar.test.tsx.snap (100%) create mode 100644 x-pack/plugins/spaces/public/management/components/customize_avatar/customize_avatar.tsx rename x-pack/plugins/spaces/public/management/components/{customize_space => customize_avatar}/customize_space_avatar.test.tsx (100%) rename x-pack/plugins/spaces/public/management/components/{customize_space => customize_avatar}/customize_space_avatar.tsx (100%) create mode 100644 x-pack/plugins/spaces/public/management/components/customize_avatar/index.ts delete mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx index 45b263b66f2fb..6fef00ccecec9 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx @@ -160,9 +160,7 @@ export class FeatureTable extends Component<Props, State> { <EuiSpacer size="s" /> {helpText && ( <> - <EuiCallOut iconType="iInCircle" size="s"> - {helpText} - </EuiCallOut> + <EuiCallOut size="s" title={helpText} /> <EuiSpacer size="s" /> </> )} @@ -404,7 +402,7 @@ export class FeatureTable extends Component<Props, State> { 'xpack.security.management.editRole.featureTable.managementCategoryHelpText', { defaultMessage: - 'Access to Stack Management is determined by both Elasticsearch and Kibana privileges, and cannot be explicitly disabled.', + 'Additional Stack Management permissions can be found outside of this menu, in index and cluster privileges.', } ); } diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/customize_avatar/__snapshots__/customize_space_avatar.test.tsx.snap similarity index 100% rename from x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap rename to x-pack/plugins/spaces/public/management/components/customize_avatar/__snapshots__/customize_space_avatar.test.tsx.snap diff --git a/x-pack/plugins/spaces/public/management/components/customize_avatar/customize_avatar.tsx b/x-pack/plugins/spaces/public/management/components/customize_avatar/customize_avatar.tsx new file mode 100644 index 0000000000000..37daa938663e0 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/components/customize_avatar/customize_avatar.tsx @@ -0,0 +1,106 @@ +/* + * 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 { EuiDescribedFormGroup, EuiLoadingSpinner, EuiTitle } from '@elastic/eui'; +import React, { Component, lazy, Suspense } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { CustomizeSpaceAvatar } from './customize_space_avatar'; +import { getSpaceAvatarComponent } from '../../../space_avatar'; +import type { SpaceValidator } from '../../lib'; +import type { CustomizeSpaceFormValues } from '../../types'; +import { SectionPanel } from '../section_panel'; + +// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. +const LazySpaceAvatar = lazy(() => + getSpaceAvatarComponent().then((component) => ({ default: component })) +); + +interface Props { + validator: SpaceValidator; + space: CustomizeSpaceFormValues; + onChange: (space: CustomizeSpaceFormValues) => void; + title?: string; +} + +interface State { + customizingAvatar: boolean; + usingCustomIdentifier: boolean; +} + +export class CustomizeAvatar extends Component<Props, State> { + public state = { + customizingAvatar: false, + usingCustomIdentifier: false, + }; + + public render() { + const { validator, space } = this.props; + + return ( + <SectionPanel dataTestSubj="customizeAvatarSection"> + <EuiDescribedFormGroup + title={ + <EuiTitle size="xs"> + <h3> + <FormattedMessage + id="xpack.spaces.management.manageSpacePage.avatarTitle" + defaultMessage="Define an avatar" + /> + </h3> + </EuiTitle> + } + description={ + <> + <p> + {i18n.translate('xpack.spaces.management.manageSpacePage.avatarDescription', { + defaultMessage: 'Choose how your space avatar appears across Kibana.', + })} + </p> + {space.avatarType === 'image' ? ( + <Suspense fallback={<EuiLoadingSpinner />}> + <LazySpaceAvatar + space={{ + ...space, + initials: '?', + name: undefined, + }} + size="xl" + /> + </Suspense> + ) : ( + <Suspense fallback={<EuiLoadingSpinner />}> + <LazySpaceAvatar + space={{ + name: '?', + ...space, + imageUrl: undefined, + }} + size="xl" + /> + </Suspense> + )} + </> + } + fullWidth + > + <CustomizeSpaceAvatar + space={this.props.space} + onChange={this.onAvatarChange} + validator={validator} + /> + </EuiDescribedFormGroup> + </SectionPanel> + ); + } + + public onAvatarChange = (space: CustomizeSpaceFormValues) => { + this.props.onChange(space); + }; +} diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.test.tsx b/x-pack/plugins/spaces/public/management/components/customize_avatar/customize_space_avatar.test.tsx similarity index 100% rename from x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.test.tsx rename to x-pack/plugins/spaces/public/management/components/customize_avatar/customize_space_avatar.test.tsx diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/management/components/customize_avatar/customize_space_avatar.tsx similarity index 100% rename from x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx rename to x-pack/plugins/spaces/public/management/components/customize_avatar/customize_space_avatar.tsx diff --git a/x-pack/plugins/spaces/public/management/components/customize_avatar/index.ts b/x-pack/plugins/spaces/public/management/components/customize_avatar/index.ts new file mode 100644 index 0000000000000..60d3168efc245 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/components/customize_avatar/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CustomizeAvatar } from './customize_avatar'; diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap index d7527e300eece..fe9692a971d3f 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap @@ -38,7 +38,7 @@ exports[`renders correctly 1`] = ` <EuiFormRow data-test-subj="optionalDescription" fullWidth={true} - helpText="The description appears on the space selection screen." + helpText="Appears on the space selection screen and spaces list." isInvalid={false} label="Description" labelAppend={ @@ -89,56 +89,5 @@ exports[`renders correctly 1`] = ` /> </EuiFormRow> </EuiDescribedFormGroup> - <EuiDescribedFormGroup - description={ - <React.Fragment> - <p> - Choose how your space avatar appears across Kibana. - </p> - <React.Suspense - fallback={<EuiLoadingSpinner />} - > - <UNDEFINED - size="xl" - space={ - Object { - "id": "", - "imageUrl": undefined, - "name": "", - } - } - /> - </React.Suspense> - </React.Fragment> - } - fullWidth={true} - title={ - <EuiTitle - size="xs" - > - <h3> - <Memo(MemoizedFormattedMessage) - defaultMessage="Create an avatar" - id="xpack.spaces.management.manageSpacePage.avatarTitle" - /> - </h3> - </EuiTitle> - } - > - <CustomizeSpaceAvatar - onChange={[Function]} - space={ - Object { - "id": "", - "name": "", - } - } - validator={ - SpaceValidator { - "shouldValidate": true, - } - } - /> - </EuiDescribedFormGroup> </SectionPanel> `; diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space.tsx b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space.tsx index f67e40df53ec3..a5761c34c97b0 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space.tsx +++ b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space.tsx @@ -9,29 +9,22 @@ import { EuiDescribedFormGroup, EuiFieldText, EuiFormRow, - EuiLoadingSpinner, EuiText, EuiTextArea, EuiTitle, } from '@elastic/eui'; import type { ChangeEvent } from 'react'; -import React, { Component, lazy, Suspense } from 'react'; +import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { CustomizeSpaceAvatar } from './customize_space_avatar'; -import { getSpaceAvatarComponent, getSpaceColor, getSpaceInitials } from '../../../space_avatar'; +import { getSpaceColor, getSpaceInitials } from '../../../space_avatar'; import type { SpaceValidator } from '../../lib'; import { toSpaceIdentifier } from '../../lib'; import type { CustomizeSpaceFormValues } from '../../types'; import { SectionPanel } from '../section_panel'; -// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. -const LazySpaceAvatar = lazy(() => - getSpaceAvatarComponent().then((component) => ({ default: component })) -); - interface Props { validator: SpaceValidator; space: CustomizeSpaceFormValues; @@ -112,7 +105,7 @@ export class CustomizeSpace extends Component<Props, State> { helpText={i18n.translate( 'xpack.spaces.management.manageSpacePage.spaceDescriptionHelpText', { - defaultMessage: 'The description appears on the space selection screen.', + defaultMessage: 'Appears on the space selection screen and spaces list.', } )} {...validator.validateSpaceDescription(this.props.space)} @@ -156,58 +149,6 @@ export class CustomizeSpace extends Component<Props, State> { </EuiFormRow> )} </EuiDescribedFormGroup> - - <EuiDescribedFormGroup - title={ - <EuiTitle size="xs"> - <h3> - <FormattedMessage - id="xpack.spaces.management.manageSpacePage.avatarTitle" - defaultMessage="Create an avatar" - /> - </h3> - </EuiTitle> - } - description={ - <> - <p> - {i18n.translate('xpack.spaces.management.manageSpacePage.avatarDescription', { - defaultMessage: 'Choose how your space avatar appears across Kibana.', - })} - </p> - {space.avatarType === 'image' ? ( - <Suspense fallback={<EuiLoadingSpinner />}> - <LazySpaceAvatar - space={{ - ...space, - initials: '?', - name: undefined, - }} - size="xl" - /> - </Suspense> - ) : ( - <Suspense fallback={<EuiLoadingSpinner />}> - <LazySpaceAvatar - space={{ - name: '?', - ...space, - imageUrl: undefined, - }} - size="xl" - /> - </Suspense> - )} - </> - } - fullWidth - > - <CustomizeSpaceAvatar - space={this.props.space} - onChange={this.onAvatarChange} - validator={validator} - /> - </EuiDescribedFormGroup> </SectionPanel> ); } diff --git a/x-pack/plugins/spaces/public/management/components/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/enabled_features/__snapshots__/enabled_features.test.tsx.snap index fd56cf65620f1..9babe0f169992 100644 --- a/x-pack/plugins/spaces/public/management/components/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/components/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -2,8 +2,7 @@ exports[`EnabledFeatures renders as expected 1`] = ` <SectionPanel - data-test-subj="enabled-features-panel" - title="Features" + dataTestSubj="enabled-features-panel" > <EuiFlexGroup> <EuiFlexItem> @@ -26,14 +25,16 @@ exports[`EnabledFeatures renders as expected 1`] = ` > <p> <MemoizedFormattedMessage - defaultMessage="Hidden features are removed from the user interface, but not disabled. To secure access to features, {manageRolesLink}." - id="xpack.spaces.management.enabledSpaceFeatures.notASecurityMechanismMessage" + defaultMessage="Choose the features to display in the navigation menu for users of this space. If you want to focus on a single solution, you can simplify the navigation even more by selecting a {solutionView}." + id="xpack.spaces.management.enabledSpaceFeatures.chooseFeaturesToDisplayMessage" values={ Object { - "manageRolesLink": <Memo(MemoizedFormattedMessage) - defaultMessage="manage security roles" - id="xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText" - />, + "solutionView": <strong> + <Memo(MemoizedFormattedMessage) + defaultMessage="Solution view" + id="xpack.spaces.management.enabledSpaceFeatures.chooseFeaturesToDisplaySolutionViewText" + /> + </strong>, } } /> diff --git a/x-pack/plugins/spaces/public/management/components/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/components/enabled_features/enabled_features.tsx index 36d0694953242..377f4c51ff0c5 100644 --- a/x-pack/plugins/spaces/public/management/components/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/components/enabled_features/enabled_features.tsx @@ -5,14 +5,12 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; import type { KibanaFeatureConfig } from '@kbn/features-plugin/public'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { FeatureTable } from './feature_table'; import type { Space } from '../../../../common'; @@ -25,16 +23,8 @@ interface Props { } export const EnabledFeatures: FunctionComponent<Props> = (props) => { - const { services } = useKibana(); - const canManageRoles = services.application?.capabilities.management?.security?.roles === true; - return ( - <SectionPanel - title={i18n.translate('xpack.spaces.management.manageSpacePage.featuresTitle', { - defaultMessage: 'Features', - })} - data-test-subj="enabled-features-panel" - > + <SectionPanel dataTestSubj="enabled-features-panel"> <EuiFlexGroup> <EuiFlexItem> <EuiTitle size="xs"> @@ -49,25 +39,16 @@ export const EnabledFeatures: FunctionComponent<Props> = (props) => { <EuiText size="s" color="subdued"> <p> <FormattedMessage - id="xpack.spaces.management.enabledSpaceFeatures.notASecurityMechanismMessage" - defaultMessage="Hidden features are removed from the user interface, but not disabled. To secure access to features, {manageRolesLink}." + id="xpack.spaces.management.enabledSpaceFeatures.chooseFeaturesToDisplayMessage" + defaultMessage="Choose the features to display in the navigation menu for users of this space. If you want to focus on a single solution, you can simplify the navigation even more by selecting a {solutionView}." values={{ - manageRolesLink: canManageRoles ? ( - <EuiLink - href={services.application?.getUrlForApp('management', { - path: '/security/roles', - })} - > + solutionView: ( + <strong> <FormattedMessage - id="xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText" - defaultMessage="manage security roles" + id="xpack.spaces.management.enabledSpaceFeatures.chooseFeaturesToDisplaySolutionViewText" + defaultMessage="Solution view" /> - </EuiLink> - ) : ( - <FormattedMessage - id="xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText" - defaultMessage="manage security roles" - /> + </strong> ), }} /> diff --git a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx index c336791991df4..4bf5e437f7350 100644 --- a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx +++ b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx @@ -7,6 +7,8 @@ import type { EuiSuperSelectOption, EuiThemeComputed } from '@elastic/eui'; import { + EuiBetaBadge, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -24,6 +26,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space } from '../../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../../common/constants'; import type { SpaceValidator } from '../../lib'; import { SectionPanel } from '../section_panel'; @@ -40,9 +43,7 @@ const getOptions = ({ size }: EuiThemeComputed): Array<EuiSuperSelectOption<Solu <EuiIcon type="logoElasticsearch" css={iconCss} /> {i18n.translate( 'xpack.spaces.management.manageSpacePage.solutionViewSelect.searchOptionLabel', - { - defaultMessage: 'Search', - } + { defaultMessage: 'Search' } )} </> ), @@ -55,9 +56,7 @@ const getOptions = ({ size }: EuiThemeComputed): Array<EuiSuperSelectOption<Solu <EuiIcon type="logoObservability" css={iconCss} /> {i18n.translate( 'xpack.spaces.management.manageSpacePage.solutionViewSelect.obltOptionLabel', - { - defaultMessage: 'Observability', - } + { defaultMessage: 'Observability' } )} </> ), @@ -70,9 +69,7 @@ const getOptions = ({ size }: EuiThemeComputed): Array<EuiSuperSelectOption<Solu <EuiIcon type="logoSecurity" css={iconCss} /> {i18n.translate( 'xpack.spaces.management.manageSpacePage.solutionViewSelect.securityOptionLabel', - { - defaultMessage: 'Security', - } + { defaultMessage: 'Security' } )} </> ), @@ -85,9 +82,7 @@ const getOptions = ({ size }: EuiThemeComputed): Array<EuiSuperSelectOption<Solu <EuiIcon type="logoKibana" css={iconCss} /> {i18n.translate( 'xpack.spaces.management.manageSpacePage.solutionViewSelect.classicOptionLabel', - { - defaultMessage: 'Classic', - } + { defaultMessage: 'Classic' } )} </> ), @@ -112,25 +107,40 @@ export const SolutionView: FunctionComponent<Props> = ({ sectionTitle, }) => { const { euiTheme } = useEuiTheme(); + const showClassicDefaultViewCallout = isEditing && space.solution == null; return ( <SectionPanel title={sectionTitle} dataTestSubj="navigationPanel"> - <EuiFlexGroup> + <EuiFlexGroup alignItems="flexStart"> <EuiFlexItem> <EuiTitle size="xs"> - <h3> - <FormattedMessage - id="xpack.spaces.management.manageSpacePage.setSolutionViewMessage" - defaultMessage="Set solution view" - /> - </h3> + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <h3> + <FormattedMessage + id="xpack.spaces.management.manageSpacePage.setSolutionViewMessage" + defaultMessage="Select solution view" + /> + </h3> + </EuiFlexItem> + <EuiFlexItem> + <EuiBetaBadge + label={i18n.translate( + 'xpack.spaces.management.manageSpacePage.setSolutionViewNewBadge', + { defaultMessage: 'New' } + )} + color="accent" + size="s" + /> + </EuiFlexItem> + </EuiFlexGroup> </EuiTitle> <EuiSpacer size="s" /> <EuiText size="s" color="subdued"> <p> <FormattedMessage id="xpack.spaces.management.manageSpacePage.setSolutionViewDescription" - defaultMessage="Determines the navigation all users will see for this space. Each solution view contains features from Analytics tools and Management." + defaultMessage="Focus the navigation and menus of this space on a specific solution. Features that are not relevant to the selected solution are no longer visible to users of this space." /> </p> </EuiText> @@ -145,20 +155,43 @@ export const SolutionView: FunctionComponent<Props> = ({ > <EuiSuperSelect options={getOptions(euiTheme)} - valueOfSelected={space.solution} + valueOfSelected={ + space.solution ?? + (showClassicDefaultViewCallout ? SOLUTION_VIEW_CLASSIC : undefined) + } data-test-subj="solutionViewSelect" onChange={(solution) => { onChange({ ...space, solution }); }} placeholder={i18n.translate( 'xpack.spaces.management.navigation.solutionViewDefaultValue', - { - defaultMessage: 'Select view', - } + { defaultMessage: 'Select solution view' } )} isInvalid={validator.validateSolutionView(space, isEditing).isInvalid} /> </EuiFormRow> + + {showClassicDefaultViewCallout && ( + <> + <EuiText size="s" color="subdued"> + <FormattedMessage + id="xpack.spaces.management.manageSpacePage.solutionViewSelect.classicDefaultViewCallout" + defaultMessage="Affects all users of the space" + /> + </EuiText> + + <EuiSpacer /> + <EuiCallOut + color="primary" + size="s" + iconType="iInCircle" + title={i18n.translate( + 'xpack.spaces.management.manageSpacePage.solutionViewSelect.classicDefaultViewCallout', + { defaultMessage: 'By default your current view is Classic' } + )} + /> + </> + )} </EuiFlexItem> </EuiFlexGroup> </SectionPanel> diff --git a/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx index 4c8617ff007b8..14413b0b2f47b 100644 --- a/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx @@ -6,7 +6,6 @@ */ import type { EuiCheckboxProps } from '@elastic/eui'; -import { EuiButton } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; import type { ReactWrapper } from 'enzyme'; import React from 'react'; @@ -23,7 +22,6 @@ import type { SolutionView, Space } from '../../../common/types/latest'; import { EventTracker } from '../../analytics'; import type { SpacesManager } from '../../spaces_manager'; import { spacesManagerMock } from '../../spaces_manager/mocks'; -import { ConfirmAlterActiveSpaceModal } from '../components/confirm_alter_active_space_modal'; import { EnabledFeatures } from '../components/enabled_features'; // To be resolved by EUI team. @@ -153,8 +151,8 @@ describe('ManageSpacePage', () => { expect(errors).toEqual([ 'Enter a name.', 'Enter a URL identifier.', + 'Select a solution.', 'Enter initials.', - 'Select one solution.', ]); expect(spacesManager.createSpace).not.toHaveBeenCalled(); @@ -168,7 +166,7 @@ describe('ManageSpacePage', () => { { const errors = wrapper.find('div.euiFormErrorText').map((node) => node.text()); - expect(errors).toEqual(['Select one solution.']); // requires solution view to be set + expect(errors).toEqual(['Select a solution.']); // requires solution view to be set } updateSpace(wrapper, false, 'oblt'); @@ -274,7 +272,13 @@ describe('ManageSpacePage', () => { expect(wrapper.find('input[name="name"]')).toHaveLength(1); }); - expect(wrapper.find(EnabledFeatures)).toHaveLength(1); + // expect visible features table to exist after setting the Solution View to Classic + await waitFor(() => { + // switch to classic + updateSpace(wrapper, false, 'classic'); + // expect visible features table to exist again + expect(wrapper.find(EnabledFeatures)).toHaveLength(1); + }); }); it('hides feature visibility controls when not allowed', async () => { @@ -333,9 +337,6 @@ describe('ManageSpacePage', () => { await Promise.resolve(); wrapper.update(); - - // default for create space: expect visible features table to exist - expect(wrapper.find(EnabledFeatures)).toHaveLength(1); }); await waitFor(() => { @@ -353,147 +354,6 @@ describe('ManageSpacePage', () => { }); }); - it('allows a space to be updated', async () => { - const spaceToUpdate = { - id: 'existing-space', - name: 'Existing Space', - description: 'hey an existing space', - color: '#aabbcc', - initials: 'AB', - disabledFeatures: [], - solution: 'es', - }; - - const spacesManager = spacesManagerMock.create(); - spacesManager.getSpace = jest.fn().mockResolvedValue({ - ...spaceToUpdate, - }); - spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - - const onLoadSpace = jest.fn(); - - const wrapper = mountWithIntl( - <CreateSpacePage - spaceId={'existing-space'} - spacesManager={spacesManager as unknown as SpacesManager} - onLoadSpace={onLoadSpace} - getFeatures={featuresStart.getFeatures} - notifications={notificationServiceMock.createStartContract()} - history={history} - capabilities={{ - navLinks: {}, - management: {}, - catalogue: {}, - spaces: { manage: true }, - }} - eventTracker={eventTracker} - allowFeatureVisibility - allowSolutionVisibility - /> - ); - - await waitFor(() => { - wrapper.update(); - expect(spacesManager.getSpace).toHaveBeenCalledWith('existing-space'); - }); - - expect(onLoadSpace).toHaveBeenCalledWith({ - ...spaceToUpdate, - }); - - await Promise.resolve(); - - wrapper.update(); - - updateSpace(wrapper, true, 'oblt'); - - await clickSaveButton(wrapper); - - expect(spacesManager.updateSpace).toHaveBeenCalledWith({ - id: 'existing-space', - name: 'New Space Name', - description: 'some description', - color: '#AABBCC', - initials: 'AB', - imageUrl: '', - disabledFeatures: ['feature-1'], - solution: 'oblt', // solution has been changed - }); - - expect(reportEvent).toHaveBeenCalledWith('space_solution_changed', { - action: 'edit', - solution: 'oblt', - solution_prev: 'es', - space_id: 'existing-space', - }); - }); - - it('sets calculated fields for existing spaces', async () => { - // The Spaces plugin provides functions to calculate the initials and color of a space if they have not been customized. The new space - // management page explicitly sets these fields when a new space is created, but it should also handle existing "legacy" spaces that do - // not already have these fields set. - const spaceToUpdate = { - id: 'existing-space', - name: 'Existing Space', - description: 'hey an existing space', - color: undefined, - initials: undefined, - imageUrl: undefined, - disabledFeatures: [], - }; - - const spacesManager = spacesManagerMock.create(); - spacesManager.getSpace = jest.fn().mockResolvedValue({ - ...spaceToUpdate, - }); - spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - - const onLoadSpace = jest.fn(); - - const wrapper = mountWithIntl( - <CreateSpacePage - spaceId={'existing-space'} - spacesManager={spacesManager as unknown as SpacesManager} - onLoadSpace={onLoadSpace} - getFeatures={featuresStart.getFeatures} - notifications={notificationServiceMock.createStartContract()} - history={history} - capabilities={{ - navLinks: {}, - management: {}, - catalogue: {}, - spaces: { manage: true }, - }} - eventTracker={eventTracker} - allowFeatureVisibility - allowSolutionVisibility - /> - ); - - await waitFor(() => { - wrapper.update(); - expect(spacesManager.getSpace).toHaveBeenCalledWith('existing-space'); - }); - - expect(onLoadSpace).toHaveBeenCalledWith({ - ...spaceToUpdate, - }); - - await Promise.resolve(); - - wrapper.update(); - - // not changing anything, just clicking the "Update space" button - await clickSaveButton(wrapper); - - expect(spacesManager.updateSpace).toHaveBeenCalledWith({ - ...spaceToUpdate, - color: '#E7664C', - initials: 'ES', - imageUrl: '', - }); - }); - it('notifies when there is an error retrieving features', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.createSpace = jest.fn(spacesManager.createSpace); @@ -528,119 +388,6 @@ describe('ManageSpacePage', () => { }); }); }); - - it('warns when updating features in the active space', async () => { - const spacesManager = spacesManagerMock.create(); - spacesManager.getSpace = jest.fn().mockResolvedValue({ - id: 'my-space', - name: 'Existing Space', - description: 'hey an existing space', - color: '#aabbcc', - initials: 'AB', - disabledFeatures: [], - }); - spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - - const wrapper = mountWithIntl( - <CreateSpacePage - spaceId={'my-space'} - spacesManager={spacesManager as unknown as SpacesManager} - getFeatures={featuresStart.getFeatures} - notifications={notificationServiceMock.createStartContract()} - history={history} - capabilities={{ - navLinks: {}, - management: {}, - catalogue: {}, - spaces: { manage: true }, - }} - eventTracker={eventTracker} - allowFeatureVisibility - allowSolutionVisibility - /> - ); - - await waitFor(() => { - wrapper.update(); - expect(spacesManager.getSpace).toHaveBeenCalledWith('my-space'); - }); - - await Promise.resolve(); - - wrapper.update(); - - updateSpace(wrapper); - - await clickSaveButton(wrapper); - - const warningDialog = wrapper.find(ConfirmAlterActiveSpaceModal); - expect(warningDialog).toHaveLength(1); - - expect(spacesManager.updateSpace).toHaveBeenCalledTimes(0); - - const confirmButton = warningDialog - .find(EuiButton) - .find('[data-test-subj="confirmModalConfirmButton"]') - .find('button'); - - confirmButton.simulate('click'); - - await Promise.resolve(); - - wrapper.update(); - - expect(spacesManager.updateSpace).toHaveBeenCalledTimes(1); - }); - - it('does not warn when features are left alone in the active space', async () => { - const spacesManager = spacesManagerMock.create(); - spacesManager.getSpace = jest.fn().mockResolvedValue({ - id: 'my-space', - name: 'Existing Space', - description: 'hey an existing space', - color: '#aabbcc', - initials: 'AB', - disabledFeatures: [], - }); - spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - - const wrapper = mountWithIntl( - <CreateSpacePage - spaceId={'my-space'} - spacesManager={spacesManager as unknown as SpacesManager} - getFeatures={featuresStart.getFeatures} - notifications={notificationServiceMock.createStartContract()} - history={history} - capabilities={{ - navLinks: {}, - management: {}, - catalogue: {}, - spaces: { manage: true }, - }} - eventTracker={eventTracker} - allowFeatureVisibility - allowSolutionVisibility - /> - ); - - await waitFor(() => { - wrapper.update(); - expect(spacesManager.getSpace).toHaveBeenCalledWith('my-space'); - }); - - await Promise.resolve(); - - wrapper.update(); - - updateSpace(wrapper, false); - - await clickSaveButton(wrapper); - - const warningDialog = wrapper.find(ConfirmAlterActiveSpaceModal); - expect(warningDialog).toHaveLength(0); - - expect(spacesManager.updateSpace).toHaveBeenCalledTimes(1); - }); }); function updateSpace( @@ -680,15 +427,6 @@ function toggleFeature(wrapper: ReactWrapper<any, any>) { wrapper.update(); } -async function clickSaveButton(wrapper: ReactWrapper<any, any>) { - const saveButton = wrapper.find('button[data-test-subj="save-space-button"]'); - saveButton.simulate('click'); - - await Promise.resolve(); - - wrapper.update(); -} - function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } diff --git a/x-pack/plugins/spaces/public/management/create_space/create_space_page.tsx b/x-pack/plugins/spaces/public/management/create_space/create_space_page.tsx index e8204a53fe345..31a5bf885e785 100644 --- a/x-pack/plugins/spaces/public/management/create_space/create_space_page.tsx +++ b/x-pack/plugins/spaces/public/management/create_space/create_space_page.tsx @@ -27,15 +27,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space } from '../../../common'; -import { isReservedSpace } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; import type { EventTracker } from '../../analytics'; import { getSpacesFeatureDescription } from '../../constants'; import { getSpaceColor, getSpaceInitials } from '../../space_avatar'; import type { SpacesManager } from '../../spaces_manager'; import { UnauthorizedPrompt } from '../components'; import { ConfirmAlterActiveSpaceModal } from '../components/confirm_alter_active_space_modal'; +import { CustomizeAvatar } from '../components/customize_avatar'; import { CustomizeSpace } from '../components/customize_space'; -import { DeleteSpacesButton } from '../components/delete_spaces_button'; import { EnabledFeatures } from '../components/enabled_features'; import { SolutionView } from '../components/solution_view'; import { toSpaceIdentifier } from '../lib'; @@ -60,7 +60,6 @@ interface State { features: KibanaFeature[]; originalSpace?: Partial<Space>; showAlteringActiveSpaceDialog: boolean; - showVisibleFeaturesPicker: boolean; haveDisabledFeaturesChanged: boolean; hasSolutionViewChanged: boolean; isLoading: boolean; @@ -80,7 +79,6 @@ export class CreateSpacePage extends Component<Props, State> { this.state = { isLoading: true, showAlteringActiveSpaceDialog: false, - showVisibleFeaturesPicker: !!props.allowFeatureVisibility, saveInProgress: false, space: { color: getSpaceColor({}), @@ -185,12 +183,9 @@ export class CreateSpacePage extends Component<Props, State> { return ( <div data-test-subj="spaces-create-page"> <CustomizeSpace - title={i18n.translate('xpack.spaces.management.manageSpacePage.generalTitle', { - defaultMessage: 'General', - })} space={this.state.space} onChange={this.onSpaceChange} - editingExistingSpace={this.editingExistingSpace()} + editingExistingSpace={false} validator={this.validator} /> @@ -201,25 +196,30 @@ export class CreateSpacePage extends Component<Props, State> { space={this.state.space} onChange={this.onSolutionViewChange} validator={this.validator} - isEditing={this.editingExistingSpace()} - sectionTitle={i18n.translate( - 'xpack.spaces.management.manageSpacePage.navigationTitle', - { defaultMessage: 'Navigation' } - )} + isEditing={false} /> </> )} - {this.state.showVisibleFeaturesPicker && ( - <> - <EuiSpacer /> - <EnabledFeatures - space={this.state.space} - features={this.state.features} - onChange={this.onSpaceChange} - /> - </> - )} + {this.props.allowFeatureVisibility && + (!this.state.space.solution || this.state.space.solution === SOLUTION_VIEW_CLASSIC) && ( + <> + <EuiSpacer /> + <EnabledFeatures + space={this.state.space} + features={this.state.features} + onChange={this.onSpaceChange} + /> + </> + )} + + <EuiSpacer /> + + <CustomizeAvatar + space={this.state.space} + onChange={this.onSpaceChange} + validator={this.validator} + /> <EuiSpacer /> @@ -240,14 +240,6 @@ export class CreateSpacePage extends Component<Props, State> { }; public getTitle = () => { - if (this.editingExistingSpace()) { - return ( - <FormattedMessage - id="xpack.spaces.management.manageSpacePage.editSpaceTitle" - defaultMessage="Edit space" - /> - ); - } return ( <FormattedMessage id="xpack.spaces.management.manageSpacePage.createSpaceTitle" @@ -257,7 +249,6 @@ export class CreateSpacePage extends Component<Props, State> { }; public getChangeImpactWarning = () => { - if (!this.editingExistingSpace()) return null; const { haveDisabledFeaturesChanged, hasSolutionViewChanged } = this.state; if (!haveDisabledFeaturesChanged && !hasSolutionViewChanged) return null; @@ -289,13 +280,6 @@ export class CreateSpacePage extends Component<Props, State> { } ); - const updateSpaceText = i18n.translate( - 'xpack.spaces.management.manageSpacePage.updateSpaceButton', - { - defaultMessage: 'Update space', - } - ); - const cancelButtonText = i18n.translate( 'xpack.spaces.management.manageSpacePage.cancelSpaceButton', { @@ -303,8 +287,6 @@ export class CreateSpacePage extends Component<Props, State> { } ); - const saveText = this.editingExistingSpace() ? updateSpaceText : createSpaceText; - return ( <EuiFlexGroup responsive={false}> <EuiFlexItem grow={false}> @@ -314,7 +296,7 @@ export class CreateSpacePage extends Component<Props, State> { data-test-subj="save-space-button" isLoading={this.state.saveInProgress} > - {saveText} + {createSpaceText} </EuiButton> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -323,37 +305,12 @@ export class CreateSpacePage extends Component<Props, State> { </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={true} /> - {this.getActionButton()} </EuiFlexGroup> ); }; - public getActionButton = () => { - if (this.state.space && this.editingExistingSpace() && !isReservedSpace(this.state.space)) { - return ( - <EuiFlexItem grow={false}> - <DeleteSpacesButton - data-test-subj="delete-space-button" - space={this.state.space as Space} - spacesManager={this.props.spacesManager} - onDelete={this.backToSpacesList} - notifications={this.props.notifications} - /> - </EuiFlexItem> - ); - } - - return null; - }; - private onSolutionViewChange = (space: Partial<Space>) => { - if (this.props.allowFeatureVisibility) { - let showVisibleFeaturesPicker = false; - if (space.solution === 'classic' || space.solution == null) { - showVisibleFeaturesPicker = true; - } - this.setState((state) => ({ ...state, showVisibleFeaturesPicker })); - } + this.setState((state) => ({ ...state, solution: space.solution })); this.onSpaceChange(space); }; @@ -366,14 +323,8 @@ export class CreateSpacePage extends Component<Props, State> { public saveSpace = () => { this.validator.enableValidation(); - const originalSpace: Space = this.state.originalSpace as Space; const space: Space = this.state.space as Space; - const { haveDisabledFeaturesChanged, hasSolutionViewChanged } = this.state; - const result = this.validator.validateForSave( - space, - this.editingExistingSpace(), - this.props.allowSolutionVisibility - ); + const result = this.validator.validateForSave(space, false, this.props.allowSolutionVisibility); if (result.isInvalid) { this.setState({ formError: result, @@ -382,24 +333,7 @@ export class CreateSpacePage extends Component<Props, State> { return; } - if (this.editingExistingSpace()) { - const { spacesManager } = this.props; - - spacesManager.getActiveSpace().then((activeSpace) => { - const editingActiveSpace = activeSpace.id === originalSpace.id; - - if (editingActiveSpace && (haveDisabledFeaturesChanged || hasSolutionViewChanged)) { - this.setState({ - showAlteringActiveSpaceDialog: true, - }); - - return; - } - this.performSave(); - }); - } else { - this.performSave(); - } + this.performSave(); }; private loadSpace = async (spaceId: string, featuresPromise: Promise<KibanaFeature[]>) => { @@ -472,15 +406,8 @@ export class CreateSpacePage extends Component<Props, State> { solution, }; - let action; - const isEditing = this.editingExistingSpace(); const { spacesManager, eventTracker } = this.props; - - if (isEditing) { - action = spacesManager.updateSpace(params); - } else { - action = spacesManager.createSpace(params); - } + const action = spacesManager.createSpace(params); this.setState({ saveInProgress: true }); @@ -493,7 +420,7 @@ export class CreateSpacePage extends Component<Props, State> { spaceId: id, solution, solutionPrev: this.state.originalSpace?.solution, - action: isEditing ? 'edit' : 'create', + action: 'create', }); }; @@ -536,6 +463,4 @@ export class CreateSpacePage extends Component<Props, State> { }; private backToSpacesList = () => this.props.history.push('/'); - - private editingExistingSpace = () => !!this.props.spaceId; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx index cd2bd76a57928..825bc6977ad1c 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx @@ -201,8 +201,8 @@ export const EditSpace: FC<PageProps> = ({ <HeaderAvatar /> </EuiFlexItem> <EuiFlexItem grow={true}> - <EuiFlexGroup direction="column"> - <EuiFlexItem grow={true} al> + <EuiFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem grow={true}> <EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexItem grow={true}> <EuiTitle size="l"> diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx deleted file mode 100644 index f5bfbe79ec2d4..0000000000000 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import type { FC } from 'react'; -import React from 'react'; - -import type { KibanaFeature } from '@kbn/features-plugin/common'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { useEditSpaceServices } from './provider'; -import type { Space } from '../../../common'; -import { FeatureTable } from '../components/enabled_features/feature_table'; -import { SectionPanel } from '../components/section_panel'; - -interface Props { - space: Partial<Space>; - features: KibanaFeature[]; - onChange: (updatedSpace: Partial<Space>) => void; -} - -export const EditSpaceEnabledFeatures: FC<Props> = ({ features, space, onChange }) => { - const { capabilities, getUrlForApp } = useEditSpaceServices(); - const canManageRoles = capabilities.roles?.save === true; - - if (!features) { - return null; - } - - return ( - <SectionPanel dataTestSubj="enabled-features-panel"> - <EuiFlexGroup> - <EuiFlexItem> - <EuiTitle size="xs"> - <h3> - <FormattedMessage - id="xpack.spaces.management.editSpaceFeatures.featuresVisibility" - defaultMessage="Set features visibility" - /> - </h3> - </EuiTitle> - <EuiSpacer size="s" /> - <EuiText size="s" color="subdued"> - <p> - <FormattedMessage - id="xpack.spaces.management.editSpaceFeatures.notASecurityMechanismMessage" - defaultMessage="Hidden features are removed from the user interface, but not disabled. To secure access to features, {manageRolesLink}." - values={{ - manageRolesLink: canManageRoles ? ( - <EuiLink href={getUrlForApp('management', { path: '/security/roles' })}> - <FormattedMessage - id="xpack.spaces.management.editSpaceFeatures.manageRolesLinkText" - defaultMessage="manage security roles" - /> - </EuiLink> - ) : ( - <FormattedMessage - id="xpack.spaces.management.editSpaceFeatures.askAnAdministratorText" - defaultMessage="ask an administrator to manage roles" - /> - ), - }} - /> - </p> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <FeatureTable features={features} space={space} onChange={onChange} /> - </EuiFlexItem> - </EuiFlexGroup> - </SectionPanel> - ); -}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx index 24269528916f8..cb82b5f800fbb 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx @@ -13,7 +13,6 @@ import type { KibanaFeature } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; -import { EditSpaceEnabledFeatures } from './edit_space_features_tab'; import { EditSpaceTabFooter } from './footer'; import { useEditSpaceServices } from './provider'; import type { Space } from '../../../common'; @@ -21,7 +20,9 @@ import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; import { getSpaceInitials } from '../../space_avatar'; import { ConfirmDeleteModal } from '../components'; import { ConfirmAlterActiveSpaceModal } from '../components/confirm_alter_active_space_modal'; +import { CustomizeAvatar } from '../components/customize_avatar'; import { CustomizeSpace } from '../components/customize_space'; +import { EnabledFeatures } from '../components/enabled_features'; import { SolutionView } from '../components/solution_view'; import { SpaceValidator } from '../lib'; import type { CustomizeSpaceFormValues } from '../types'; @@ -249,17 +250,15 @@ export const EditSpaceSettingsTab: React.FC<Props> = ({ space, features, history <EuiSpacer /> <EuiCallOut color="warning" - iconType="help" - title="Warning" - data-test-subj="space-edit-page-user-impact-warning" - > - {i18n.translate( + iconType="iInCircle" + title={i18n.translate( 'xpack.spaces.management.spaceDetails.spaceChangesWarning.impactAllUsersInSpace', { - defaultMessage: 'The changes made will impact all users in the space.', + defaultMessage: 'The changes will apply to all users of the space.', } )} - </EuiCallOut> + data-test-subj="space-edit-page-user-impact-warning" + /> </> ) ); @@ -289,10 +288,10 @@ export const EditSpaceSettingsTab: React.FC<Props> = ({ space, features, history </> )} - {props.allowFeatureVisibility && (solution == null || solution === SOLUTION_VIEW_CLASSIC) && ( + {props.allowFeatureVisibility && (!solution || solution === SOLUTION_VIEW_CLASSIC) && ( <> <EuiSpacer /> - <EditSpaceEnabledFeatures + <EnabledFeatures features={features} space={getSpaceFromFormValues(formValues)} onChange={onChangeFeatures} @@ -300,6 +299,14 @@ export const EditSpaceSettingsTab: React.FC<Props> = ({ space, features, history </> )} + <EuiSpacer /> + + <CustomizeAvatar + space={getSpaceFromFormValues(formValues)} + onChange={onChangeSpaceSettings} + validator={validator} + /> + {doShowUserImpactWarning()} <EuiSpacer /> diff --git a/x-pack/plugins/spaces/public/management/edit_space/footer.tsx b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx index 013a356f9b400..b00494cbee51c 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/footer.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx @@ -31,57 +31,57 @@ export const EditSpaceTabFooter: React.FC<Props> = ({ onClickSubmit, onClickDeleteSpace, }) => { + if (isLoading) { + return ( + <EuiFlexGroup justifyContent="spaceAround"> + <EuiFlexItem grow={false}> + <EuiLoadingSpinner /> + </EuiFlexItem> + </EuiFlexGroup> + ); + } + return ( - <> - {isLoading && ( - <EuiFlexGroup justifyContent="spaceAround"> - <EuiFlexItem grow={false}> - <EuiLoadingSpinner /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexGroup> + {isDirty && ( + <EuiFlexItem grow={false}> + <EuiButton + color="primary" + fill + onClick={onClickSubmit} + data-test-subj="save-space-button" + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.footerActions.updateSpace" + defaultMessage="Apply changes" + /> + </EuiButton> + </EuiFlexItem> )} - {!isLoading && ( - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiButtonEmpty - onClick={onClickDeleteSpace} - color="danger" - data-test-subj="delete-space-button" - > - <FormattedMessage - id="xpack.spaces.management.spaceDetails.footerActions.deleteSpace" - defaultMessage="Delete space" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={true} /> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={onClickCancel} data-test-subj="cancel-space-button"> - <FormattedMessage - id="xpack.spaces.management.spaceDetails.footerActions.cancel" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty onClick={onClickCancel} data-test-subj="cancel-space-button"> + <FormattedMessage + id="xpack.spaces.management.spaceDetails.footerActions.cancel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={true} /> - {isDirty && ( - <EuiFlexItem grow={false}> - <EuiButton - color="primary" - fill - onClick={onClickSubmit} - data-test-subj="save-space-button" - > - <FormattedMessage - id="xpack.spaces.management.spaceDetails.footerActions.updateSpace" - defaultMessage="Update space" - /> - </EuiButton> - </EuiFlexItem> - )} - </EuiFlexGroup> - )} - </> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={onClickDeleteSpace} + color="danger" + iconType="trash" + data-test-subj="delete-space-button" + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.footerActions.deleteSpace" + defaultMessage="Delete space" + /> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx index 658730a848a33..276efb7f92526 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -354,7 +354,7 @@ export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { <EuiFormRow label={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabel', - { defaultMessage: 'Select roles(s)' } + { defaultMessage: 'Select roles' } )} labelAppend={ <EuiLink href={getUrlForApp('management', { deepLinkId: 'roles' })}> @@ -367,7 +367,8 @@ export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { helpText={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.selectRolesHelp', { - defaultMessage: 'Select Kibana spaces to which you wish to assign privileges.', + defaultMessage: + 'Users assigned to selected roles will gain access to this space.', } )} > @@ -380,6 +381,10 @@ export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { values: { spaceName: space.name }, } )} + placeholder={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesPlaceholder', + { defaultMessage: 'Add a role...' } + )} isLoading={fetchingDataDeps} options={createRolesComboBoxOptions(spaceUnallocatedRoles)} selectedOptions={selectedRoles} diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx index 6a1d9f24bc042..ffe7ecba85ec0 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx @@ -78,6 +78,7 @@ const getTableColumns = ({ name: i18n.translate('xpack.spaces.management.spaceDetails.rolesTable.column.name.title', { defaultMessage: 'Role', }), + width: '45%', }, { field: 'privileges', @@ -118,25 +119,25 @@ const getTableColumns = ({ { defaultMessage: 'Role type' } ), render: (_value: Role['metadata']) => { - return React.createElement(EuiBadge, { - children: _value?._reserved - ? i18n.translate( + return _value?._reserved + ? React.createElement(EuiBadge, { + children: i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', { defaultMessage: 'Reserved' } - ) - : i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', - { defaultMessage: 'Custom' } ), - color: _value?._reserved ? undefined : 'success', - }); + color: 'primary', + }) + : null; }, }, ]; if (!isReadOnly) { columns.push({ - name: 'Actions', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.columnHeaderName', + { defaultMessage: 'Actions' } + ), actions: [ { type: 'icon', @@ -163,22 +164,22 @@ const getTableColumns = ({ : i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isAssignedToAll', { - defaultMessage: `Can't perform actions on a role that is assigned to all spaces`, + defaultMessage: `You can't edit the access of a role that is assigned to all spaces.`, } ), - isPrimary: true, + showOnHover: true, enabled: () => false, available: (rowRecord) => !isEditableRole(rowRecord), }, { type: 'icon', icon: 'pencil', + isPrimary: true, 'data-test-subj': 'spaceRoleCellEditAction', name: i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.title', { defaultMessage: 'Remove from space' } ), - isPrimary: true, description: i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', { @@ -186,15 +187,14 @@ const getTableColumns = ({ 'Click this action to edit the role privileges of this user for this space.', } ), - showOnHover: true, available: (rowRecord) => isEditableRole(rowRecord), onClick: onClickRowEditAction, }, { - isPrimary: true, type: 'icon', icon: 'trash', color: 'danger', + isPrimary: true, 'data-test-subj': 'spaceRoleCellDeleteAction', name: i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.remove.title', @@ -204,7 +204,6 @@ const getTableColumns = ({ 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', { defaultMessage: 'Click this action to remove the user from this space.' } ), - showOnHover: true, available: (rowRecord) => isEditableRole(rowRecord), onClick: onClickRowRemoveAction, }, diff --git a/x-pack/plugins/spaces/public/management/lib/validate_space.ts b/x-pack/plugins/spaces/public/management/lib/validate_space.ts index 7a7980028dad0..89205cc91f2ef 100644 --- a/x-pack/plugins/spaces/public/management/lib/validate_space.ts +++ b/x-pack/plugins/spaces/public/management/lib/validate_space.ts @@ -181,7 +181,7 @@ export class SpaceValidator { if (!space.solution) { return invalid( i18n.translate('xpack.spaces.management.validateSpace.requiredSolutionViewErrorMessage', { - defaultMessage: 'Select one solution.', + defaultMessage: 'Select a solution.', }) ); } diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx index 091057a2f4a4c..c8707f8959f0c 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.test.tsx @@ -95,7 +95,7 @@ describe('SpacesGridPage', () => { expect(wrapper.find('EuiInMemoryTable').prop('items')).toBe(spaces); expect(wrapper.find('EuiInMemoryTable').prop('columns')).not.toContainEqual({ field: 'solution', - name: 'Solution View', + name: 'Solution view', sortable: true, render: expect.any(Function), }); @@ -155,7 +155,7 @@ describe('SpacesGridPage', () => { expect(wrapper.find('EuiInMemoryTable').prop('items')).toBe(spacesWithSolution); expect(wrapper.find('EuiInMemoryTable').prop('columns')).toContainEqual({ field: 'solution', - name: 'Solution View', + name: 'Solution view', sortable: true, render: expect.any(Function), }); diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 5ac3ecf0ca687..462b65f327ebc 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -10,7 +10,7 @@ import { type EuiBasicTableColumn, EuiButton, EuiCallOut, - EuiFlexGroup, + EuiFlexGrid, EuiFlexItem, EuiInMemoryTable, EuiLink, @@ -19,6 +19,7 @@ import { EuiPageSection, EuiSpacer, EuiText, + useIsWithinBreakpoints, } from '@elastic/eui'; import React, { Component, lazy, Suspense } from 'react'; @@ -152,9 +153,7 @@ export class SpacesGridPage extends Component<Props, State> { box: { placeholder: i18n.translate( 'xpack.spaces.management.spacesGridPage.searchPlaceholder', - { - defaultMessage: 'Search', - } + { defaultMessage: 'Search' } ), }, }} @@ -281,28 +280,49 @@ export class SpacesGridPage extends Component<Props, State> { defaultMessage: 'Space', }), sortable: true, - render: (value: string, rowRecord: Space) => ( - <EuiFlexGroup responsive={false} alignItems="center" gutterSize="m"> - <EuiFlexItem grow={false}> - <EuiLink - {...reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord))} - data-test-subj={`${rowRecord.id}-hyperlink`} + render: (value: string, rowRecord: Space) => { + const SpaceName = () => { + const isCurrent = this.state.activeSpace?.id === rowRecord.id; + const isWide = useIsWithinBreakpoints(['xl']); + const gridColumns = isCurrent && isWide ? 2 : 1; + return ( + <EuiFlexGrid + responsive={false} + columns={gridColumns} + alignItems="center" + gutterSize="s" > - {value} - </EuiLink> - </EuiFlexItem> - {this.state.activeSpace?.id === rowRecord.id && ( - <EuiFlexItem grow={false}> - <EuiBadge color="primary" data-test-subj={`spacesListCurrentBadge-${rowRecord.id}`}> - {i18n.translate('xpack.spaces.management.spacesGridPage.currentSpaceMarkerText', { - defaultMessage: 'current', - })} - </EuiBadge> - </EuiFlexItem> - )} - </EuiFlexGroup> - ), + <EuiFlexItem> + <EuiLink + {...reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord))} + data-test-subj={`${rowRecord.id}-hyperlink`} + > + {value} + </EuiLink> + </EuiFlexItem> + <EuiFlexItem> + {isCurrent && ( + <span> + <EuiBadge + color="primary" + data-test-subj={`spacesListCurrentBadge-${rowRecord.id}`} + > + {i18n.translate( + 'xpack.spaces.management.spacesGridPage.currentSpaceMarkerText', + { defaultMessage: 'current' } + )} + </EuiBadge> + </span> + )} + </EuiFlexItem> + </EuiFlexGrid> + ); + }; + + return <SpaceName />; + }, 'data-test-subj': 'spacesListTableRowNameCell', + width: '15%', }, { field: 'description', @@ -311,7 +331,7 @@ export class SpacesGridPage extends Component<Props, State> { }), sortable: true, truncateText: true, - width: '30%', + width: '45%', }, ]; @@ -331,7 +351,7 @@ export class SpacesGridPage extends Component<Props, State> { return ( <FormattedMessage id="xpack.spaces.management.spacesGridPage.allFeaturesEnabled" - defaultMessage="All features visible" + defaultMessage="All features" /> ); } @@ -377,7 +397,7 @@ export class SpacesGridPage extends Component<Props, State> { config.push({ field: 'solution', name: i18n.translate('xpack.spaces.management.spacesGridPage.solutionColumnName', { - defaultMessage: 'Solution View', + defaultMessage: 'Solution view', }), sortable: true, render: (solution: Space['solution'], record: Space) => ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f993da6cd3985..06b9b42e4b8b9 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -42710,8 +42710,6 @@ "xpack.spaces.management.deselectAllFeaturesLink": "Tout masquer", "xpack.spaces.management.enabledFeatures.featureCategoryButtonLabel": "Touche bascule de catégorie", "xpack.spaces.management.enabledSpaceFeatures.enableFeaturesInSpaceMessage": "Définir la visibilité des fonctionnalités", - "xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText": "gérer les rôles de sécurité", - "xpack.spaces.management.enabledSpaceFeatures.notASecurityMechanismMessage": "Les fonctionnalités masquées sont supprimées de l'interface utilisateur, mais pas désactivées. Pour sécuriser l'accès aux fonctionnalités, {manageRolesLink}.", "xpack.spaces.management.featureAccordionSwitchLabel": "{enabledCount} fonctionnalités visibles / {featureCount}", "xpack.spaces.management.featureVisibilityTitle": "Visibilité des fonctionnalités", "xpack.spaces.management.hideAllFeaturesText": "Tout masquer", @@ -42723,15 +42721,11 @@ "xpack.spaces.management.manageSpacePage.createSpaceTitle": "Créer l'espace", "xpack.spaces.management.manageSpacePage.describeSpaceDescription": "Attribuez à votre espace un nom facile à retenir.", "xpack.spaces.management.manageSpacePage.describeSpaceTitle": "Décrire cet espace", - "xpack.spaces.management.manageSpacePage.editSpaceTitle": "Modifier l'espace", "xpack.spaces.management.manageSpacePage.errorLoadingSpaceTitle": "Erreur lors du chargement de l'espace : {message}", "xpack.spaces.management.manageSpacePage.errorSavingSpaceTitle": "Erreur lors de l'enregistrement de l'espace : {message}", - "xpack.spaces.management.manageSpacePage.featuresTitle": "Fonctionnalités", - "xpack.spaces.management.manageSpacePage.generalTitle": "Général", "xpack.spaces.management.manageSpacePage.loadErrorTitle": "Erreur lors du chargement des fonctionnalités disponibles", "xpack.spaces.management.manageSpacePage.loadingMessage": "Chargement…", "xpack.spaces.management.manageSpacePage.nameFormRowLabel": "Nom", - "xpack.spaces.management.manageSpacePage.navigationTitle": "Navigation", "xpack.spaces.management.manageSpacePage.optionalLabel": "Facultatif", "xpack.spaces.management.manageSpacePage.setSolutionViewDescription": "Détermine la navigation que tous les utilisateurs verront pour cet espace. Chaque vue de solution contient des fonctionnalités de Outils d'analyse et de Gestion.", "xpack.spaces.management.manageSpacePage.setSolutionViewMessage": "Définir la vue de la solution", @@ -42742,7 +42736,6 @@ "xpack.spaces.management.manageSpacePage.spaceDescriptionFormRowLabel": "Description", "xpack.spaces.management.manageSpacePage.spaceDescriptionHelpText": "La description s'affiche sur l'écran de sélection de l'espace.", "xpack.spaces.management.manageSpacePage.spaceSuccessfullySavedNotificationMessage": "L'espace {name} a été enregistré.", - "xpack.spaces.management.manageSpacePage.updateSpaceButton": "Mettre à jour l'espace", "xpack.spaces.management.navigation.solutionViewLabel": "Afficher la solution", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "Les espaces réservés sont intégrés et ne peuvent être que partiellement modifiés.", "xpack.spaces.management.selectAllFeaturesLink": "Afficher tout", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 14b59b1f54b8b..f54abfcf29bb3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -42450,8 +42450,6 @@ "xpack.spaces.management.deselectAllFeaturesLink": "すべて非表示", "xpack.spaces.management.enabledFeatures.featureCategoryButtonLabel": "カテゴリ切り替え", "xpack.spaces.management.enabledSpaceFeatures.enableFeaturesInSpaceMessage": "機能の表示を設定", - "xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText": "セキュリティロールを管理", - "xpack.spaces.management.enabledSpaceFeatures.notASecurityMechanismMessage": "非表示の機能はユーザーインターフェイスから削除されますが、無効にされません。機能へのアクセスを保護するには、{manageRolesLink}してください。", "xpack.spaces.management.featureAccordionSwitchLabel": "{featureCount} 件中 {enabledCount} 件の機能を表示中", "xpack.spaces.management.featureVisibilityTitle": "機能の表示", "xpack.spaces.management.hideAllFeaturesText": "すべて非表示", @@ -42463,15 +42461,11 @@ "xpack.spaces.management.manageSpacePage.createSpaceTitle": "スペースを作成", "xpack.spaces.management.manageSpacePage.describeSpaceDescription": "スペースに覚えやすい名前を付けます。", "xpack.spaces.management.manageSpacePage.describeSpaceTitle": "このスペースを説明", - "xpack.spaces.management.manageSpacePage.editSpaceTitle": "スペースの編集", "xpack.spaces.management.manageSpacePage.errorLoadingSpaceTitle": "スペースの読み込み中にエラーが発生:{message}", "xpack.spaces.management.manageSpacePage.errorSavingSpaceTitle": "スペースの保存中にエラーが発生:{message}", - "xpack.spaces.management.manageSpacePage.featuresTitle": "機能", - "xpack.spaces.management.manageSpacePage.generalTitle": "一般", "xpack.spaces.management.manageSpacePage.loadErrorTitle": "利用可能な機能の読み込みエラー", "xpack.spaces.management.manageSpacePage.loadingMessage": "読み込み中…", "xpack.spaces.management.manageSpacePage.nameFormRowLabel": "名前", - "xpack.spaces.management.manageSpacePage.navigationTitle": "ナビゲーション", "xpack.spaces.management.manageSpacePage.optionalLabel": "オプション", "xpack.spaces.management.manageSpacePage.setSolutionViewDescription": "すべてのユーザーにこのスペースで表示されるナビゲーションを決定します。各ソリューションビューには、分析ツールと管理の機能が含まれます。", "xpack.spaces.management.manageSpacePage.setSolutionViewMessage": "ソリューションビューを設定", @@ -42482,7 +42476,6 @@ "xpack.spaces.management.manageSpacePage.spaceDescriptionFormRowLabel": "説明", "xpack.spaces.management.manageSpacePage.spaceDescriptionHelpText": "説明はスペース選択画面に表示されます。", "xpack.spaces.management.manageSpacePage.spaceSuccessfullySavedNotificationMessage": "スペース {name} が保存されました。", - "xpack.spaces.management.manageSpacePage.updateSpaceButton": "スペースを更新", "xpack.spaces.management.navigation.solutionViewLabel": "ソリューションビュー", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "リザーブされたスペースはビルトインのため、部分的な変更しかできません。", "xpack.spaces.management.selectAllFeaturesLink": "すべて表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e55a9107d7c4d..7899d8a68f04c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -42500,8 +42500,6 @@ "xpack.spaces.management.deselectAllFeaturesLink": "全部隐藏", "xpack.spaces.management.enabledFeatures.featureCategoryButtonLabel": "类别切换", "xpack.spaces.management.enabledSpaceFeatures.enableFeaturesInSpaceMessage": "设置功能可见性", - "xpack.spaces.management.enabledSpaceFeatures.manageRolesLinkText": "管理安全角色", - "xpack.spaces.management.enabledSpaceFeatures.notASecurityMechanismMessage": "将会从用户界面移除隐藏的功能,但不会禁用。要获取功能的访问权限,{manageRolesLink}。", "xpack.spaces.management.featureAccordionSwitchLabel": "{enabledCount}/{featureCount} 个功能可见", "xpack.spaces.management.featureVisibilityTitle": "功能可见性", "xpack.spaces.management.hideAllFeaturesText": "全部隐藏", @@ -42513,15 +42511,11 @@ "xpack.spaces.management.manageSpacePage.createSpaceTitle": "创建工作区", "xpack.spaces.management.manageSpacePage.describeSpaceDescription": "为您的工作区提供好记的名称。", "xpack.spaces.management.manageSpacePage.describeSpaceTitle": "描述此工作区", - "xpack.spaces.management.manageSpacePage.editSpaceTitle": "编辑工作区", "xpack.spaces.management.manageSpacePage.errorLoadingSpaceTitle": "加载空间时出错:{message}", "xpack.spaces.management.manageSpacePage.errorSavingSpaceTitle": "保存空间时出错:{message}", - "xpack.spaces.management.manageSpacePage.featuresTitle": "功能", - "xpack.spaces.management.manageSpacePage.generalTitle": "常规", "xpack.spaces.management.manageSpacePage.loadErrorTitle": "加载可用功能时出错", "xpack.spaces.management.manageSpacePage.loadingMessage": "正在加载……", "xpack.spaces.management.manageSpacePage.nameFormRowLabel": "名称", - "xpack.spaces.management.manageSpacePage.navigationTitle": "导航", "xpack.spaces.management.manageSpacePage.optionalLabel": "可选", "xpack.spaces.management.manageSpacePage.setSolutionViewDescription": "确定所有用户将在此工作区看到的导航。每个解决方案视图均包含来自分析工具的功能和管理功能。", "xpack.spaces.management.manageSpacePage.setSolutionViewMessage": "设置解决方案视图", @@ -42532,7 +42526,6 @@ "xpack.spaces.management.manageSpacePage.spaceDescriptionFormRowLabel": "描述", "xpack.spaces.management.manageSpacePage.spaceDescriptionHelpText": "描述显示在“工作区选择”屏幕上。", "xpack.spaces.management.manageSpacePage.spaceSuccessfullySavedNotificationMessage": "空间 “{name}” 已保存。", - "xpack.spaces.management.manageSpacePage.updateSpaceButton": "更新工作区", "xpack.spaces.management.navigation.solutionViewLabel": "解决方案视图", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "保留的工作区是内置的,只能进行部分修改。", "xpack.spaces.management.selectAllFeaturesLink": "全部显示", From b5e941344f9a198f15da4dff5170fb82869ef527 Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:28:57 -0700 Subject: [PATCH 06/11] [ResponseOps] Prepare the connector `delete` HTTP API for versioning (#194227) Towards https://github.com/elastic/response-ops-team/issues/125 ## Summary Preparing the `DELETE ${BASE_ACTION_API_PATH}/connector/{id}` HTTP API for versioning --- .../routes/connector/apis/delete/index.ts | 12 +++++++++ .../connector/apis/delete/schemas/latest.ts | 8 ++++++ .../connector/apis/delete/schemas/v1.ts | 16 ++++++++++++ .../connector/apis/delete/types/latest.ts | 8 ++++++ .../routes/connector/apis/delete/types/v1.ts | 11 ++++++++ .../{ => connector/delete}/delete.test.ts | 20 +++++++-------- .../routes/{ => connector/delete}/delete.ts | 25 ++++++++----------- .../server/routes/connector/delete/index.ts | 8 ++++++ x-pack/plugins/actions/server/routes/index.ts | 4 +-- 9 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/actions/common/routes/connector/apis/delete/index.ts create mode 100644 x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/latest.ts create mode 100644 x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/v1.ts create mode 100644 x-pack/plugins/actions/common/routes/connector/apis/delete/types/latest.ts create mode 100644 x-pack/plugins/actions/common/routes/connector/apis/delete/types/v1.ts rename x-pack/plugins/actions/server/routes/{ => connector/delete}/delete.test.ts (83%) rename x-pack/plugins/actions/server/routes/{ => connector/delete}/delete.ts (67%) create mode 100644 x-pack/plugins/actions/server/routes/connector/delete/index.ts diff --git a/x-pack/plugins/actions/common/routes/connector/apis/delete/index.ts b/x-pack/plugins/actions/common/routes/connector/apis/delete/index.ts new file mode 100644 index 0000000000000..68b75d728e56a --- /dev/null +++ b/x-pack/plugins/actions/common/routes/connector/apis/delete/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { deleteConnectorRequestParamsSchema } from './schemas/latest'; +export type { DeleteConnectorRequestParams } from './types/latest'; + +export { deleteConnectorRequestParamsSchema as deleteConnectorRequestParamsSchemaV1 } from './schemas/v1'; +export type { DeleteConnectorRequestParams as DeleteConnectorRequestParamsV1 } from './types/v1'; diff --git a/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/latest.ts b/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/v1.ts b/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/v1.ts new file mode 100644 index 0000000000000..3847b188e85ed --- /dev/null +++ b/x-pack/plugins/actions/common/routes/connector/apis/delete/schemas/v1.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const deleteConnectorRequestParamsSchema = schema.object({ + id: schema.string({ + meta: { + description: 'An identifier for the connector.', + }, + }), +}); diff --git a/x-pack/plugins/actions/common/routes/connector/apis/delete/types/latest.ts b/x-pack/plugins/actions/common/routes/connector/apis/delete/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/actions/common/routes/connector/apis/delete/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/actions/common/routes/connector/apis/delete/types/v1.ts b/x-pack/plugins/actions/common/routes/connector/apis/delete/types/v1.ts new file mode 100644 index 0000000000000..bd713f5921f25 --- /dev/null +++ b/x-pack/plugins/actions/common/routes/connector/apis/delete/types/v1.ts @@ -0,0 +1,11 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { deleteConnectorRequestParamsSchemaV1 } from '..'; + +export type DeleteConnectorRequestParams = TypeOf<typeof deleteConnectorRequestParamsSchemaV1>; diff --git a/x-pack/plugins/actions/server/routes/delete.test.ts b/x-pack/plugins/actions/server/routes/connector/delete/delete.test.ts similarity index 83% rename from x-pack/plugins/actions/server/routes/delete.test.ts rename to x-pack/plugins/actions/server/routes/connector/delete/delete.test.ts index b250e607738e6..9fb3f7f3a8ae5 100644 --- a/x-pack/plugins/actions/server/routes/delete.test.ts +++ b/x-pack/plugins/actions/server/routes/connector/delete/delete.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { deleteActionRoute } from './delete'; +import { deleteConnectorRoute } from './delete'; import { httpServiceMock } from '@kbn/core/server/mocks'; -import { licenseStateMock } from '../lib/license_state.mock'; -import { mockHandlerArguments } from './legacy/_mock_handler_arguments'; -import { actionsClientMock } from '../mocks'; -import { verifyAccessAndContext } from './verify_access_and_context'; +import { licenseStateMock } from '../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../legacy/_mock_handler_arguments'; +import { actionsClientMock } from '../../../mocks'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; -jest.mock('./verify_access_and_context', () => ({ +jest.mock('../../verify_access_and_context', () => ({ verifyAccessAndContext: jest.fn(), })); @@ -21,12 +21,12 @@ beforeEach(() => { (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); }); -describe('deleteActionRoute', () => { +describe('deleteConnectorRoute', () => { it('deletes an action with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); - deleteActionRoute(router, licenseState); + deleteConnectorRoute(router, licenseState); const [config, handler] = router.delete.mock.calls[0]; @@ -63,7 +63,7 @@ describe('deleteActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); - deleteActionRoute(router, licenseState); + deleteConnectorRoute(router, licenseState); const [, handler] = router.delete.mock.calls[0]; @@ -90,7 +90,7 @@ describe('deleteActionRoute', () => { throw new Error('OMG'); }); - deleteActionRoute(router, licenseState); + deleteConnectorRoute(router, licenseState); const [, handler] = router.delete.mock.calls[0]; diff --git a/x-pack/plugins/actions/server/routes/delete.ts b/x-pack/plugins/actions/server/routes/connector/delete/delete.ts similarity index 67% rename from x-pack/plugins/actions/server/routes/delete.ts rename to x-pack/plugins/actions/server/routes/connector/delete/delete.ts index 8b25fe66c9eb2..38f7c41d6037b 100644 --- a/x-pack/plugins/actions/server/routes/delete.ts +++ b/x-pack/plugins/actions/server/routes/connector/delete/delete.ts @@ -5,20 +5,17 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; -import { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; -import { ActionsRequestHandlerContext } from '../types'; -import { verifyAccessAndContext } from './verify_access_and_context'; +import { ILicenseState } from '../../../lib'; +import { BASE_ACTION_API_PATH } from '../../../../common'; +import { ActionsRequestHandlerContext } from '../../../types'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { + deleteConnectorRequestParamsSchemaV1, + DeleteConnectorRequestParamsV1, +} from '../../../../common/routes/connector/apis/delete'; -const paramSchema = schema.object({ - id: schema.string({ - meta: { description: 'An identifier for the connector.' }, - }), -}); - -export const deleteActionRoute = ( +export const deleteConnectorRoute = ( router: IRouter<ActionsRequestHandlerContext>, licenseState: ILicenseState ) => { @@ -33,7 +30,7 @@ export const deleteActionRoute = ( }, validate: { request: { - params: paramSchema, + params: deleteConnectorRequestParamsSchemaV1, }, response: { 204: { @@ -45,7 +42,7 @@ export const deleteActionRoute = ( router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const actionsClient = (await context.actions).getActionsClient(); - const { id } = req.params; + const { id }: DeleteConnectorRequestParamsV1 = req.params; await actionsClient.delete({ id }); return res.noContent(); }) diff --git a/x-pack/plugins/actions/server/routes/connector/delete/index.ts b/x-pack/plugins/actions/server/routes/connector/delete/index.ts new file mode 100644 index 0000000000000..7539eb498e837 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/delete/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { deleteConnectorRoute } from './delete'; diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts index ca25b88bcf798..975eb662be415 100644 --- a/x-pack/plugins/actions/server/routes/index.ts +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -14,7 +14,7 @@ import { listTypesWithSystemRoute } from './connector/list_types_system'; import { ILicenseState } from '../lib'; import { ActionsRequestHandlerContext } from '../types'; import { createActionRoute } from './create'; -import { deleteActionRoute } from './delete'; +import { deleteConnectorRoute } from './connector/delete'; import { executeActionRoute } from './execute'; import { getConnectorRoute } from './connector/get'; import { updateActionRoute } from './update'; @@ -37,7 +37,7 @@ export function defineRoutes(opts: RouteOptions) { defineLegacyRoutes(router, licenseState, usageCounter); createActionRoute(router, licenseState); - deleteActionRoute(router, licenseState); + deleteConnectorRoute(router, licenseState); getConnectorRoute(router, licenseState); getAllConnectorsRoute(router, licenseState); updateActionRoute(router, licenseState); From 121ff399672673844c5a92996c7a379894abeea8 Mon Sep 17 00:00:00 2001 From: Jen Huang <its.jenetic@gmail.com> Date: Tue, 1 Oct 2024 12:30:24 -0700 Subject: [PATCH 07/11] [UII] Add proxy args to install snippets (#193922) ## Summary Resolves #184222. This PR: - Ensures custom agent binary download source URI is respected where ever it appears in command snippets, for both Fleet Server and Elastic Agent install instructions - If a proxy is associated with the source URI, the appropriate args are added to the commands as well - For `curl` commands, these are appended as `--proxy <url>` and `--proxy-header "<key>-<value>"` (repeated for each header key/value pair) - For Windows, these are appended as `-Proxy "<url>"` and `-Headers @{"<key1>"="<value1>"; "<key2>"="<value2>"}` - Adjusts Fleet Server `./elastic-agent install` instructions so that: - `--fleet-server-es` is the value of the data output host set on that Fleet Server policy (must be ES output) - If a proxy is associated with that ES output, the corresponding args are appended: `--proxy-url=<url>` and `--proxy-header "<key>-<value>"` (repeated for each header key/value pair) The internal API at `/internal/fleet/settings/enrollment` has new properties added to its response to support this: ``` fleet_server: { es_output?: Output; es_output_proxy?: FleetProxy; }; download_source_proxy?: FleetProxy; ``` ## Examples **Fleet Server install with proxied custom download and proxied ES host:** ``` curl -L -O https://my-agent-binary-source/beats/elastic-agent/elastic-agent-9.0.0-linux-x86_64.tar.gz --proxy http://some-proxy:1111 --proxy-header "Accept-Language=en-US,en;q=0.5" --proxy-header "Accept-Encoding=gzip, deflate, br" tar xzvf elastic-agent-9.0.0-linux-x86_64.tar.gz cd elastic-agent-9.0.0-linux-x86_64 sudo ./elastic-agent install \ --fleet-server-es=http://localhost:9999 \ --fleet-server-service-token=REDACTED \ --fleet-server-policy=027a180f-2f4a-4dd1-a531-bf1d1d64179f \ --fleet-server-port=8220 \ --proxy-url=http://some-proxy:1111 \ --proxy-header="Accept-Language=en-US,en;q=0.5" \ --proxy-header="Accept-Encoding=gzip, deflate, br" ``` ``` $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri https://my-agent-binary-source/beats/elastic-agent/elastic-agent-9.0.0-windows-x86_64.zip -OutFile elastic-agent-9.0.0-windows-x86_64.zip -Proxy "http://some-proxy:1111" -Headers @{"Accept-Language"="en-US,en;q=0.5";"Accept-Encoding"="gzip, deflate, br"} Expand-Archive .\elastic-agent-9.0.0-windows-x86_64.zip cd elastic-agent-9.0.0-windows-x86_64 .\elastic-agent.exe install ` --fleet-server-es=http://localhost:9999 ` --fleet-server-service-token=REDACTED ` --fleet-server-policy=027a180f-2f4a-4dd1-a531-bf1d1d64179f ` --fleet-server-port=8220 ` --proxy-url=http://some-proxy:1111 ` --proxy-header="Accept-Language=en-US,en;q=0.5" ` --proxy-header="Accept-Encoding=gzip, deflate, br" ``` **Elastic Agent install with proxied download source and proxied Fleet Server host:** ``` curl -L -O https://my-agent-binary-source/beats/elastic-agent/elastic-agent-8.15.1-darwin-aarch64.tar.gz --proxy http://some-proxy:1111 --proxy-header "Accept-Language=en-US,en;q=0.5" --proxy-header "Accept-Encoding=gzip, deflate, br" tar xzvf elastic-agent-8.15.1-darwin-aarch64.tar.gz cd elastic-agent-8.15.1-darwin-aarch64 sudo ./elastic-agent install --url=https://localhost:2222 --enrollment-token=REDACTED --proxy-url=http://some-proxy:1111 --proxy-header "Accept-Language=en-US,en;q=0.5" --proxy-header "Accept-Encoding=gzip, deflate, br" ``` ``` $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri https://my-agent-binary-source/beats/elastic-agent/elastic-agent-8.15.1-windows-x86_64.zip -OutFile elastic-agent-8.15.1-windows-x86_64.zip -Proxy "http://some-proxy:1111" -Headers @{"Accept-Language"="en-US,en;q=0.5";"Accept-Encoding"="gzip, deflate, br"} Expand-Archive .\elastic-agent-8.15.1-windows-x86_64.zip -DestinationPath . cd elastic-agent-8.15.1-windows-x86_64 .\elastic-agent.exe install --url=https://localhost:2222 --enrollment-token=REDACTED --proxy-url=http://some-proxy:1111 --proxy-header "Accept-Language=en-US,en;q=0.5" --proxy-header "Accept-Encoding=gzip, deflate, br" ``` ### To-do - [x] Unit tests - [x] API integration tests for enrollment settings endpoint --- .../fleet_settings_enrollment_response.yaml | 12 + .../fleet/common/types/rest_spec/settings.ts | 13 +- .../steps/install_fleet_server.tsx | 33 +- .../utils/install_command_utils.test.ts | 695 +++++++++++++++--- .../utils/install_command_utils.ts | 86 ++- .../install_agent_standalone.tsx | 2 +- .../agent_enrollment_flyout/index.tsx | 10 +- .../steps/compute_steps.tsx | 12 +- .../agent_enrollment_flyout/types.ts | 1 + .../enrollment_instructions/manual/index.tsx | 57 +- .../standalone/index.tsx | 27 +- ...use_fleet_server_hosts_for_policy.test.tsx | 52 ++ .../use_fleet_server_hosts_for_policy.ts | 8 +- .../settings/enrollment_settings_handler.ts | 37 +- .../fleet/server/types/rest_spec/settings.ts | 28 +- .../apis/settings/enrollment.ts | 45 +- .../es_archives/fleet/fleet_server/data.json | 5 +- 17 files changed, 916 insertions(+), 207 deletions(-) diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_enrollment_response.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_enrollment_response.yaml index 5c9204e5f35a3..8de00dae5c9ea 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_enrollment_response.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_enrollment_response.yaml @@ -23,6 +23,12 @@ properties: type: string download_source_id: type: string + space_ids: + type: array + items: + type: string + data_output_id: + type: string required: - id - name @@ -33,10 +39,16 @@ properties: $ref: ./fleet_server_host.yaml host_proxy: $ref: ./proxies.yaml + es_output: + $ref: ./output_create_request_elasticsearch.yaml + es_output_proxy: + $ref: ./proxies.yaml required: - agent_policies - has_active download_source: $ref: ./download_sources.yaml + download_source_proxy: + $ref: ./proxies.yaml required: - fleet_server diff --git a/x-pack/plugins/fleet/common/types/rest_spec/settings.ts b/x-pack/plugins/fleet/common/types/rest_spec/settings.ts index 6961a0254562f..c281b79b4d50c 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/settings.ts @@ -5,7 +5,14 @@ * 2.0. */ -import type { Settings, AgentPolicy, FleetServerHost, FleetProxy, DownloadSource } from '../models'; +import type { + Settings, + AgentPolicy, + FleetServerHost, + FleetProxy, + DownloadSource, + Output, +} from '../models'; export interface GetSettingsResponse { item: Settings; @@ -35,16 +42,20 @@ export type EnrollmentSettingsFleetServerPolicy = Pick< | 'fleet_server_host_id' | 'download_source_id' | 'space_ids' + | 'data_output_id' >; export interface GetEnrollmentSettingsResponse { fleet_server: { policies: EnrollmentSettingsFleetServerPolicy[]; has_active: boolean; + es_output?: Output; + es_output_proxy?: FleetProxy; host?: FleetServerHost; host_proxy?: FleetProxy; }; download_source?: DownloadSource; + download_source_proxy?: FleetProxy; } export interface PutSpaceSettingsRequest { body: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx index 97edbd849e0ae..a7631deb88b80 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/install_fleet_server.tsx @@ -13,8 +13,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { PLATFORM_TYPE } from '../../../hooks'; -import { useDefaultDownloadSource } from '../../../hooks'; -import { useStartServices, useDefaultOutput, useKibanaVersion } from '../../../hooks'; +import { useFleetServerHostsForPolicy } from '../../../hooks'; +import { useStartServices, useKibanaVersion } from '../../../hooks'; import { PlatformSelector } from '../..'; @@ -61,24 +61,31 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ }> = ({ serviceToken, fleetServerHost, fleetServerPolicyId, deploymentMode }) => { const { docLinks } = useStartServices(); const kibanaVersion = useKibanaVersion(); - const { output } = useDefaultOutput(); - const { downloadSource } = useDefaultDownloadSource(); - const commandOutput = output?.type === 'elasticsearch' ? output : undefined; + const { esOutput, esOutputProxy, downloadSource, downloadSourceProxy } = + useFleetServerHostsForPolicy( + fleetServerPolicyId + ? { + id: fleetServerPolicyId, + } + : null + ); const installCommands = (['linux', 'mac', 'windows', 'deb', 'rpm'] as PLATFORM_TYPE[]).reduce( (acc, platform) => { - acc[platform] = getInstallCommandForPlatform( + acc[platform] = getInstallCommandForPlatform({ platform, - commandOutput?.hosts?.[0] ?? '<ELASTICSEARCH_HOST>', - serviceToken ?? '', - fleetServerPolicyId, + esOutputHost: esOutput?.hosts?.[0] ?? '<ELASTICSEARCH_HOST>', + esOutputProxy, + serviceToken: serviceToken ?? '', + policyId: fleetServerPolicyId, fleetServerHost, - deploymentMode === 'production', - commandOutput?.ca_trusted_fingerprint ?? undefined, + isProductionDeployment: deploymentMode === 'production', + sslCATrustedFingerprint: esOutput?.ca_trusted_fingerprint ?? undefined, kibanaVersion, - downloadSource - ); + downloadSource, + downloadSourceProxy, + }); return acc; }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts index 21a2cc53257f7..c86811da2fde7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts @@ -10,11 +10,11 @@ import { getInstallCommandForPlatform } from './install_command_utils'; describe('getInstallCommandForPlatform', () => { describe('without policy id', () => { it('should return the correct command if the the policyId is not set for linux', () => { - const res = getInstallCommandForPlatform( - 'linux', - 'http://elasticsearch:9200', - 'service-token-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz @@ -28,11 +28,11 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is not set for mac', () => { - const res = getInstallCommandForPlatform( - 'mac', - 'http://elasticsearch:9200', - 'service-token-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'mac', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-aarch64.tar.gz @@ -46,11 +46,11 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is not set for windows', () => { - const res = getInstallCommandForPlatform( - 'windows', - 'http://elasticsearch:9200', - 'service-token-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'windows', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + }); expect(res).toMatchInlineSnapshot(` "$ProgressPreference = 'SilentlyContinue' @@ -65,11 +65,11 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is not set for rpm', () => { - const res = getInstallCommandForPlatform( - 'rpm', - 'http://elasticsearch:9200', - 'service-token-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'rpm', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm @@ -84,11 +84,11 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is not set for deb', () => { - const res = getInstallCommandForPlatform( - 'deb', - 'http://elasticsearch:9200', - 'service-token-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'deb', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb @@ -103,15 +103,12 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command sslCATrustedFingerprint option is passed', () => { - const res = getInstallCommandForPlatform( - 'linux', - 'http://elasticsearch:9200', - 'service-token-1', - undefined, - undefined, - false, - 'fingerprint123456' - ); + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + sslCATrustedFingerprint: 'fingerprint123456', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz @@ -128,12 +125,12 @@ describe('getInstallCommandForPlatform', () => { describe('with policy id', () => { it('should return the correct command if the the policyId is set for linux', () => { - const res = getInstallCommandForPlatform( - 'linux', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz @@ -148,12 +145,12 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for mac', () => { - const res = getInstallCommandForPlatform( - 'mac', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'mac', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-aarch64.tar.gz @@ -168,12 +165,12 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for windows', () => { - const res = getInstallCommandForPlatform( - 'windows', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'windows', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + }); expect(res).toMatchInlineSnapshot(` "$ProgressPreference = 'SilentlyContinue' @@ -189,12 +186,12 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for rpm', () => { - const res = getInstallCommandForPlatform( - 'rpm', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'rpm', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm @@ -210,12 +207,12 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for deb', () => { - const res = getInstallCommandForPlatform( - 'deb', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1' - ); + const res = getInstallCommandForPlatform({ + platform: 'deb', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb @@ -233,22 +230,18 @@ describe('getInstallCommandForPlatform', () => { describe('with policy id and downloadSource', () => { it('should return the correct command if the the policyId is set for linux', () => { - const res = getInstallCommandForPlatform( - 'linux', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - undefined, - undefined, - undefined, - undefined, - { + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + downloadSource: { id: 'test', name: 'test', is_default: false, host: 'https://test.fr/8.12.0-test/', - } - ); + }, + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://test.fr/8.12.0-test/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz @@ -265,14 +258,14 @@ describe('getInstallCommandForPlatform', () => { describe('with policy id and fleet server host and production deployment', () => { it('should return the correct command if the the policyId is set for linux', () => { - const res = getInstallCommandForPlatform( - 'linux', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - 'http://fleetserver:8220', - true - ); + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + isProductionDeployment: true, + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz @@ -291,14 +284,14 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for mac', () => { - const res = getInstallCommandForPlatform( - 'mac', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - 'http://fleetserver:8220', - true - ); + const res = getInstallCommandForPlatform({ + platform: 'mac', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + isProductionDeployment: true, + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-aarch64.tar.gz @@ -317,14 +310,14 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for windows', () => { - const res = getInstallCommandForPlatform( - 'windows', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - 'http://fleetserver:8220', - true - ); + const res = getInstallCommandForPlatform({ + platform: 'windows', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + isProductionDeployment: true, + }); expect(res).toMatchInlineSnapshot(` "$ProgressPreference = 'SilentlyContinue' @@ -344,14 +337,14 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for rpm', () => { - const res = getInstallCommandForPlatform( - 'rpm', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - 'http://fleetserver:8220', - true - ); + const res = getInstallCommandForPlatform({ + platform: 'rpm', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + isProductionDeployment: true, + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm @@ -371,14 +364,14 @@ describe('getInstallCommandForPlatform', () => { }); it('should return the correct command if the the policyId is set for deb', () => { - const res = getInstallCommandForPlatform( - 'deb', - 'http://elasticsearch:9200', - 'service-token-1', - 'policy-1', - 'http://fleetserver:8220', - true - ); + const res = getInstallCommandForPlatform({ + platform: 'deb', + esOutputHost: 'http://elasticsearch:9200', + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + isProductionDeployment: true, + }); expect(res).toMatchInlineSnapshot(` "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb @@ -397,4 +390,474 @@ describe('getInstallCommandForPlatform', () => { `); }); }); + + describe('with simple proxy settings', () => { + it('should return the correct command if proxies are set for linux', () => { + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz --proxy http://download-src-proxy:2222 + tar xzvf elastic-agent--linux-x86_64.tar.gz + cd elastic-agent--linux-x86_64 + sudo ./elastic-agent install \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111" + `); + }); + + it('should return the correct command if proxies are set for mac', () => { + const res = getInstallCommandForPlatform({ + platform: 'mac', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--darwin-aarch64.tar.gz --proxy http://download-src-proxy:2222 + tar xzvf elastic-agent--darwin-aarch64.tar.gz + cd elastic-agent--darwin-aarch64 + sudo ./elastic-agent install \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111" + `); + }); + + it('should return the correct command if proxies are set for windows', () => { + const res = getInstallCommandForPlatform({ + platform: 'windows', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "$ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip -Proxy \\"http://download-src-proxy:2222\\" + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip + cd elastic-agent--windows-x86_64 + .\\\\elastic-agent.exe install \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --fleet-server-port=8220 \` + --proxy-url=http://es-proxy:1111" + `); + }); + + it('should return the correct command if proxies are set for rpm', () => { + const res = getInstallCommandForPlatform({ + platform: 'rpm', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--x86_64.rpm --proxy http://download-src-proxy:2222 + sudo rpm -vi elastic-agent--x86_64.rpm + sudo elastic-agent enroll \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 + sudo systemctl enable elastic-agent + sudo systemctl start elastic-agent" + `); + }); + + it('should return the correct command if proxies are set for deb', () => { + const res = getInstallCommandForPlatform({ + platform: 'deb', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--amd64.deb --proxy http://download-src-proxy:2222 + sudo dpkg -i elastic-agent--amd64.deb + sudo elastic-agent enroll \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 + sudo systemctl enable elastic-agent + sudo systemctl start elastic-agent" + `); + }); + }); + + describe('with full proxy settings', () => { + it('should return the correct command if proxies are set for linux', () => { + const res = getInstallCommandForPlatform({ + platform: 'linux', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + proxy_headers: { + 'X-Forwarded-For': 'forwarded-value', + 'test-header': 'test-value', + }, + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + proxy_headers: { + 'Accept-Language': 'en-US,en;q=0.5', + 'second-header': 'second-value', + }, + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz --proxy http://download-src-proxy:2222 --proxy-header \\"Accept-Language=en-US,en;q=0.5\\" --proxy-header \\"second-header=second-value\\" + tar xzvf elastic-agent--linux-x86_64.tar.gz + cd elastic-agent--linux-x86_64 + sudo ./elastic-agent install \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 \\\\ + --proxy-header=\\"X-Forwarded-For=forwarded-value\\" \\\\ + --proxy-header=\\"test-header=test-value\\"" + `); + }); + + it('should return the correct command if proxies are set for mac', () => { + const res = getInstallCommandForPlatform({ + platform: 'mac', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + proxy_headers: { + 'X-Forwarded-For': 'forwarded-value', + 'test-header': 'test-value', + }, + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + proxy_headers: { + 'Accept-Language': 'en-US,en;q=0.5', + 'second-header': 'second-value', + }, + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--darwin-aarch64.tar.gz --proxy http://download-src-proxy:2222 --proxy-header \\"Accept-Language=en-US,en;q=0.5\\" --proxy-header \\"second-header=second-value\\" + tar xzvf elastic-agent--darwin-aarch64.tar.gz + cd elastic-agent--darwin-aarch64 + sudo ./elastic-agent install \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 \\\\ + --proxy-header=\\"X-Forwarded-For=forwarded-value\\" \\\\ + --proxy-header=\\"test-header=test-value\\"" + `); + }); + + it('should return the correct command if proxies are set for windows', () => { + const res = getInstallCommandForPlatform({ + platform: 'windows', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + proxy_headers: { + 'X-Forwarded-For': 'forwarded-value', + 'test-header': 'test-value', + }, + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + proxy_headers: { + 'Accept-Language': 'en-US,en;q=0.5', + 'second-header': 'second-value', + }, + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "$ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip -Proxy \\"http://download-src-proxy:2222\\" -Headers @{\\"Accept-Language\\"=\\"en-US,en;q=0.5\\"; \\"second-header\\"=\\"second-value\\"} + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip + cd elastic-agent--windows-x86_64 + .\\\\elastic-agent.exe install \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --fleet-server-port=8220 \` + --proxy-url=http://es-proxy:1111 \` + --proxy-header=\\"X-Forwarded-For=forwarded-value\\" \` + --proxy-header=\\"test-header=test-value\\"" + `); + }); + + it('should return the correct command if proxies are set for rpm', () => { + const res = getInstallCommandForPlatform({ + platform: 'rpm', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + proxy_headers: { + 'X-Forwarded-For': 'forwarded-value', + 'test-header': 'test-value', + }, + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + proxy_headers: { + 'Accept-Language': 'en-US,en;q=0.5', + 'second-header': 'second-value', + }, + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--x86_64.rpm --proxy http://download-src-proxy:2222 --proxy-header \\"Accept-Language=en-US,en;q=0.5\\" --proxy-header \\"second-header=second-value\\" + sudo rpm -vi elastic-agent--x86_64.rpm + sudo elastic-agent enroll \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 \\\\ + --proxy-header=\\"X-Forwarded-For=forwarded-value\\" \\\\ + --proxy-header=\\"test-header=test-value\\" + sudo systemctl enable elastic-agent + sudo systemctl start elastic-agent" + `); + }); + + it('should return the correct command if proxies are set for deb', () => { + const res = getInstallCommandForPlatform({ + platform: 'deb', + esOutputHost: 'http://elasticsearch:9200', + esOutputProxy: { + id: 'es-proxy', + name: 'es-proxy', + url: 'http://es-proxy:1111', + proxy_headers: { + 'X-Forwarded-For': 'forwarded-value', + 'test-header': 'test-value', + }, + is_preconfigured: false, + }, + serviceToken: 'service-token-1', + policyId: 'policy-1', + fleetServerHost: 'http://fleetserver:8220', + downloadSource: { + id: 'download-src', + name: 'download-src', + host: 'https://download-src/8.12.0-test/', + is_default: false, + proxy_id: 'download-proxy', + }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'http://download-src-proxy:2222', + proxy_headers: { + 'Accept-Language': 'en-US,en;q=0.5', + 'second-header': 'second-value', + }, + is_preconfigured: false, + }, + }); + + expect(res).toMatchInlineSnapshot(` + "curl -L -O https://download-src/8.12.0-test/beats/elastic-agent/elastic-agent--amd64.deb --proxy http://download-src-proxy:2222 --proxy-header \\"Accept-Language=en-US,en;q=0.5\\" --proxy-header \\"second-header=second-value\\" + sudo dpkg -i elastic-agent--amd64.deb + sudo elastic-agent enroll \\\\ + --fleet-server-es=http://elasticsearch:9200 \\\\ + --fleet-server-service-token=service-token-1 \\\\ + --fleet-server-policy=policy-1 \\\\ + --fleet-server-port=8220 \\\\ + --proxy-url=http://es-proxy:1111 \\\\ + --proxy-header=\\"X-Forwarded-For=forwarded-value\\" \\\\ + --proxy-header=\\"test-header=test-value\\" + sudo systemctl enable elastic-agent + sudo systemctl start elastic-agent" + `); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts index 79809b94470e4..5656e49de4bc9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts @@ -5,7 +5,11 @@ * 2.0. */ -import type { DownloadSource } from '../../../../../../common/types'; +import type { DownloadSource, FleetProxy } from '../../../../../../common/types'; +import { + getDownloadBaseUrl, + getDownloadSourceProxyArgs, +} from '../../../../../components/enrollment_instructions/manual'; import type { PLATFORM_TYPE } from '../../../hooks'; export type CommandsByPlatform = { @@ -15,27 +19,31 @@ export type CommandsByPlatform = { function getArtifact( platform: PLATFORM_TYPE, kibanaVersion: string, - downloadSource?: DownloadSource + downloadSource?: DownloadSource, + downloadSourceProxy?: FleetProxy ) { - const ARTIFACT_BASE_URL = `${ - downloadSource - ? downloadSource.host.endsWith('/') - ? downloadSource.host.substring(0, downloadSource.host.length - 1) - : downloadSource.host - : 'https://artifacts.elastic.co/downloads' - }/beats/elastic-agent`; + const ARTIFACT_BASE_URL = `${getDownloadBaseUrl(downloadSource)}/beats/elastic-agent`; + const { windows: windowsDownloadSourceProxyArgs, curl: curlDownloadSourceProxyArgs } = + getDownloadSourceProxyArgs(downloadSourceProxy); + + const appendWindowsDownloadSourceProxyArgs = windowsDownloadSourceProxyArgs + ? ` ${windowsDownloadSourceProxyArgs}` + : ''; + const appendCurlDownloadSourceProxyArgs = curlDownloadSourceProxyArgs + ? ` ${curlDownloadSourceProxyArgs}` + : ''; const artifactMap: Record<PLATFORM_TYPE, { downloadCommand: string }> = { linux: { downloadCommand: [ - `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, + `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz${appendCurlDownloadSourceProxyArgs}`, `tar xzvf elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, `cd elastic-agent-${kibanaVersion}-linux-x86_64`, ].join(`\n`), }, mac: { downloadCommand: [ - `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-darwin-aarch64.tar.gz`, + `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-darwin-aarch64.tar.gz${appendCurlDownloadSourceProxyArgs}`, `tar xzvf elastic-agent-${kibanaVersion}-darwin-aarch64.tar.gz`, `cd elastic-agent-${kibanaVersion}-darwin-aarch64`, ].join(`\n`), @@ -43,20 +51,20 @@ function getArtifact( windows: { downloadCommand: [ `$ProgressPreference = 'SilentlyContinue'`, - `Invoke-WebRequest -Uri https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip`, + `Invoke-WebRequest -Uri ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip${appendWindowsDownloadSourceProxyArgs}`, `Expand-Archive .\\elastic-agent-${kibanaVersion}-windows-x86_64.zip`, `cd elastic-agent-${kibanaVersion}-windows-x86_64`, ].join(`\n`), }, deb: { downloadCommand: [ - `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-amd64.deb`, + `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-amd64.deb${appendCurlDownloadSourceProxyArgs}`, `sudo dpkg -i elastic-agent-${kibanaVersion}-amd64.deb`, ].join(`\n`), }, rpm: { downloadCommand: [ - `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-x86_64.rpm`, + `curl -L -O ${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-x86_64.rpm${appendCurlDownloadSourceProxyArgs}`, `sudo rpm -vi elastic-agent-${kibanaVersion}-x86_64.rpm`, ].join(`\n`), }, @@ -68,20 +76,34 @@ function getArtifact( return artifactMap[platform]; } -export function getInstallCommandForPlatform( - platform: PLATFORM_TYPE, - esHost: string, - serviceToken: string, - policyId?: string, - fleetServerHost?: string, - isProductionDeployment?: boolean, - sslCATrustedFingerprint?: string, - kibanaVersion?: string, - downloadSource?: DownloadSource -): string { +export function getInstallCommandForPlatform({ + platform, + esOutputHost, + esOutputProxy, + serviceToken, + policyId, + fleetServerHost, + isProductionDeployment, + sslCATrustedFingerprint, + kibanaVersion, + downloadSource, + downloadSourceProxy, +}: { + platform: PLATFORM_TYPE; + esOutputHost: string; + esOutputProxy?: FleetProxy | undefined; + serviceToken: string; + policyId?: string; + fleetServerHost?: string; + isProductionDeployment?: boolean; + sslCATrustedFingerprint?: string; + kibanaVersion?: string; + downloadSource?: DownloadSource; + downloadSourceProxy?: FleetProxy; +}): string { const newLineSeparator = platform === 'windows' ? '`\n' : '\\\n'; - const artifact = getArtifact(platform, kibanaVersion ?? '', downloadSource); + const artifact = getArtifact(platform, kibanaVersion ?? '', downloadSource, downloadSourceProxy); const commandArguments = []; @@ -89,7 +111,7 @@ export function getInstallCommandForPlatform( commandArguments.push(['url', fleetServerHost]); } - commandArguments.push(['fleet-server-es', esHost]); + commandArguments.push(['fleet-server-es', esOutputHost]); commandArguments.push(['fleet-server-service-token', serviceToken]); if (policyId) { commandArguments.push(['fleet-server-policy', policyId]); @@ -110,7 +132,15 @@ export function getInstallCommandForPlatform( commandArguments.push(['fleet-server-port', '8220']); - const commandArgumentsStr = commandArguments + const enrollmentProxyArgs = []; + if (esOutputProxy) { + enrollmentProxyArgs.push(['proxy-url', esOutputProxy.url]); + Object.entries(esOutputProxy.proxy_headers || []).forEach(([key, value]) => { + enrollmentProxyArgs.push(['proxy-header', `"${key}=${value}"`]); + }); + } + + const commandArgumentsStr = [...commandArguments, ...enrollmentProxyArgs] .reduce((acc, [key, val]) => { if (acc === '' && key === 'url') { return `--${key}=${val}`; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx index 0b9a6f6e85830..2802ac1319b15 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/page_steps/install_agent/install_agent_standalone.tsx @@ -52,7 +52,7 @@ export const InstallElasticAgentStandalonePageStep: React.FC<InstallAgentPagePro ); } - const installManagedCommands = StandaloneInstructions(kibanaVersion); + const installManagedCommands = StandaloneInstructions({ agentVersion: kibanaVersion }); const steps = [ ConfigureStandaloneAgentStep({ diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index 1b4e6565efc01..5ccdf37951703 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -77,8 +77,13 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<FlyOutProps> = ({ const { agentPolicyWithPackagePolicies } = useAgentPolicyWithPackagePolicies(selectedPolicyId); - const { fleetServerHost, fleetProxy, downloadSource, isLoadingInitialRequest } = - useFleetServerHostsForPolicy(agentPolicyWithPackagePolicies); + const { + fleetServerHost, + fleetProxy, + downloadSource, + isLoadingInitialRequest, + downloadSourceProxy, + } = useFleetServerHostsForPolicy(agentPolicyWithPackagePolicies); const selectedPolicy = agentPolicyWithPackagePolicies ? agentPolicyWithPackagePolicies @@ -196,6 +201,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<FlyOutProps> = ({ fleetServerHost={fleetServerHost} fleetProxy={fleetProxy} downloadSource={downloadSource} + downloadSourceProxy={downloadSourceProxy} setSelectedPolicyId={setSelectedPolicyId} agentPolicy={agentPolicy} selectedPolicy={selectedPolicy} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx index ea31b163fb368..660aafa339b47 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/compute_steps.tsx @@ -53,6 +53,8 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ setSelectedAPIKeyId, isK8s, cloudSecurityIntegration, + downloadSource, + downloadSourceProxy, }) => { const { yaml, onCreateApiKey, isCreatingApiKey, apiKey, downloadYaml } = useFetchFullPolicy( selectedPolicy, @@ -62,7 +64,11 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ const agentVersion = useAgentVersion(); const instructionsSteps = useMemo(() => { - const standaloneInstallCommands = StandaloneInstructions(agentVersion || ''); + const standaloneInstallCommands = StandaloneInstructions({ + agentVersion: agentVersion || '', + downloadSource, + downloadSourceProxy, + }); const steps: EuiContainedStepProps[] = !agentPolicy ? [ @@ -107,6 +113,8 @@ export const StandaloneSteps: React.FunctionComponent<InstructionProps> = ({ return steps; }, [ agentVersion, + downloadSource, + downloadSourceProxy, agentPolicy, selectedPolicy, agentPolicies, @@ -143,6 +151,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({ fleetServerHost, fleetProxy, downloadSource, + downloadSourceProxy, refreshAgentPolicies, mode, setMode, @@ -173,6 +182,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({ fleetServerHost, fleetProxy, downloadSource, + downloadSourceProxy, agentVersion: agentVersion || '', gcpProjectId, gcpOrganizationId, diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index 0eefe62229193..a58feeda65617 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -83,4 +83,5 @@ export interface InstructionProps extends BaseProps { fleetServerHost: string; fleetProxy?: FleetProxy; downloadSource?: DownloadSource; + downloadSourceProxy?: FleetProxy; } diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx index e5733983dd754..654b3a782649c 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_DOWNLOAD_SOURCE_URI } from '../../../../common/constants'; import type { DownloadSource, FleetProxy } from '../../../types'; function getfleetServerHostsEnrollArgs( @@ -14,7 +15,7 @@ function getfleetServerHostsEnrollArgs( ) { const proxyHeadersArgs = fleetProxy?.proxy_headers ? Object.entries(fleetProxy.proxy_headers).reduce((acc, [proxyKey, proyVal]) => { - acc += ` --proxy-header ${proxyKey}=${proyVal}`; + acc += ` --proxy-header "${proxyKey}=${proyVal}"`; return acc; }, '') @@ -23,11 +24,45 @@ function getfleetServerHostsEnrollArgs( return `--url=${fleetServerHost || `FLEET_SERVER_HOST`} --enrollment-token=${apiKey}${proxyArgs}`; } +export const getDownloadBaseUrl = (downloadSource?: DownloadSource) => { + const source = downloadSource?.host || DEFAULT_DOWNLOAD_SOURCE_URI; + return source.endsWith('/') ? source.substring(0, source.length - 1) : source; +}; + +export const getDownloadSourceProxyArgs = (downloadSourceProxy?: FleetProxy) => { + const windows = `${downloadSourceProxy?.url ? `-Proxy "${downloadSourceProxy.url}"` : ''} ${ + downloadSourceProxy?.proxy_headers + ? `-Headers @{${Object.entries(downloadSourceProxy.proxy_headers) + .reduce((acc, [proxyKey, proyVal]) => { + acc.push(`"${proxyKey}"="${proyVal}"`); + return acc; + }, [] as string[]) + .join('; ')}}` + : '' + }`.trim(); + const curl = `${downloadSourceProxy?.url ? `--proxy ${downloadSourceProxy.url}` : ''} ${ + downloadSourceProxy?.proxy_headers + ? Object.entries(downloadSourceProxy.proxy_headers) + .reduce((acc, [proxyKey, proyVal]) => { + acc.push(`--proxy-header "${proxyKey}=${proyVal}"`); + return acc; + }, [] as string[]) + .join(' ') + : '' + }`.trim(); + + return { + windows, + curl, + }; +}; + export const ManualInstructions = ({ apiKey, fleetServerHost, fleetProxy, downloadSource, + downloadSourceProxy, agentVersion: agentVersion, gcpProjectId = '<PROJECT_ID>', gcpOrganizationId = '<ORGANIZATION_ID>', @@ -37,44 +72,44 @@ export const ManualInstructions = ({ fleetServerHost: string; fleetProxy?: FleetProxy; downloadSource?: DownloadSource; + downloadSourceProxy?: FleetProxy; agentVersion: string; gcpProjectId?: string; gcpOrganizationId?: string; gcpAccountType?: string; }) => { const enrollArgs = getfleetServerHostsEnrollArgs(apiKey, fleetServerHost, fleetProxy); - const downloadBaseUrl = downloadSource - ? downloadSource.host.endsWith('/') - ? downloadSource.host.substring(0, downloadSource.host.length - 1) - : downloadSource.host - : 'https://artifacts.elastic.co/downloads'; + const downloadBaseUrl = getDownloadBaseUrl(downloadSource); const fleetServerUrl = enrollArgs?.split('--url=')?.pop()?.split('--enrollment')[0]; const enrollmentToken = enrollArgs?.split('--enrollment-token=')[1]; const k8sCommand = 'kubectl apply -f elastic-agent-managed-kubernetes.yml'; - const linuxCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-linux-x86_64.tar.gz + const { windows: windowsDownloadSourceProxyArgs, curl: curlDownloadSourceProxyArgs } = + getDownloadSourceProxyArgs(downloadSourceProxy); + + const linuxCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-linux-x86_64.tar.gz ${curlDownloadSourceProxyArgs} tar xzvf elastic-agent-${agentVersion}-linux-x86_64.tar.gz cd elastic-agent-${agentVersion}-linux-x86_64 sudo ./elastic-agent install ${enrollArgs}`; - const macCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-darwin-aarch64.tar.gz + const macCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-darwin-aarch64.tar.gz ${curlDownloadSourceProxyArgs} tar xzvf elastic-agent-${agentVersion}-darwin-aarch64.tar.gz cd elastic-agent-${agentVersion}-darwin-aarch64 sudo ./elastic-agent install ${enrollArgs}`; const windowsCommand = `$ProgressPreference = 'SilentlyContinue' -Invoke-WebRequest -Uri ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-windows-x86_64.zip -OutFile elastic-agent-${agentVersion}-windows-x86_64.zip +Invoke-WebRequest -Uri ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-windows-x86_64.zip -OutFile elastic-agent-${agentVersion}-windows-x86_64.zip ${windowsDownloadSourceProxyArgs} Expand-Archive .\\elastic-agent-${agentVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${agentVersion}-windows-x86_64 .\\elastic-agent.exe install ${enrollArgs}`; - const linuxDebCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-amd64.deb + const linuxDebCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-amd64.deb ${curlDownloadSourceProxyArgs} sudo dpkg -i elastic-agent-${agentVersion}-amd64.deb sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`; - const linuxRpmCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-x86_64.rpm + const linuxRpmCommand = `curl -L -O ${downloadBaseUrl}/beats/elastic-agent/elastic-agent-${agentVersion}-x86_64.rpm ${curlDownloadSourceProxyArgs} sudo rpm -vi elastic-agent-${agentVersion}-x86_64.rpm sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`; diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx index 21221fdaba79f..dd8eafec86ec4 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx @@ -4,27 +4,42 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { CommandsByPlatform } from '../../../applications/fleet/components/fleet_server_instructions/utils/install_command_utils'; +import type { DownloadSource, FleetProxy } from '../../../types'; +import { getDownloadBaseUrl, getDownloadSourceProxyArgs } from '../manual'; + +export const StandaloneInstructions = ({ + agentVersion, + downloadSource, + downloadSourceProxy, +}: { + agentVersion: string; + downloadSource?: DownloadSource; + downloadSourceProxy?: FleetProxy; +}): CommandsByPlatform => { + const downloadBaseUrl = getDownloadBaseUrl(downloadSource); + const { windows: windowsDownloadSourceProxyArgs, curl: curlDownloadSourceProxyArgs } = + getDownloadSourceProxyArgs(downloadSourceProxy); -export const StandaloneInstructions = (agentVersion: string): CommandsByPlatform => { - const linuxDebCommand = `curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-amd64.deb + const linuxDebCommand = `curl -L -O ${downloadBaseUrl}/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-amd64.deb ${curlDownloadSourceProxyArgs} sudo dpkg -i elastic-agent-${agentVersion}-amd64.deb \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`; - const linuxRpmCommand = `curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-x86_64.rpm + const linuxRpmCommand = `curl -L -O ${downloadBaseUrl}/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-x86_64.rpm ${curlDownloadSourceProxyArgs} sudo rpm -vi elastic-agent-${agentVersion}-x86_64.rpm \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`; - const linuxCommand = `curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-linux-x86_64.tar.gz + const linuxCommand = `curl -L -O ${downloadBaseUrl}/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-linux-x86_64.tar.gz ${curlDownloadSourceProxyArgs} tar xzvf elastic-agent-${agentVersion}-linux-x86_64.tar.gz cd elastic-agent-${agentVersion}-linux-x86_64 sudo ./elastic-agent install`; - const macCommand = `curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-darwin-aarch64.tar.gz + const macCommand = `curl -L -O ${downloadBaseUrl}/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-darwin-aarch64.tar.gz ${curlDownloadSourceProxyArgs} tar xzvf elastic-agent-${agentVersion}-darwin-aarch64.tar.gz cd elastic-agent-${agentVersion}-darwin-aarch64 sudo ./elastic-agent install`; const windowsCommand = `$ProgressPreference = 'SilentlyContinue' -Invoke-WebRequest -Uri https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-windows-x86_64.zip -OutFile elastic-agent-${agentVersion}-windows-x86_64.zip +Invoke-WebRequest -Uri ${downloadBaseUrl}/downloads/beats/elastic-agent/elastic-agent-${agentVersion}-windows-x86_64.zip -OutFile elastic-agent-${agentVersion}-windows-x86_64.zip ${windowsDownloadSourceProxyArgs} Expand-Archive .\elastic-agent-${agentVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${agentVersion}-windows-x86_64 .\\elastic-agent.exe install`; diff --git a/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.test.tsx b/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.test.tsx index 9e6c8ab5c62b1..6f9c96d130ebd 100644 --- a/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.test.tsx +++ b/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.test.tsx @@ -42,6 +42,23 @@ describe('useFleetServerHostsForPolicy', () => { is_preconfigured: false, }, has_active: true, + es_output: { + id: 'es-output', + name: 'es-output', + is_default: false, + is_default_monitoring: false, + type: 'elasticsearch', + hosts: ['https://elasticsearch:9200'], + }, + es_output_proxy: { + id: 'es-output-proxy', + name: 'es-output-proxy', + url: 'https://es-output-proxy', + proxy_headers: { + 'header-key': 'header-value', + }, + is_preconfigured: false, + }, }, download_source: { id: 'default-source', @@ -49,6 +66,15 @@ describe('useFleetServerHostsForPolicy', () => { host: 'https://defaultsource', is_default: false, }, + download_source_proxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'https://download-src-proxy', + proxy_headers: { + 'header-key': 'header-value', + }, + is_preconfigured: false, + }, }, }); }); @@ -64,12 +90,38 @@ describe('useFleetServerHostsForPolicy', () => { url: 'https://defaultproxy', is_preconfigured: false, }, + esOutput: { + id: 'es-output', + name: 'es-output', + is_default: false, + is_default_monitoring: false, + type: 'elasticsearch', + hosts: ['https://elasticsearch:9200'], + }, + esOutputProxy: { + id: 'es-output-proxy', + name: 'es-output-proxy', + url: 'https://es-output-proxy', + proxy_headers: { + 'header-key': 'header-value', + }, + is_preconfigured: false, + }, downloadSource: { id: 'default-source', name: 'default-source', host: 'https://defaultsource', is_default: false, }, + downloadSourceProxy: { + id: 'download-src-proxy', + name: 'download-src-proxy', + url: 'https://download-src-proxy', + proxy_headers: { + 'header-key': 'header-value', + }, + is_preconfigured: false, + }, }); }); }); diff --git a/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.ts b/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.ts index 8ca0d8a6871c3..12b8523f13028 100644 --- a/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.ts +++ b/x-pack/plugins/fleet/public/hooks/use_fleet_server_hosts_for_policy.ts @@ -14,7 +14,7 @@ import { useGetEnrollmentSettings } from './use_request'; /** * Return Fleet server hosts urls and proxy for a given agent policy */ -export function useFleetServerHostsForPolicy(agentPolicy?: AgentPolicy | null) { +export function useFleetServerHostsForPolicy(agentPolicy?: Pick<AgentPolicy, 'id'> | null) { const { isLoading, isInitialRequest, @@ -26,14 +26,20 @@ export function useFleetServerHostsForPolicy(agentPolicy?: AgentPolicy | null) { isLoadingInitialRequest: isLoading && isInitialRequest, fleetServerHost: enrollmentSettings?.fleet_server.host?.host_urls[0] || '', fleetProxy: enrollmentSettings?.fleet_server.host_proxy, + esOutput: enrollmentSettings?.fleet_server.es_output, + esOutputProxy: enrollmentSettings?.fleet_server.es_output_proxy, downloadSource: enrollmentSettings?.download_source, + downloadSourceProxy: enrollmentSettings?.download_source_proxy, }), [ isLoading, isInitialRequest, enrollmentSettings?.fleet_server.host, enrollmentSettings?.fleet_server.host_proxy, + enrollmentSettings?.fleet_server.es_output, + enrollmentSettings?.fleet_server.es_output_proxy, enrollmentSettings?.download_source, + enrollmentSettings?.download_source_proxy, ] ); } diff --git a/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts b/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts index 73a7d03a14592..69bf95207f82c 100644 --- a/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts +++ b/x-pack/plugins/fleet/server/routes/settings/enrollment_settings_handler.ts @@ -22,6 +22,7 @@ import { agentPolicyService, appContextService, downloadSourceService } from '.. import { getFleetServerHostsForAgentPolicy } from '../../services/fleet_server_host'; import { getFleetProxy } from '../../services/fleet_proxies'; import { getFleetServerPolicies, hasFleetServersForPolicies } from '../../services/fleet_server'; +import { getDataOutputForAgentPolicy } from '../../services/agent_policies'; export const getEnrollmentSettingsHandler: FleetRequestHandler< undefined, @@ -46,6 +47,7 @@ export const getEnrollmentSettingsHandler: FleetRequestHandler< name: undefined, fleet_server_host_id: undefined, download_source_id: undefined, + data_output_id: undefined, }; // Check if there is any active fleet server enrolled into the fleet server policies policies if (fleetServerPolicies) { @@ -69,6 +71,19 @@ export const getEnrollmentSettingsHandler: FleetRequestHandler< settingsResponse.download_source = undefined; } + // Get download source proxy + // ignore errors if the download source proxy is not found + try { + if (settingsResponse.download_source?.proxy_id) { + settingsResponse.download_source_proxy = await getFleetProxy( + soClient, + settingsResponse.download_source.proxy_id + ); + } + } catch (e) { + settingsResponse.download_source_proxy = undefined; + } + // Get associated fleet server host, or default one if it doesn't exist // `getFleetServerHostsForAgentPolicy` errors if there is no default, so catch it try { @@ -80,7 +95,7 @@ export const getEnrollmentSettingsHandler: FleetRequestHandler< settingsResponse.fleet_server.host = undefined; } - // if a fleet server host was found, get associated fleet server host proxy if any + // If a fleet server host was found, get associated fleet server host proxy if any // ignore errors if the proxy is not found try { if (settingsResponse.fleet_server.host?.proxy_id) { @@ -93,6 +108,25 @@ export const getEnrollmentSettingsHandler: FleetRequestHandler< settingsResponse.fleet_server.host_proxy = undefined; } + // Get associated output and proxy (if any) to use for Fleet Server enrollment + try { + if (settingsResponse.fleet_server.policies.length > 0) { + const dataOutput = await getDataOutputForAgentPolicy(soClient, scopedAgentPolicy); + if (dataOutput.type === 'elasticsearch' && dataOutput.hosts?.[0]) { + settingsResponse.fleet_server.es_output = dataOutput; + if (dataOutput.proxy_id) { + settingsResponse.fleet_server.es_output_proxy = await getFleetProxy( + soClient, + dataOutput.proxy_id + ); + } + } + } + } catch (e) { + settingsResponse.fleet_server.es_output = undefined; + settingsResponse.fleet_server.es_output_proxy = undefined; + } + return response.ok({ body: settingsResponse }); } catch (error) { return defaultFleetErrorHandler({ error, response }); @@ -115,6 +149,7 @@ export const getFleetServerOrAgentPolicies = async ( fleet_server_host_id: policy.fleet_server_host_id, download_source_id: policy.download_source_id, space_ids: policy.space_ids, + data_output_id: policy.data_output_id, }); // If an agent policy is specified, return only that policy diff --git a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts index 6553d2e976bed..0adaa69f1d30a 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -9,6 +9,10 @@ import { schema } from '@kbn/config-schema'; import { isDiffPathProtocol } from '../../../common/services'; +import { OutputSchema } from '../models'; + +import { FleetProxySchema } from './fleet_proxies'; + export const GetSettingsRequestSchema = {}; export const PutSettingsRequestSchema = { @@ -101,6 +105,7 @@ export const GetEnrollmentSettingsResponseSchema = schema.object({ fleet_server_host_id: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), download_source_id: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), space_ids: schema.maybe(schema.arrayOf(schema.string())), + data_output_id: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), }) ), has_active: schema.boolean(), @@ -115,25 +120,9 @@ export const GetEnrollmentSettingsResponseSchema = schema.object({ proxy_id: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), }) ), - host_proxy: schema.maybe( - schema.object({ - id: schema.string(), - proxy_headers: schema.maybe( - schema.recordOf( - schema.string(), - schema.oneOf([schema.string(), schema.number(), schema.boolean()]) - ) - ), - name: schema.string(), - url: schema.string(), - certificate_authorities: schema.maybe( - schema.oneOf([schema.literal(null), schema.string()]) - ), - certificate: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), - certificate_key: schema.maybe(schema.oneOf([schema.literal(null), schema.string()])), - is_preconfigured: schema.boolean(), - }) - ), + host_proxy: schema.maybe(FleetProxySchema), + es_output: schema.maybe(OutputSchema), + es_output_proxy: schema.maybe(FleetProxySchema), }), download_source: schema.maybe( schema.object({ @@ -154,4 +143,5 @@ export const GetEnrollmentSettingsResponseSchema = schema.object({ ), }) ), + download_source_proxy: schema.maybe(FleetProxySchema), }); diff --git a/x-pack/test/fleet_api_integration/apis/settings/enrollment.ts b/x-pack/test/fleet_api_integration/apis/settings/enrollment.ts index fc464ce0e3ded..d69051b6140a9 100644 --- a/x-pack/test/fleet_api_integration/apis/settings/enrollment.ts +++ b/x-pack/test/fleet_api_integration/apis/settings/enrollment.ts @@ -18,11 +18,8 @@ export default function (providerContext: FtrProviderContext) { describe('Enrollment settings - get', function () { skipIfNoDockerRegistry(providerContext); - before(async () => { - await fleetAndAgents.setup(); - }); - it('should respond with empty enrollment settings on empty cluster', async function () { + await fleetAndAgents.setup(); const response = await supertest .get(`/internal/fleet/settings/enrollment`) .set('kbn-xsrf', 'xxxx') @@ -51,6 +48,7 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .send({ force: true }) .expect(200); + await fleetAndAgents.setup(); }); after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/fleet_server'); @@ -92,12 +90,25 @@ export default function (providerContext: FtrProviderContext) { host_proxy: { id: 'my-proxy', name: 'my proxy', + proxy_headers: { + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.5', + }, url: 'https://my-proxy', certificate: '', certificate_authorities: '', certificate_key: '', is_preconfigured: false, }, + es_output: { + hosts: ['http://localhost:9200'], + id: 'fleet-default-output', + is_default: true, + is_default_monitoring: true, + name: 'default', + preset: 'balanced', + type: 'elasticsearch', + }, }, download_source: { id: 'fleet-default-download-source', @@ -137,12 +148,25 @@ export default function (providerContext: FtrProviderContext) { host_proxy: { id: 'my-proxy', name: 'my proxy', + proxy_headers: { + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.5', + }, url: 'https://my-proxy', certificate: '', certificate_authorities: '', certificate_key: '', is_preconfigured: false, }, + es_output: { + hosts: ['http://localhost:9200'], + id: 'fleet-default-output', + is_default: true, + is_default_monitoring: true, + name: 'default', + preset: 'balanced', + type: 'elasticsearch', + }, }, download_source: { id: 'fleet-default-download-source', @@ -178,6 +202,19 @@ export default function (providerContext: FtrProviderContext) { host: 'https://localhost:2222', proxy_id: 'my-proxy', }, + download_source_proxy: { + certificate: '', + certificate_authorities: '', + certificate_key: '', + id: 'my-proxy', + is_preconfigured: false, + name: 'my proxy', + proxy_headers: { + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.5', + }, + url: 'https://my-proxy', + }, }); }); }); diff --git a/x-pack/test/functional/es_archives/fleet/fleet_server/data.json b/x-pack/test/functional/es_archives/fleet/fleet_server/data.json index a3729885c13e9..32e3735981802 100644 --- a/x-pack/test/functional/es_archives/fleet/fleet_server/data.json +++ b/x-pack/test/functional/es_archives/fleet/fleet_server/data.json @@ -435,12 +435,11 @@ "certificate_key": "", "is_preconfigured": false, "name": "my proxy", - "proxy_headers": null, + "proxy_headers": "{\"Accept-Language\":\"en-US,en;q=0.5\",\"Accept-Encoding\":\"gzip, deflate, br\"}", "url": "https://my-proxy" }, "managed": false, - "references": [ - ], + "references": [], "type": "fleet-proxy", "updated_at": "2024-04-22T22:07:16.226Z" } From 9d671f694bd584ff9ad5b75532aea9f2b6bc1c92 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:11:34 -0500 Subject: [PATCH 08/11] [Security Solution][Document Details] Add an advanced setting for visualizations in flyout in ESS (#194012) ## Summary This PR replaces a feature flag `visualizationInFlyoutEnabled` with advanced setting `securitySolution:enableVisualizationsInFlyout`. ![image](https://github.com/user-attachments/assets/3ddf00d8-d641-44ae-aca6-45a4c1bcbd7e) #### When advanced setting is off (DEFAULT): - Visualize tab should not be present in alert/event flyout - Analyzer and session preview links should go to timeline #### When advanced setting is on: - Visualize tab is present in alert/event flyout - The analyzer and session preview icon should open left panel ![image](https://github.com/user-attachments/assets/ca25538f-2f7c-4fc7-9081-473c6b9b3a5b) #### Some enhancements and fixes: - Clicking alerts in session viewer opens an alert preview - Upsell and no data messages are updated in session viewer to be consistent with session preview - Links in analyzer and session preview should be disabled in previews (`isPreviewMode`) - Links in analyzer and session preview should be disabled in rule preview (`isPreview`) https://github.com/user-attachments/assets/074166b8-3ce1-4488-9245-029b7dc55c59 ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../settings/setting_ids/index.ts | 2 + .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 6 + .../security_solution/common/constants.ts | 4 + .../common/experimental_features.ts | 5 - .../left/components/session_view.test.tsx | 53 ++++++- .../left/components/session_view.tsx | 68 ++++++++- .../flyout/document_details/left/index.tsx | 6 +- .../left/tabs/visualize_tab.tsx | 38 ++--- .../analyzer_preview_container.test.tsx | 112 ++++++++------- .../components/analyzer_preview_container.tsx | 58 ++------ .../session_preview_container.test.tsx | 96 +++++++++++-- .../components/session_preview_container.tsx | 92 +++++------- .../visualizations_section.test.tsx | 14 +- .../session_view_no_data_message.test.tsx | 61 ++++++++ .../session_view_no_data_message.tsx | 87 ++++++++++++ .../shared/components/test_ids.ts | 3 + .../hooks/use_navigate_to_analyzer.test.tsx | 106 ++++++++++++++ .../shared/hooks/use_navigate_to_analyzer.tsx | 133 ++++++++++++++++++ .../use_navigate_to_session_view.test.tsx | 81 +++++++++++ .../hooks/use_navigate_to_session_view.tsx | 104 ++++++++++++++ .../security_solution/server/ui_settings.ts | 19 +++ .../translations/translations/fr-FR.json | 5 - .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - 26 files changed, 953 insertions(+), 215 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx diff --git a/packages/kbn-management/settings/setting_ids/index.ts b/packages/kbn-management/settings/setting_ids/index.ts index 611a7a0e13df5..cb32dbd4a4505 100644 --- a/packages/kbn-management/settings/setting_ids/index.ts +++ b/packages/kbn-management/settings/setting_ids/index.ts @@ -187,6 +187,8 @@ export const SECURITY_SOLUTION_EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER = /** This Kibana Advanced Setting allows users to enable/disable the Asset Criticality feature */ export const SECURITY_SOLUTION_ENABLE_ASSET_CRITICALITY_SETTING = 'securitySolution:enableAssetCriticality' as const; +export const SECURITY_SOLUTION_ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING = + 'securitySolution:enableVisualizationsInFlyout' as const; // Timelion settings export const TIMELION_ES_DEFAULT_INDEX_ID = 'timelion:es.default_index'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 1c118620773ae..0ece5f004c23e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -130,6 +130,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'securitySolution:enableVisualizationsInFlyout': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'search:includeFrozen': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 71c692b6fdf34..ca1df3c95e87a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -76,6 +76,7 @@ export interface UsageStats { 'securitySolution:enableAssetCriticality': boolean; 'securitySolution:excludeColdAndFrozenTiersInAnalyzer': boolean; 'securitySolution:enableCcsWarning': boolean; + 'securitySolution:enableVisualizationsInFlyout': boolean; 'search:includeFrozen': boolean; 'courier:maxConcurrentShardRequests': number; 'courier:setRequestPreference': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 82e7666018782..f25c29e5f6952 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9919,6 +9919,12 @@ "description": "Non-default value of setting." } }, + "securitySolution:enableVisualizationsInFlyout":{ + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "search:includeFrozen": { "type": "boolean", "_meta": { diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e2b85fd123f91..f947369112033 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -199,6 +199,10 @@ export const ENABLE_ASSET_CRITICALITY_SETTING = 'securitySolution:enableAssetCri export const EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION = 'securitySolution:excludedDataTiersForRuleExecution' as const; +/** This Kibana Advanced Setting allows users to enable/disable the Visualizations in Flyout feature */ +export const ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING = + 'securitySolution:enableVisualizationsInFlyout' as const; + /** * Id for the notifications alerting type * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 88ca097e09b18..982b102abd93e 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -209,11 +209,6 @@ export const allowedExperimentalValues = Object.freeze({ */ analyzerDatePickersAndSourcererDisabled: false, - /** - * Enables visualization: session viewer and analyzer in expandable flyout - */ - visualizationInFlyoutEnabled: false, - /** * Enables an ability to customize Elastic prebuilt rules. * diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.test.tsx index cce77411d6c9f..29d12721c3ef7 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.test.tsx @@ -11,12 +11,35 @@ import '@testing-library/jest-dom'; import { DocumentDetailsContext } from '../../shared/context'; import { TestProviders } from '../../../../common/mock'; import { SESSION_VIEW_TEST_ID } from './test_ids'; +import { + SESSION_VIEW_UPSELL_TEST_ID, + SESSION_VIEW_NO_DATA_TEST_ID, +} from '../../shared/components/test_ids'; import { SessionView } from './session_view'; import { ANCESTOR_INDEX, ENTRY_LEADER_ENTITY_ID, ENTRY_LEADER_START, } from '../../shared/constants/field_names'; +import { useSessionPreview } from '../../right/hooks/use_session_preview'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { mockContextValue } from '../../shared/mocks/mock_context'; +import { useLicense } from '../../../../common/hooks/use_license'; + +jest.mock('../../right/hooks/use_session_preview'); +jest.mock('../../../../common/hooks/use_license'); +jest.mock('../../../../sourcerer/containers'); + +const NO_DATA_MESSAGE = + 'You can only view Linux session details if you’ve enabled the Include session data setting in your Elastic Defend integration policy. Refer to Enable Session View dataExternal link(opens in a new tab or window) for more information.'; + +const UPSELL_TEXT = 'This feature requires an Enterprise subscription'; + +const sessionViewConfig = { + index: {}, + sessionEntityId: 'sessionEntityId', + sessionStartTime: 'sessionStartTime', +}; interface MockData { [key: string]: string; @@ -46,7 +69,7 @@ jest.mock('../../../../common/lib/kibana', () => { }; }); -const renderSessionView = (contextValue: DocumentDetailsContext) => +const renderSessionView = (contextValue: DocumentDetailsContext = mockContextValue) => render( <TestProviders> <DocumentDetailsContext.Provider value={contextValue}> @@ -56,6 +79,19 @@ const renderSessionView = (contextValue: DocumentDetailsContext) => ); describe('<SessionView />', () => { + beforeEach(() => { + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + jest.mocked(useSourcererDataView).mockReturnValue({ + browserFields: {}, + dataViewId: '', + loading: false, + indicesExist: true, + selectedPatterns: ['index'], + indexPattern: { fields: [], title: '' }, + sourcererDataView: undefined, + }); + }); it('renders session view correctly', () => { const contextValue = { getFieldsData: mockFieldsData, @@ -75,4 +111,19 @@ describe('<SessionView />', () => { const wrapper = renderSessionView(contextValue); expect(wrapper.getByTestId(SESSION_VIEW_TEST_ID)).toBeInTheDocument(); }); + + it('should render upsell message in header if no correct license', () => { + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => false }); + + const { getByTestId } = renderSessionView(); + expect(getByTestId(SESSION_VIEW_UPSELL_TEST_ID)).toHaveTextContent(UPSELL_TEXT); + }); + + it('should render error message and text in header if no sessionConfig', () => { + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + (useSessionPreview as jest.Mock).mockReturnValue(null); + + const { getByTestId } = renderSessionView(); + expect(getByTestId(SESSION_VIEW_NO_DATA_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.tsx index fa44e0154b59f..38bf50a679ee2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/session_view.tsx @@ -6,7 +6,10 @@ */ import type { FC } from 'react'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { TableId } from '@kbn/securitysolution-data-table'; +import { EuiPanel } from '@elastic/eui'; import { ANCESTOR_INDEX, ENTRY_LEADER_ENTITY_ID, @@ -14,8 +17,17 @@ import { } from '../../shared/constants/field_names'; import { getField } from '../../shared/utils'; import { SESSION_VIEW_TEST_ID } from './test_ids'; +import { isActiveTimeline } from '../../../../helpers'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; import { useKibana } from '../../../../common/lib/kibana'; import { useDocumentDetailsContext } from '../../shared/context'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; +import { detectionsTimelineIds } from '../../../../timelines/containers/helpers'; +import { ALERT_PREVIEW_BANNER } from '../../preview/constants'; +import { useLicense } from '../../../../common/hooks/use_license'; +import { useSessionPreview } from '../../right/hooks/use_session_preview'; +import { SessionViewNoDataMessage } from '../../shared/components/session_view_no_data_message'; export const SESSION_VIEW_ID = 'session-view'; @@ -23,26 +35,70 @@ export const SESSION_VIEW_ID = 'session-view'; * Session view displayed in the document details expandable flyout left section under the Visualize tab */ export const SessionView: FC = () => { - const { sessionView } = useKibana().services; - const { getFieldsData, indexName } = useDocumentDetailsContext(); + const { sessionView, telemetry } = useKibana().services; + const { getFieldsData, indexName, scopeId, dataFormattedForFieldBrowser } = + useDocumentDetailsContext(); + + const sessionViewConfig = useSessionPreview({ getFieldsData, dataFormattedForFieldBrowser }); + const isEnterprisePlus = useLicense().isEnterprise(); + const isEnabled = sessionViewConfig && isEnterprisePlus; const ancestorIndex = getField(getFieldsData(ANCESTOR_INDEX)); // e.g in case of alert, we want to grab it's origin index const sessionEntityId = getField(getFieldsData(ENTRY_LEADER_ENTITY_ID)) || ''; const sessionStartTime = getField(getFieldsData(ENTRY_LEADER_START)) || ''; const index = ancestorIndex || indexName; - // TODO as part of https://github.com/elastic/security-team/issues/7031 - // bring back no data message if needed + const sourcererScope = useMemo(() => { + if (isActiveTimeline(scopeId)) { + return SourcererScopeName.timeline; + } else if (detectionsTimelineIds.includes(scopeId as TableId)) { + return SourcererScopeName.detections; + } else { + return SourcererScopeName.default; + } + }, [scopeId]); + + const { selectedPatterns } = useSourcererDataView(sourcererScope); + const eventDetailsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]); + + const { openPreviewPanel } = useExpandableFlyoutApi(); + const openAlertDetailsPreview = useCallback( + (eventId?: string, onClose?: () => void) => { + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + params: { + id: eventId, + indexName: eventDetailsIndex, + scopeId, + banner: ALERT_PREVIEW_BANNER, + isPreviewMode: true, + }, + }); + telemetry.reportDetailsFlyoutOpened({ + location: scopeId, + panel: 'preview', + }); + }, + [openPreviewPanel, eventDetailsIndex, scopeId, telemetry] + ); - return ( + return isEnabled ? ( <div data-test-subj={SESSION_VIEW_TEST_ID}> {sessionView.getSessionView({ index, sessionEntityId, sessionStartTime, isFullScreen: true, + loadAlertDetails: openAlertDetailsPreview, })} </div> + ) : ( + <EuiPanel hasShadow={false}> + <SessionViewNoDataMessage + isEnterprisePlus={isEnterprisePlus} + hasSessionViewConfig={sessionViewConfig !== null} + /> + </EuiPanel> ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx index 838209490f7d8..8e6b817e275e5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/index.tsx @@ -9,7 +9,9 @@ import type { FC } from 'react'; import React, { memo, useMemo } from 'react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../common/constants'; import { DocumentDetailsLeftPanelKey } from '../shared/constants/panel_keys'; import { useKibana } from '../../../common/lib/kibana'; import { PanelHeader } from './header'; @@ -38,8 +40,8 @@ export const LeftPanel: FC<Partial<DocumentDetailsProps>> = memo(({ path }) => { 'securitySolutionNotesEnabled' ); - const visualizationInFlyoutEnabled = useIsExperimentalFeatureEnabled( - 'visualizationInFlyoutEnabled' + const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING ); const tabsDisplayed = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx index 031273c3e0892..0dad444ee6ece 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx @@ -13,11 +13,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useDocumentDetailsContext } from '../../shared/context'; import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { - DocumentDetailsLeftPanelKey, - DocumentDetailsAnalyzerPanelKey, -} from '../../shared/constants/panel_keys'; -import { LeftPanelVisualizeTab } from '..'; +import { DocumentDetailsAnalyzerPanelKey } from '../../shared/constants/panel_keys'; import { VISUALIZE_TAB_BUTTON_GROUP_TEST_ID, VISUALIZE_TAB_GRAPH_ANALYZER_BUTTON_TEST_ID, @@ -59,8 +55,8 @@ const visualizeButtons: EuiButtonGroupOptionProps[] = [ * Visualize view displayed in the document details expandable flyout left section */ export const VisualizeTab = memo(() => { - const { eventId, indexName, scopeId } = useDocumentDetailsContext(); - const { openLeftPanel, openPreviewPanel } = useExpandableFlyoutApi(); + const { scopeId } = useDocumentDetailsContext(); + const { openPreviewPanel } = useExpandableFlyoutApi(); const panels = useExpandableFlyoutState(); const [activeVisualizationId, setActiveVisualizationId] = useState( panels.left?.path?.subTab ?? SESSION_VIEW_ID @@ -72,28 +68,16 @@ export const VisualizeTab = memo(() => { setActiveVisualizationId(optionId); if (optionId === ANALYZE_GRAPH_ID) { startTransaction({ name: ALERTS_ACTIONS.OPEN_ANALYZER }); + openPreviewPanel({ + id: DocumentDetailsAnalyzerPanelKey, + params: { + resolverComponentInstanceID: `${key}-${scopeId}`, + banner: ANALYZER_PREVIEW_BANNER, + }, + }); } - openLeftPanel({ - id: DocumentDetailsLeftPanelKey, - path: { - tab: LeftPanelVisualizeTab, - subTab: optionId, - }, - params: { - id: eventId, - indexName, - scopeId, - }, - }); - openPreviewPanel({ - id: DocumentDetailsAnalyzerPanelKey, - params: { - resolverComponentInstanceID: `${key}-${scopeId}`, - banner: ANALYZER_PREVIEW_BANNER, - }, - }); }, - [startTransaction, eventId, indexName, scopeId, openLeftPanel, openPreviewPanel, key] + [startTransaction, openPreviewPanel, key, scopeId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx index 3908aef1eb6b9..9c743f2b1bc9d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx @@ -8,7 +8,6 @@ import { render, screen } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import React from 'react'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { DocumentDetailsContext } from '../../shared/context'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { AnalyzerPreviewContainer } from './analyzer_preview_container'; @@ -23,18 +22,8 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '@kbn/security-solution-common'; -import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { - DocumentDetailsLeftPanelKey, - DocumentDetailsAnalyzerPanelKey, -} from '../../shared/constants/panel_keys'; -import { ANALYZE_GRAPH_ID, ANALYZER_PREVIEW_BANNER } from '../../left/components/analyze_graph'; -jest.mock('@kbn/expandable-flyout'); jest.mock( '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver' ); @@ -42,9 +31,19 @@ jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree'); jest.mock( '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline' ); -jest.mock('../../shared/hooks/use_which_flyout'); -const mockUseWhichFlyout = useWhichFlyout as jest.Mock; -const FLYOUT_KEY = 'securitySolution'; + +const mockNavigateToAnalyzer = jest.fn(); +jest.mock('../../shared/hooks/use_navigate_to_analyzer', () => { + return { useNavigateToAnalyzer: () => ({ navigateToAnalyzer: mockNavigateToAnalyzer }) }; +}); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); @@ -59,18 +58,19 @@ jest.mock('react-redux', () => { }; }); -jest.mock('../../../../common/hooks/use_experimental_features'); -const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; +const mockUseUiSetting = jest.fn().mockReturnValue([false]); +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); const NO_ANALYZER_MESSAGE = 'You can only visualize events triggered by hosts configured with the Elastic Defend integration or any sysmon data from winlogbeat. Refer to Visual event analyzerExternal link(opens in a new tab or window) for more information.'; -const panelContextValue = { - ...mockContextValue, - dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, -}; - -const renderAnalyzerPreview = (context = panelContextValue) => +const renderAnalyzerPreview = (context = mockContextValue) => render( <TestProviders> <DocumentDetailsContext.Provider value={context}> @@ -82,9 +82,6 @@ const renderAnalyzerPreview = (context = panelContextValue) => describe('AnalyzerPreviewContainer', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); - jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); - mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); }); it('should render component and link in header', () => { @@ -170,7 +167,27 @@ describe('AnalyzerPreviewContainer', () => { investigateInTimelineAlertClick: jest.fn(), }); - const { queryByTestId } = renderAnalyzerPreview({ ...panelContextValue, isPreview: true }); + const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreview: true }); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + const { investigateInTimelineAlertClick } = useInvestigateInTimeline({}); + expect(investigateInTimelineAlertClick).not.toHaveBeenCalled(); + }); + + it('should not navigate to analyzer when in preview mode', () => { + (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); + (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ + loading: false, + error: false, + alertIds: ['alertid'], + statsNodes: mock.mockStatsNodes, + }); + (useInvestigateInTimeline as jest.Mock).mockReturnValue({ + investigateInTimelineAlertClick: jest.fn(), + }); + + const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreviewMode: true }); expect( queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)) ).not.toBeInTheDocument(); @@ -181,7 +198,7 @@ describe('AnalyzerPreviewContainer', () => { describe('when visualizationInFlyoutEnabled is enabled', () => { it('should open left flyout visualization tab when clicking on title', () => { - mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + mockUseUiSetting.mockReturnValue([true]); (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ @@ -197,29 +214,30 @@ describe('AnalyzerPreviewContainer', () => { const { getByTestId } = renderAnalyzerPreview(); getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click(); - expect(mockFlyoutApi.openLeftPanel).toHaveBeenCalledWith({ - id: DocumentDetailsLeftPanelKey, - path: { - tab: 'visualize', - subTab: ANALYZE_GRAPH_ID, - }, - params: { - id: mockContextValue.eventId, - indexName: mockContextValue.indexName, - scopeId: mockContextValue.scopeId, - }, + expect(mockNavigateToAnalyzer).toHaveBeenCalled(); + }); + + it('should disable link when in rule preview', () => { + mockUseUiSetting.mockReturnValue([true]); + (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); + (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ + loading: false, + error: false, + alertIds: ['alertid'], + statsNodes: mock.mockStatsNodes, }); - expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ - id: DocumentDetailsAnalyzerPanelKey, - params: { - resolverComponentInstanceID: `${FLYOUT_KEY}-${mockContextValue.scopeId}`, - banner: ANALYZER_PREVIEW_BANNER, - }, + (useInvestigateInTimeline as jest.Mock).mockReturnValue({ + investigateInTimelineAlertClick: jest.fn(), }); + + const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreview: true }); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); }); - it('should not disable link when in rule preview', () => { - mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + it('should disable link when in preview mode', () => { + mockUseUiSetting.mockReturnValue([true]); (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ loading: false, @@ -231,7 +249,7 @@ describe('AnalyzerPreviewContainer', () => { investigateInTimelineAlertClick: jest.fn(), }); - const { queryByTestId } = renderAnalyzerPreview({ ...panelContextValue, isPreview: true }); + const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreviewMode: true }); expect( queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)) ).not.toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx index ab8b7d8cec668..6896f15ca88cb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx @@ -11,14 +11,8 @@ import { TimelineTabs } from '@kbn/securitysolution-data-table'; import { EuiLink, EuiMark } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { ExpandablePanel } from '@kbn/security-solution-common'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { - DocumentDetailsLeftPanelKey, - DocumentDetailsAnalyzerPanelKey, -} from '../../shared/constants/panel_keys'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions'; @@ -28,7 +22,7 @@ import { useDocumentDetailsContext } from '../../shared/context'; import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; import { AnalyzerPreview } from './analyzer_preview'; import { ANALYZER_PREVIEW_TEST_ID } from './test_ids'; -import { ANALYZE_GRAPH_ID, ANALYZER_PREVIEW_BANNER } from '../../left/components/analyze_graph'; +import { useNavigateToAnalyzer } from '../../shared/hooks/use_navigate_to_analyzer'; const timelineId = 'timeline-1'; @@ -36,14 +30,11 @@ const timelineId = 'timeline-1'; * Analyzer preview under Overview, Visualizations. It shows a tree representation of analyzer. */ export const AnalyzerPreviewContainer: React.FC = () => { - const { telemetry } = useKibana().services; - const { dataAsNestedObject, isPreview, eventId, indexName, scopeId } = + const { dataAsNestedObject, isPreview, eventId, indexName, scopeId, isPreviewMode } = useDocumentDetailsContext(); - const { openLeftPanel, openPreviewPanel } = useExpandableFlyoutApi(); - const key = useWhichFlyout() ?? 'memory'; - const visualizationInFlyoutEnabled = useIsExperimentalFeatureEnabled( - 'visualizationInFlyoutEnabled' + const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING ); // decide whether to show the analyzer preview or not const isEnabled = useIsInvestigateInResolverActionEnabled(dataAsNestedObject); @@ -70,32 +61,12 @@ export const AnalyzerPreviewContainer: React.FC = () => { dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); }, [dataAsNestedObject, dispatch, investigateInTimelineAlertClick, startTransaction]); - const gotoVisualizationTab = useCallback(() => { - openLeftPanel({ - id: DocumentDetailsLeftPanelKey, - path: { - tab: 'visualize', - subTab: ANALYZE_GRAPH_ID, - }, - params: { - id: eventId, - indexName, - scopeId, - }, - }); - openPreviewPanel({ - id: DocumentDetailsAnalyzerPanelKey, - params: { - resolverComponentInstanceID: `${key}-${scopeId}`, - banner: ANALYZER_PREVIEW_BANNER, - }, - }); - telemetry.reportDetailsFlyoutTabClicked({ - location: scopeId, - panel: 'left', - tabId: 'visualize', - }); - }, [eventId, indexName, openLeftPanel, openPreviewPanel, key, scopeId, telemetry]); + const { navigateToAnalyzer } = useNavigateToAnalyzer({ + eventId, + indexName, + isFlyoutOpen: true, + scopeId, + }); return ( <ExpandablePanel @@ -108,9 +79,10 @@ export const AnalyzerPreviewContainer: React.FC = () => { ), iconType: visualizationInFlyoutEnabled ? 'arrowStart' : 'timeline', ...(isEnabled && - !isPreview && { + !isPreview && + !isPreviewMode && { link: { - callback: visualizationInFlyoutEnabled ? gotoVisualizationTab : goToAnalyzerTab, + callback: visualizationInFlyoutEnabled ? navigateToAnalyzer : goToAnalyzerTab, tooltip: visualizationInFlyoutEnabled ? ( <FormattedMessage id="xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip" diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx index 26bbdcebe22f1..4d4c20787ce1a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx @@ -20,27 +20,37 @@ import { EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID, EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID, } from '@kbn/security-solution-common'; -import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; +import { mockContextValue } from '../../shared/mocks/mock_context'; jest.mock('../hooks/use_session_preview'); jest.mock('../../../../common/hooks/use_license'); +const mockNavigateToSessionView = jest.fn(); +jest.mock('../../shared/hooks/use_navigate_to_session_view', () => { + return { useNavigateToSessionView: () => ({ navigateToSessionView: mockNavigateToSessionView }) }; +}); + +const mockUseUiSetting = jest.fn().mockReturnValue([false]); +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); + const NO_DATA_MESSAGE = 'You can only view Linux session details if you’ve enabled the Include session data setting in your Elastic Defend integration policy. Refer to Enable Session View dataExternal link(opens in a new tab or window) for more information.'; const UPSELL_TEXT = 'This feature requires an Enterprise subscription'; -const panelContextValue = { - getFieldsData: mockGetFieldsData, -} as unknown as DocumentDetailsContext; - const sessionViewConfig = { index: {}, sessionEntityId: 'sessionEntityId', sessionStartTime: 'sessionStartTime', }; -const renderSessionPreview = (context = panelContextValue) => +const renderSessionPreview = (context = mockContextValue) => render( <TestProviders> <DocumentDetailsContext.Provider value={context}> @@ -50,7 +60,7 @@ const renderSessionPreview = (context = panelContextValue) => ); describe('SessionPreviewContainer', () => { - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); @@ -127,7 +137,7 @@ describe('SessionPreviewContainer', () => { (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); const { getByTestId, queryByTestId } = renderSessionPreview({ - ...panelContextValue, + ...mockContextValue, isPreview: true, }); @@ -148,4 +158,74 @@ describe('SessionPreviewContainer', () => { queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) ).not.toBeInTheDocument(); }); + + it('should not render link to session viewer if flyout is open in preview mode', () => { + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + + const { getByTestId, queryByTestId } = renderSessionPreview({ + ...mockContextValue, + isPreviewMode: true, + }); + + expect(getByTestId(SESSION_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + }); + + describe('when visualization in flyout flag is enabled', () => { + it('should open left panel vizualization tab when visualization in flyout flag is on', () => { + mockUseUiSetting.mockReturnValue([true]); + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + + const { getByTestId } = renderSessionPreview(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)).click(); + + expect(mockNavigateToSessionView).toHaveBeenCalled(); + }); + + it('should not render link to session viewer if flyout is open in rule preview', () => { + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + + const { getByTestId, queryByTestId } = renderSessionPreview({ + ...mockContextValue, + isPreview: true, + }); + + expect(getByTestId(SESSION_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + }); + + it('should not render link to session viewer if flyout is open in preview mode', () => { + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + + const { getByTestId, queryByTestId } = renderSessionPreview({ + ...mockContextValue, + isPreview: true, + }); + + expect(getByTestId(SESSION_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx index aa97b904c1381..974c74b995393 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx @@ -8,10 +8,10 @@ import React, { type FC, useCallback } from 'react'; import { TimelineTabs } from '@kbn/securitysolution-data-table'; import { useDispatch } from 'react-redux'; -import { EuiLink, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/css'; import { ExpandablePanel } from '@kbn/security-solution-common'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useLicense } from '../../../../common/hooks/use_license'; import { SessionPreview } from './session_preview'; import { useSessionPreview } from '../hooks/use_session_preview'; @@ -22,6 +22,8 @@ import { SESSION_PREVIEW_TEST_ID } from './test_ids'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; import { setActiveTabTimeline } from '../../../../timelines/store/actions'; import { getScopedActions } from '../../../../helpers'; +import { useNavigateToSessionView } from '../../shared/hooks/use_navigate_to_session_view'; +import { SessionViewNoDataMessage } from '../../shared/components/session_view_no_data_message'; const timelineId = 'timeline-1'; @@ -29,8 +31,20 @@ const timelineId = 'timeline-1'; * Checks if the SessionView component is available, if so render it or else render an error message */ export const SessionPreviewContainer: FC = () => { - const { dataAsNestedObject, getFieldsData, isPreview, dataFormattedForFieldBrowser } = - useDocumentDetailsContext(); + const { + eventId, + indexName, + scopeId, + dataAsNestedObject, + getFieldsData, + isPreview, + isPreviewMode, + dataFormattedForFieldBrowser, + } = useDocumentDetailsContext(); + + const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); // decide whether to show the session view or not const sessionViewConfig = useSessionPreview({ getFieldsData, dataFormattedForFieldBrowser }); @@ -64,54 +78,12 @@ export const SessionPreviewContainer: FC = () => { startTransaction, ]); - const { euiTheme } = useEuiTheme(); - - const noSessionMessage = !isEnterprisePlus ? ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellDescription" - defaultMessage="This feature requires an {subscription}" - values={{ - subscription: ( - <EuiLink href="https://www.elastic.co/pricing/" target="_blank"> - <FormattedMessage - id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellLinkText" - defaultMessage="Enterprise subscription" - /> - </EuiLink> - ), - }} - /> - ) : !sessionViewConfig ? ( - <FormattedMessage - id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataDescription" - defaultMessage="You can only view Linux session details if you’ve enabled the {setting} setting in your Elastic Defend integration policy. Refer to {link} for more information." - values={{ - setting: ( - <span - css={css` - font-weight: ${euiTheme.font.weight.bold}; - `} - > - <FormattedMessage - id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataSettingDescription" - defaultMessage="Include session data" - /> - </span> - ), - link: ( - <EuiLink - href="https://www.elastic.co/guide/en/security/current/session-view.html#enable-session-view" - target="_blank" - > - <FormattedMessage - id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataLinkText" - defaultMessage="Enable Session View data" - /> - </EuiLink> - ), - }} - /> - ) : null; + const { navigateToSessionView } = useNavigateToSessionView({ + eventId, + indexName, + isFlyoutOpen: true, + scopeId, + }); return ( <ExpandablePanel @@ -122,11 +94,12 @@ export const SessionPreviewContainer: FC = () => { defaultMessage="Session viewer preview" /> ), - iconType: 'timeline', + iconType: visualizationInFlyoutEnabled ? 'arrowStart' : 'timeline', ...(isEnabled && - !isPreview && { + !isPreview && + !isPreviewMode && { link: { - callback: goToSessionViewTab, + callback: visualizationInFlyoutEnabled ? navigateToSessionView : goToSessionViewTab, tooltip: ( <FormattedMessage id="xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTooltip" @@ -138,7 +111,14 @@ export const SessionPreviewContainer: FC = () => { }} data-test-subj={SESSION_PREVIEW_TEST_ID} > - {isEnabled ? <SessionPreview /> : noSessionMessage} + {isEnabled ? ( + <SessionPreview /> + ) : ( + <SessionViewNoDataMessage + isEnterprisePlus={isEnterprisePlus} + hasSessionViewConfig={sessionViewConfig !== null} + /> + )} </ExpandablePanel> ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx index c62824a529e1a..9af61e21fb67d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx @@ -24,7 +24,6 @@ import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { useExpandSection } from '../hooks/use_expand_section'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; jest.mock('../hooks/use_expand_section'); jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ @@ -32,9 +31,6 @@ jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ })); const mockUseAlertPrevalenceFromProcessTree = useAlertPrevalenceFromProcessTree as jest.Mock; -jest.mock('../../../../common/hooks/use_experimental_features'); -const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; - jest.mock('../../../../timelines/containers/use_timeline_data_filters', () => ({ useTimelineDataFilters: jest.fn(), })); @@ -54,6 +50,15 @@ jest.mock( '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver' ); +const mockUseUiSetting = jest.fn().mockReturnValue([false]); +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); + const panelContextValue = { ...mockContextValue, dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, @@ -72,7 +77,6 @@ const renderVisualizationsSection = (contextValue = panelContextValue) => describe('<VisualizationsSection />', () => { beforeEach(() => { - mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] }); mockUseAlertPrevalenceFromProcessTree.mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.test.tsx new file mode 100644 index 0000000000000..49f0056c50c0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { TestProviders } from '../../../../common/mock'; +import { SESSION_VIEW_UPSELL_TEST_ID, SESSION_VIEW_NO_DATA_TEST_ID } from './test_ids'; +import { SessionViewNoDataMessage } from './session_view_no_data_message'; + +const NO_DATA_MESSAGE = + 'You can only view Linux session details if you’ve enabled the Include session data setting in your Elastic Defend integration policy. Refer to Enable Session View dataExternal link(opens in a new tab or window) for more information.'; + +const UPSELL_TEXT = 'This feature requires an Enterprise subscription'; + +const renderSessionViewNoDataMessage = ({ + isEnterprisePlus, + hasSessionViewConfig, +}: { + isEnterprisePlus: boolean; + hasSessionViewConfig: boolean; +}) => + render( + <TestProviders> + <SessionViewNoDataMessage + isEnterprisePlus={isEnterprisePlus} + hasSessionViewConfig={hasSessionViewConfig} + /> + </TestProviders> + ); + +describe('<SessionViewNoDataMessage />', () => { + it('renders license upsell message if license is not Enterprise', () => { + const { getByTestId } = renderSessionViewNoDataMessage({ + isEnterprisePlus: false, + hasSessionViewConfig: false, + }); + + expect(getByTestId(SESSION_VIEW_UPSELL_TEST_ID)).toHaveTextContent(UPSELL_TEXT); + }); + + it('renders no session view message if hasSessionViewConfig is false', () => { + const { getByTestId } = renderSessionViewNoDataMessage({ + isEnterprisePlus: true, + hasSessionViewConfig: false, + }); + expect(getByTestId(SESSION_VIEW_NO_DATA_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE); + }); + + it('renders null if neither is false', () => { + const { container } = renderSessionViewNoDataMessage({ + isEnterprisePlus: true, + hasSessionViewConfig: true, + }); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.tsx new file mode 100644 index 0000000000000..032240c853a8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/session_view_no_data_message.tsx @@ -0,0 +1,87 @@ +/* + * 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 { FC } from 'react'; +import React from 'react'; +import { EuiLink, useEuiTheme } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/css'; +import { SESSION_VIEW_UPSELL_TEST_ID, SESSION_VIEW_NO_DATA_TEST_ID } from './test_ids'; + +interface SessionViewNoDataMessageProps { + /** + * Whether the user has an Enterprise Plus license + */ + isEnterprisePlus: boolean; + /** + * Whether the user has Session View configuration + */ + hasSessionViewConfig: boolean; +} + +/** + * Message displayed when the user does not have access to Session View + */ +export const SessionViewNoDataMessage: FC<SessionViewNoDataMessageProps> = ({ + isEnterprisePlus, + hasSessionViewConfig, +}) => { + const { euiTheme } = useEuiTheme(); + + return !isEnterprisePlus ? ( + <div data-test-subj={SESSION_VIEW_UPSELL_TEST_ID}> + <FormattedMessage + id={'xpack.securitySolution.flyout.sessionViewer.upsellDescription'} + defaultMessage="This feature requires an {subscription}" + values={{ + subscription: ( + <EuiLink href="https://www.elastic.co/pricing/" target="_blank"> + <FormattedMessage + id={'xpack.securitySolution.flyout.sessionViewer.upsellLinkText'} + defaultMessage="Enterprise subscription" + /> + </EuiLink> + ), + }} + /> + </div> + ) : !hasSessionViewConfig ? ( + <div data-test-subj={SESSION_VIEW_NO_DATA_TEST_ID}> + <FormattedMessage + id={'xpack.securitySolution.flyout.sessionViewer.noDataDescription'} + defaultMessage="You can only view Linux session details if you’ve enabled the {setting} setting in your Elastic Defend integration policy. Refer to {link} for more information." + values={{ + setting: ( + <span + css={css` + font-weight: ${euiTheme.font.weight.bold}; + `} + > + <FormattedMessage + id={'xpack.securitySolution.flyout.sessionViewer.noDataSettingDescription'} + defaultMessage="Include session data" + /> + </span> + ), + link: ( + <EuiLink + href="https://www.elastic.co/guide/en/security/current/session-view.html#enable-session-view" + target="_blank" + > + <FormattedMessage + id={'xpack.securitySolution.flyout.sessionViewer.noDataLinkText'} + defaultMessage="Enable Session View data" + /> + </EuiLink> + ), + }} + /> + </div> + ) : null; +}; + +SessionViewNoDataMessage.displayName = 'SessionViewNoDataMessage'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts index 9939ca27886b7..8561df63d7199 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts @@ -9,3 +9,6 @@ import { PREFIX } from '../../../shared/test_ids'; export const FLYOUT_TOUR_TEST_ID = `${PREFIX}Tour` as const; export const FLYOUT_PREVIEW_LINK_TEST_ID = `${PREFIX}PreviewLink` as const; + +export const SESSION_VIEW_UPSELL_TEST_ID = `${PREFIX}SessionViewUpsell` as const; +export const SESSION_VIEW_NO_DATA_TEST_ID = `${PREFIX}SessionViewNoData` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx new file mode 100644 index 0000000000000..7ae0601d37ce9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useNavigateToAnalyzer } from './use_navigate_to_analyzer'; +import { mockFlyoutApi } from '../mocks/mock_flyout_context'; +import { useWhichFlyout } from './use_which_flyout'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../../../common/lib/kibana'; +import { + DocumentDetailsRightPanelKey, + DocumentDetailsLeftPanelKey, + DocumentDetailsAnalyzerPanelKey, +} from '../constants/panel_keys'; +import { ANALYZE_GRAPH_ID, ANALYZER_PREVIEW_BANNER } from '../../left/components/analyze_graph'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_which_flyout'); + +const mockedUseKibana = mockUseKibana(); +(useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + +const mockUseWhichFlyout = useWhichFlyout as jest.Mock; +const FLYOUT_KEY = 'securitySolution'; + +const eventId = 'eventId1'; +const indexName = 'index1'; +const scopeId = 'scopeId1'; + +describe('useNavigateToAnalyzer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + }); + + it('when isFlyoutOpen is true, should return callback that opens left and preview panels', () => { + const hookResult = renderHook(() => + useNavigateToAnalyzer({ isFlyoutOpen: true, eventId, indexName, scopeId }) + ); + hookResult.result.current.navigateToAnalyzer(); + + expect(mockFlyoutApi.openLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: ANALYZE_GRAPH_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + + expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({ + id: DocumentDetailsAnalyzerPanelKey, + params: { + resolverComponentInstanceID: `${FLYOUT_KEY}-${scopeId}`, + banner: ANALYZER_PREVIEW_BANNER, + }, + }); + }); + + it('when isFlyoutOpen is false, should return callback that opens a new flyout', () => { + const hookResult = renderHook(() => + useNavigateToAnalyzer({ isFlyoutOpen: false, eventId, indexName, scopeId }) + ); + hookResult.result.current.navigateToAnalyzer(); + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: ANALYZE_GRAPH_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + preview: { + id: DocumentDetailsAnalyzerPanelKey, + params: { + resolverComponentInstanceID: `${FLYOUT_KEY}-${scopeId}`, + banner: ANALYZER_PREVIEW_BANNER, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx new file mode 100644 index 0000000000000..448f2c081c946 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx @@ -0,0 +1,133 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { Maybe } from '@kbn/timelines-plugin/common/search_strategy/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useWhichFlyout } from './use_which_flyout'; +import { ANALYZE_GRAPH_ID, ANALYZER_PREVIEW_BANNER } from '../../left/components/analyze_graph'; +import { + DocumentDetailsLeftPanelKey, + DocumentDetailsRightPanelKey, + DocumentDetailsAnalyzerPanelKey, +} from '../constants/panel_keys'; + +export interface UseNavigateToAnalyzerParams { + /** + * When flyout is already open, call open left panel only + * When flyout is not open, open a new flyout + */ + isFlyoutOpen: boolean; + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: Maybe<string> | undefined; + /** + * Scope id of the page + */ + scopeId: string; +} + +export interface UseNavigateToAnalyzerResult { + /** + * Callback to open analyzer in visualize tab + */ + navigateToAnalyzer: () => void; +} + +/** + * Hook that returns the a callback to navigate to the analyzer in the flyout + */ +export const useNavigateToAnalyzer = ({ + isFlyoutOpen, + eventId, + indexName, + scopeId, +}: UseNavigateToAnalyzerParams): UseNavigateToAnalyzerResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openPreviewPanel, openFlyout } = useExpandableFlyoutApi(); + const key = useWhichFlyout() ?? 'memory'; + + const right: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId] + ); + + const left: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsLeftPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + path: { + tab: 'visualize', + subTab: ANALYZE_GRAPH_ID, + }, + }), + [eventId, indexName, scopeId] + ); + + const preview: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsAnalyzerPanelKey, + params: { + resolverComponentInstanceID: `${key}-${scopeId}`, + banner: ANALYZER_PREVIEW_BANNER, + }, + }), + [key, scopeId] + ); + + const navigateToAnalyzer = useCallback(() => { + if (isFlyoutOpen) { + openLeftPanel(left); + openPreviewPanel(preview); + telemetry.reportDetailsFlyoutTabClicked({ + location: scopeId, + panel: 'left', + tabId: 'visualize', + }); + } else { + openFlyout({ + right, + left, + preview, + }); + telemetry.reportDetailsFlyoutOpened({ + location: scopeId, + panel: 'left', + }); + } + }, [ + openFlyout, + openLeftPanel, + openPreviewPanel, + right, + left, + preview, + scopeId, + telemetry, + isFlyoutOpen, + ]); + + return useMemo(() => ({ navigateToAnalyzer }), [navigateToAnalyzer]); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx new file mode 100644 index 0000000000000..c0f85e07377df --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.test.tsx @@ -0,0 +1,81 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useNavigateToSessionView } from './use_navigate_to_session_view'; +import { mockFlyoutApi } from '../mocks/mock_flyout_context'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DocumentDetailsRightPanelKey, DocumentDetailsLeftPanelKey } from '../constants/panel_keys'; +import { SESSION_VIEW_ID } from '../../left/components/session_view'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/lib/kibana'); + +const mockedUseKibana = mockUseKibana(); +(useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + +const eventId = 'eventId1'; +const indexName = 'index1'; +const scopeId = 'scopeId1'; + +describe('useNavigateToSessionView', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + }); + + it('when isFlyoutOpen is true, should return callback that opens left panel', () => { + const hookResult = renderHook(() => + useNavigateToSessionView({ isFlyoutOpen: true, eventId, indexName, scopeId }) + ); + hookResult.result.current.navigateToSessionView(); + + expect(mockFlyoutApi.openLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: SESSION_VIEW_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); + + it('when isFlyoutOpen is false, should return callback that opens a new flyout', () => { + const hookResult = renderHook(() => + useNavigateToSessionView({ isFlyoutOpen: false, eventId, indexName, scopeId }) + ); + hookResult.result.current.navigateToSessionView(); + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: SESSION_VIEW_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx new file mode 100644 index 0000000000000..b8234321217e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx @@ -0,0 +1,104 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { Maybe } from '@kbn/timelines-plugin/common/search_strategy/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { SESSION_VIEW_ID } from '../../left/components/session_view'; +import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey } from '../constants/panel_keys'; + +export interface UseNavigateToSessionViewParams { + /** + * When flyout is already open, call open left panel only + * When flyout is not open, open a new flyout + */ + isFlyoutOpen: boolean; + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: Maybe<string> | undefined; + /** + * Scope id of the page + */ + scopeId: string; +} + +export interface UseNavigateToSessionViewResult { + /** + * Callback to open session view in visualize tab + */ + navigateToSessionView: () => void; +} + +/** + * Hook that returns the a callback to navigate to session view in the flyout + */ +export const useNavigateToSessionView = ({ + isFlyoutOpen, + eventId, + indexName, + scopeId, +}: UseNavigateToSessionViewParams): UseNavigateToSessionViewResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); + + const right: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId] + ); + + const left: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsLeftPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + path: { + tab: 'visualize', + subTab: SESSION_VIEW_ID, + }, + }), + [eventId, indexName, scopeId] + ); + + const navigateToSessionView = useCallback(() => { + if (isFlyoutOpen) { + openLeftPanel(left); + telemetry.reportDetailsFlyoutTabClicked({ + location: scopeId, + panel: 'left', + tabId: 'visualize', + }); + } else { + openFlyout({ + right, + left, + }); + telemetry.reportDetailsFlyoutOpened({ + location: scopeId, + panel: 'left', + }); + } + }, [openFlyout, openLeftPanel, right, left, scopeId, telemetry, isFlyoutOpen]); + + return useMemo(() => ({ navigateToSessionView }), [navigateToSessionView]); +}; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index d3f382538eeca..36b6a5a6582c8 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -39,6 +39,7 @@ import { EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER, EXCLUDED_DATA_TIERS_FOR_RULE_EXECUTION, ENABLE_ASSET_CRITICALITY_SETTING, + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING, } from '../common/constants'; import type { ExperimentalFeatures } from '../common/experimental_features'; import { LogLevelSetting } from '../common/api/detection_engine/rule_monitoring'; @@ -216,6 +217,24 @@ export const initUiSettings = ( requiresPageReload: true, schema: schema.boolean(), }, + [ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING]: { + name: i18n.translate('xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutLabel', { + defaultMessage: 'Enable visualizations in flyout', + }), + value: false, + description: i18n.translate( + 'xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutDescription', + { + defaultMessage: + '<em>[technical preview]</em> Enable visualizations (analyzer and session viewer) in flyout.', + values: { em: (chunks) => `<em>${chunks}</em>` }, + } + ), + type: 'boolean', + category: [APP_ID], + requiresPageReload: true, + schema: schema.boolean(), + }, [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { name: i18n.translate('xpack.securitySolution.uiSettings.rulesTableRefresh', { defaultMessage: 'Rules auto refresh', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 06b9b42e4b8b9..7601e3e48b1fe 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -39035,16 +39035,11 @@ "xpack.securitySolution.flyout.right.visualizations.assignees.popoverTooltip": "Assigner une alerte", "xpack.securitySolution.flyout.right.visualizations.sectionTitle": "Visualisations", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.commandDescription": "par", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataDescription": "Vous ne pouvez afficher les détails de la session Linux que si vous avez activé le paramètre {setting} dans votre politique d'intégration Elastic Defend. Pour en savoir plus, consultez {link}.", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataLinkText": "Activer les données de vue de session", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataSettingDescription": "Inclure les données de session", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.processDescription": "démarré", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.ruleDescription": "avec la règle", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTitle": "Aperçu du visualiseur de session", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTooltip": "Investiguer dans la chronologie", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.timeDescription": "à", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellDescription": "Cette fonctionnalité requiert un {subscription}", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellLinkText": "Abonnement Enterprise", "xpack.securitySolution.flyout.tour.entities.description": "Consultez la vue {entities} étendue pour en savoir plus sur les hôtes et les utilisateurs liés à l'alerte.", "xpack.securitySolution.flyout.tour.entities.text": "Entités", "xpack.securitySolution.flyout.tour.entities.title": "De nouvelles informations sur les hôtes et les utilisateurs sont disponibles", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f54abfcf29bb3..6be35ea91f84c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -38777,16 +38777,11 @@ "xpack.securitySolution.flyout.right.visualizations.assignees.popoverTooltip": "アラートの割り当て", "xpack.securitySolution.flyout.right.visualizations.sectionTitle": "ビジュアライゼーション", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.commandDescription": "グループ基準", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataDescription": "Linuxセッションの詳細を表示できるのは、Elastic Defendの統合ポリシーで{setting}設定を有効にしている場合のみです。詳細については、{link}を参照してください。", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataLinkText": "セッションビューデータを有効化", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataSettingDescription": "セッションデータを含める", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.processDescription": "開始済み", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.ruleDescription": "ルールがある", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTitle": "セッションビューアープレビュー", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTooltip": "タイムラインで調査", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.timeDescription": "に", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellDescription": "この機能には{subscription}が必要です", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellLinkText": "エンタープライズサブスクリプション", "xpack.securitySolution.flyout.tour.entities.description": "アラートに関連付けられたホストとユーザーの詳細については、展開された{entities}ビューを確認してください。", "xpack.securitySolution.flyout.tour.entities.text": "エンティティ", "xpack.securitySolution.flyout.tour.entities.title": "新しいホストとユーザーのインサイトがあります", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7899d8a68f04c..b06da5e8b9bd5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -38823,16 +38823,11 @@ "xpack.securitySolution.flyout.right.visualizations.assignees.popoverTooltip": "分配告警", "xpack.securitySolution.flyout.right.visualizations.sectionTitle": "可视化", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.commandDescription": "依据", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataDescription": "如果已在 Elastic Defend 集成策略中启用 {setting} 设置,则您只能查看 Linux 会话详情。请参阅 {link} 了解更多信息。", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataLinkText": "启用会话视图数据", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.noDataSettingDescription": "包括会话数据", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.processDescription": "已启动", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.ruleDescription": "具有规则", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTitle": "会话查看器预览", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.sessionPreviewTooltip": "在时间线中调查", "xpack.securitySolution.flyout.right.visualizations.sessionPreview.timeDescription": "处于", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellDescription": "此功能需要{subscription}", - "xpack.securitySolution.flyout.right.visualizations.sessionPreview.upsellLinkText": "企业级订阅", "xpack.securitySolution.flyout.tour.entities.description": "请查阅展开的 {entities} 视图以了解与该告警有关的主机和用户的更多信息。", "xpack.securitySolution.flyout.tour.entities.text": "实体", "xpack.securitySolution.flyout.tour.entities.title": "有新主机和用户洞见可用", From ca46f784e5185bbce503171e6432e960c94f2586 Mon Sep 17 00:00:00 2001 From: Philippe Oberti <philippe.oberti@elastic.co> Date: Tue, 1 Oct 2024 15:44:41 -0500 Subject: [PATCH 09/11] [Security Solution][Notes] - fetch notes by saved object ids (#193930) --- .../output/kibana.serverless.staging.yaml | 10 + oas_docs/output/kibana.staging.yaml | 10 + .../timeline/get_notes/get_notes_route.gen.ts | 4 + .../get_notes/get_notes_route.schema.yaml | 10 + ...imeline_api_2023_10_31.bundled.schema.yaml | 10 + ...imeline_api_2023_10_31.bundled.schema.yaml | 10 + .../public/common/mock/global_state.ts | 2 + .../left/components/add_note.tsx | 206 ----------- .../attach_to_active_timeline.test.tsx | 122 +++++++ .../components/attach_to_active_timeline.tsx | 133 +++++++ .../left/components/notes_details.test.tsx | 148 +++++++- .../left/components/notes_details.tsx | 104 +++++- .../left/components/notes_list.test.tsx | 307 ---------------- .../left/components/notes_list.tsx | 211 ----------- .../left/components/test_ids.ts | 10 +- .../security_solution/public/notes/api/api.ts | 14 + .../components/add_note.test.tsx | 93 +---- .../public/notes/components/add_note.tsx | 148 ++++++++ .../components/delete_note_button.test.tsx | 120 +++++++ .../notes/components/delete_note_button.tsx | 85 +++++ .../notes/components/notes_list.test.tsx | 145 ++++++++ .../public/notes/components/notes_list.tsx | 100 ++++++ .../components/open_flyout_button.test.tsx | 62 ++++ .../notes/components/open_flyout_button.tsx | 74 ++++ .../components/open_timeline_button.test.tsx | 61 ++++ .../notes/components/open_timeline_button.tsx | 59 ++++ .../public/notes/components/test_ids.ts | 19 + .../public/notes/store/notes.slice.test.ts | 179 +++++++++- .../public/notes/store/notes.slice.ts | 68 +++- .../actions/save_timeline_button.test.tsx | 21 ++ .../modal/actions/save_timeline_button.tsx | 109 +++--- .../timelines/components/notes/old_notes.tsx | 202 +++++++++++ .../components/notes/participants.test.tsx | 77 ++++ .../components/notes/participants.tsx | 144 ++++++++ .../components/notes/save_timeline.test.tsx | 43 +++ .../components/notes/save_timeline.tsx | 65 ++++ .../timelines/components/notes/test_ids.ts | 15 + .../note_previews/index.test.tsx | 4 +- .../components/timeline/tabs/index.tsx | 44 ++- .../timeline/tabs/notes/index.test.tsx | 221 ++++++++++++ .../components/timeline/tabs/notes/index.tsx | 334 +++++++++--------- .../query_tab_unified_components.test.tsx | 3 +- .../timeline/routes/notes/get_notes.test.ts | 121 ++++++- .../lib/timeline/routes/notes/get_notes.ts | 30 ++ 44 files changed, 2889 insertions(+), 1068 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx rename x-pack/plugins/security_solution/public/{flyout/document_details/left => notes}/components/add_note.test.tsx (57%) create mode 100644 x-pack/plugins/security_solution/public/notes/components/add_note.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/notes_list.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 621efdc026eea..8fd6d8c7be347 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -14921,6 +14921,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/Security_Timeline_API_DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/Security_Timeline_API_SavedObjectIds' - in: query name: page schema: @@ -31674,6 +31678,12 @@ components: - threat_match - zeek type: string + Security_Timeline_API_SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string Security_Timeline_API_SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 79960c7287336..eb25c65d433fa 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -18351,6 +18351,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/Security_Timeline_API_DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/Security_Timeline_API_SavedObjectIds' - in: query name: page schema: @@ -39683,6 +39687,12 @@ components: - threat_match - zeek type: string + Security_Timeline_API_SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string Security_Timeline_API_SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts index 5851b95d4d606..c4c48022f6512 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts @@ -21,6 +21,9 @@ import { Note } from '../model/components.gen'; export type DocumentIds = z.infer<typeof DocumentIds>; export const DocumentIds = z.union([z.array(z.string()), z.string()]); +export type SavedObjectIds = z.infer<typeof SavedObjectIds>; +export const SavedObjectIds = z.union([z.array(z.string()), z.string()]); + export type GetNotesResult = z.infer<typeof GetNotesResult>; export const GetNotesResult = z.object({ totalCount: z.number(), @@ -30,6 +33,7 @@ export const GetNotesResult = z.object({ export type GetNotesRequestQuery = z.infer<typeof GetNotesRequestQuery>; export const GetNotesRequestQuery = z.object({ documentIds: DocumentIds.optional(), + savedObjectIds: SavedObjectIds.optional(), page: z.string().nullable().optional(), perPage: z.string().nullable().optional(), search: z.string().nullable().optional(), diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml index 793eeac5e7c71..985e7728b7cc8 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml @@ -17,6 +17,10 @@ paths: in: query schema: $ref: '#/components/schemas/DocumentIds' + - name: savedObjectIds + in: query + schema: + $ref: '#/components/schemas/SavedObjectIds' - name: page in: query schema: @@ -65,6 +69,12 @@ components: items: type: string - type: string + SavedObjectIds: + oneOf: + - type: array + items: + type: string + - type: string GetNotesResult: type: object required: [totalCount, notes] diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index e36af57a2b3e9..68740efb388a4 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -63,6 +63,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/SavedObjectIds' - in: query name: page schema: @@ -1359,6 +1363,12 @@ components: - threat_match - zeek type: string + SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 818b83d7b85dd..cfcb36e2dee75 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -63,6 +63,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/SavedObjectIds' - in: query name: page schema: @@ -1359,6 +1363,12 @@ components: - threat_match - zeek type: string + SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index e3f9f19192a6b..16e1e7edf0eaa 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -527,12 +527,14 @@ export const mockGlobalState: State = { ids: ['1'], status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, createNote: null, deleteNotes: null, fetchNotes: null, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx deleted file mode 100644 index 5e4e390ac5077..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { - EuiButton, - EuiCheckbox, - EuiComment, - EuiCommentList, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiToolTip, - useEuiTheme, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { useDispatch, useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types'; -import { timelineSelectors } from '../../../../timelines/store'; -import { - ADD_NOTE_BUTTON_TEST_ID, - ADD_NOTE_MARKDOWN_TEST_ID, - ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, -} from './test_ids'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import type { State } from '../../../../common/store'; -import { - createNote, - ReqStatus, - selectCreateNoteError, - selectCreateNoteStatus, -} from '../../../../notes/store/notes.slice'; -import { MarkdownEditor } from '../../../../common/components/markdown_editor'; - -const timelineCheckBoxId = 'xpack.securitySolution.flyout.left.notes.attachToTimelineCheckboxId'; - -export const MARKDOWN_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.markdownAriaLabel', - { - defaultMessage: 'Note', - } -); -export const ADD_NOTE_BUTTON = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.addNoteBtnLabel', - { - defaultMessage: 'Add note', - } -); -export const CREATE_NOTE_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.createNoteErrorLabel', - { - defaultMessage: 'Error create note', - } -); -export const ATTACH_TO_TIMELINE_CHECKBOX = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.attachToTimelineCheckboxLabel', - { - defaultMessage: 'Attach to active timeline', - } -); -export const ATTACH_TO_TIMELINE_INFO = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.attachToTimelineInfoLabel', - { - defaultMessage: 'The active timeline must be saved before a note can be associated with it', - } -); - -export interface AddNewNoteProps { - /** - * Id of the document - */ - eventId: string; -} - -/** - * Renders a markdown editor and an add button to create new notes. - * The checkbox is automatically checked if the flyout is opened from a timeline and that timeline is saved. It is disabled if the flyout is NOT opened from a timeline. - */ -export const AddNote = memo(({ eventId }: AddNewNoteProps) => { - const { telemetry } = useKibana().services; - const { euiTheme } = useEuiTheme(); - const dispatch = useDispatch(); - const { addError: addErrorToast } = useAppToasts(); - const [editorValue, setEditorValue] = useState(''); - const [isMarkdownInvalid, setIsMarkdownInvalid] = useState(false); - - const activeTimeline = useSelector((state: State) => - timelineSelectors.selectTimelineById(state, TimelineId.active) - ); - - // if the flyout is open from a timeline and that timeline is saved, we automatically check the checkbox to associate the note to it - const isTimelineFlyout = useWhichFlyout() === Flyouts.timeline; - - const [checked, setChecked] = useState<boolean>(true); - const onCheckboxChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked), - [] - ); - - const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - const createError = useSelector((state: State) => selectCreateNoteError(state)); - - const addNote = useCallback(() => { - dispatch( - createNote({ - note: { - timelineId: (checked && activeTimeline?.savedObjectId) || '', - eventId, - note: editorValue, - }, - }) - ); - telemetry.reportAddNoteFromExpandableFlyoutClicked({ - isRelatedToATimeline: checked && activeTimeline?.savedObjectId !== null, - }); - setEditorValue(''); - }, [activeTimeline?.savedObjectId, checked, dispatch, editorValue, eventId, telemetry]); - - // show a toast if the create note call fails - useEffect(() => { - if (createStatus === ReqStatus.Failed && createError) { - addErrorToast(null, { - title: CREATE_NOTE_ERROR, - }); - } - }, [addErrorToast, createError, createStatus]); - - const buttonDisabled = useMemo( - () => editorValue.trim().length === 0 || isMarkdownInvalid, - [editorValue, isMarkdownInvalid] - ); - - const initialCheckboxChecked = useMemo( - () => isTimelineFlyout && activeTimeline.savedObjectId != null, - [activeTimeline?.savedObjectId, isTimelineFlyout] - ); - - const checkBoxDisabled = useMemo( - () => !isTimelineFlyout || (isTimelineFlyout && activeTimeline?.savedObjectId == null), - [activeTimeline?.savedObjectId, isTimelineFlyout] - ); - - return ( - <> - <EuiCommentList> - <EuiComment username=""> - <MarkdownEditor - dataTestSubj={ADD_NOTE_MARKDOWN_TEST_ID} - value={editorValue} - onChange={setEditorValue} - ariaLabel={MARKDOWN_ARIA_LABEL} - setIsMarkdownInvalid={setIsMarkdownInvalid} - /> - </EuiComment> - </EuiCommentList> - <EuiSpacer /> - <EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}> - <EuiFlexItem grow={false}> - <> - <EuiCheckbox - data-test-subj={ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID} - id={timelineCheckBoxId} - label={ - <> - {ATTACH_TO_TIMELINE_CHECKBOX} - <EuiToolTip position="top" content={ATTACH_TO_TIMELINE_INFO}> - <EuiIcon - type="iInCircle" - css={css` - margin-left: ${euiTheme.size.s}; - `} - /> - </EuiToolTip> - </> - } - disabled={checkBoxDisabled} - checked={initialCheckboxChecked && checked} - onChange={(e) => onCheckboxChange(e)} - /> - </> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - onClick={addNote} - isLoading={createStatus === ReqStatus.Loading} - disabled={buttonDisabled} - data-test-subj={ADD_NOTE_BUTTON_TEST_ID} - > - {ADD_NOTE_BUTTON} - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </> - ); -}); - -AddNote.displayName = 'AddNote'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx new file mode 100644 index 0000000000000..383750e05a006 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, + SAVE_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { AttachToActiveTimeline } from './attach_to_active_timeline'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types'; + +const mockSetAttachToTimeline = jest.fn(); + +describe('AttachToActiveTimeline', () => { + it('should render the component for an unsaved timeline', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, + }); + + const { getByTestId, getByText, queryByTestId } = render( + <TestProviders store={mockStore}> + <AttachToActiveTimeline + setAttachToTimeline={mockSetAttachToTimeline} + isCheckboxDisabled={false} + /> + </TestProviders> + ); + + expect(getByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); + + expect(getByText('Attach to timeline')).toBeInTheDocument(); + expect( + getByText('Before attaching a note to the timeline, you need to save the timeline first.') + ).toBeInTheDocument(); + + expect(getByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render the saved timeline texts in the callout', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, + }); + + const { getByTestId, getByText, queryByTestId } = render( + <TestProviders store={mockStore}> + <AttachToActiveTimeline + setAttachToTimeline={mockSetAttachToTimeline} + isCheckboxDisabled={false} + /> + </TestProviders> + ); + expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(getByText('Attach to timeline')).toBeInTheDocument(); + expect( + getByText('You can associate the newly created note to the active timeline.') + ).toBeInTheDocument(); + expect(getByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the callback when user click on the checkbox', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, + }); + + const { getByTestId } = render( + <TestProviders store={mockStore}> + <AttachToActiveTimeline + setAttachToTimeline={mockSetAttachToTimeline} + isCheckboxDisabled={false} + /> + </TestProviders> + ); + + const checkbox = getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID); + + checkbox.click(); + + expect(mockSetAttachToTimeline).toHaveBeenCalledWith(false); + + checkbox.click(); + + expect(mockSetAttachToTimeline).toHaveBeenCalledWith(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx new file mode 100644 index 0000000000000..278830da7e27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { EuiCallOut, EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { useSelector } from 'react-redux'; +import type { State } from '../../../../common/store'; +import { TimelineId } from '../../../../../common/types'; +import { SaveTimelineButton } from '../../../../timelines/components/modal/actions/save_timeline_button'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, + SAVE_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { timelineSelectors } from '../../../../timelines/store'; + +const timelineCheckBoxId = 'xpack.securitySolution.flyout.notes.attachToTimeline.checkboxId'; + +export const ATTACH_TO_TIMELINE_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutTitle', + { + defaultMessage: 'Attach to timeline', + } +); +export const SAVED_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutContent', + { + defaultMessage: 'You can associate the newly created note to the active timeline.', + } +); +export const UNSAVED_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutContent', + { + defaultMessage: 'Before attaching a note to the timeline, you need to save the timeline first.', + } +); +export const ATTACH_TO_TIMELINE_CHECKBOX = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.checkboxLabel', + { + defaultMessage: 'Attach to active timeline', + } +); +export const SAVE_TIMELINE_BUTTON = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.savedTimelineButtonLabel', + { + defaultMessage: 'Save timeline', + } +); + +export interface AttachToActiveTimelineProps { + /** + * Let the parent component know if the user wants to attach the note to the timeline + */ + setAttachToTimeline: (checked: boolean) => void; + /** + * Disables the checkbox (if timeline is not saved) + */ + isCheckboxDisabled: boolean; +} + +/** + * Renders a callout and a checkbox to allow the user to attach a timeline id to a note. + * If the active timeline is saved, the UI renders a checkbox to allow the user to attach the note to the timeline. + * If the active timeline is not saved, the UI renders a button that allows the user to to save the timeline directly from the flyout. + */ +export const AttachToActiveTimeline = memo( + ({ setAttachToTimeline, isCheckboxDisabled }: AttachToActiveTimelineProps) => { + const [checked, setChecked] = useState<boolean>(true); + + const timeline = useSelector((state: State) => + timelineSelectors.selectTimelineById(state, TimelineId.active) + ); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] + ); + + const onCheckboxChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setChecked(e.target.checked); + setAttachToTimeline(e.target.checked); + }, + [setAttachToTimeline] + ); + + return ( + <EuiCallOut + title={ATTACH_TO_TIMELINE_CALLOUT_TITLE} + color={'primary'} + iconType="iInCircle" + data-test-subj={ATTACH_TO_TIMELINE_CALLOUT_TEST_ID} + css={css` + margin-left: 50px; + `} + > + <EuiFlexGroup justifyContent="spaceBetween" responsive={false}> + <EuiFlexItem> + <EuiText size="s"> + {isTimelineSaved ? SAVED_TIMELINE_CALLOUT_CONTENT : UNSAVED_TIMELINE_CALLOUT_CONTENT} + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {isTimelineSaved ? ( + <EuiCheckbox + data-test-subj={ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID} + id={timelineCheckBoxId} + label={ATTACH_TO_TIMELINE_CHECKBOX} + disabled={isCheckboxDisabled} + checked={checked} + onChange={(e) => onCheckboxChange(e)} + /> + ) : ( + <SaveTimelineButton + timelineId={TimelineId.active} + buttonText={SAVE_TIMELINE_BUTTON} + data-test-subj={SAVE_TIMELINE_BUTTON_TEST_ID} + /> + )} + </EuiFlexItem> + </EuiFlexGroup> + </EuiCallOut> + ); + } +); + +AttachToActiveTimeline.displayName = 'AttachToActiveTimeline'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx index dbe09d7e23599..9426d604bce57 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx @@ -8,14 +8,34 @@ import { render } from '@testing-library/react'; import React from 'react'; import { DocumentDetailsContext } from '../../shared/context'; -import { TestProviders } from '../../../../common/mock'; -import { NotesDetails } from './notes_details'; -import { ADD_NOTE_BUTTON_TEST_ID } from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { FETCH_NOTES_ERROR, NO_NOTES, NotesDetails } from './notes_details'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { + ADD_NOTE_BUTTON_TEST_ID, + NOTES_LOADING_TEST_ID, +} from '../../../../notes/components/test_ids'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, +} from './test_ids'; +import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; +import { Flyouts } from '../../shared/constants/flyouts'; +import { TimelineId } from '../../../../../common/types'; +import { ReqStatus } from '../../../../notes'; + +jest.mock('../../shared/hooks/use_which_flyout'); jest.mock('../../../../common/components/user_privileges'); const useUserPrivilegesMock = useUserPrivileges as jest.Mock; +const mockAddError = jest.fn(); +jest.mock('../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + const mockDispatch = jest.fn(); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); @@ -28,6 +48,19 @@ jest.mock('react-redux', () => { const panelContextValue = { eventId: 'event id', } as unknown as DocumentDetailsContext; +const mockGlobalStateWithSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, +}; const renderNotesDetails = () => render( @@ -40,26 +73,121 @@ const renderNotesDetails = () => describe('NotesDetails', () => { beforeEach(() => { + jest.clearAllMocks(); useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, + kibanaSecuritySolutionsPrivileges: { crud: true }, }); + (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); }); it('should fetch notes for the document id', () => { - renderNotesDetails(); + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + render( + <TestProviders store={mockStore}> + <DocumentDetailsContext.Provider value={panelContextValue}> + <NotesDetails /> + </DocumentDetailsContext.Provider> + </TestProviders> + ); + expect(mockDispatch).toHaveBeenCalled(); }); - it('should render an add note button', () => { - const { getByTestId } = renderNotesDetails(); - expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); + it('should render loading spinner if notes are being fetched', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + <TestProviders store={store}> + <DocumentDetailsContext.Provider value={panelContextValue}> + <NotesDetails /> + </DocumentDetailsContext.Provider> + </TestProviders> + ); + + expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should render no data message if no notes are present', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Succeeded, + }, + }, + }); + + const { getByText } = render( + <TestProviders store={store}> + <DocumentDetailsContext.Provider value={panelContextValue}> + <NotesDetails /> + </DocumentDetailsContext.Provider> + </TestProviders> + ); + + expect(getByText(NO_NOTES)).toBeInTheDocument(); }); - it('should not render an add note button for users without crud privileges', () => { + it('should render error toast if fetching notes fails', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Failed, + }, + error: { + ...mockGlobalStateWithSavedTimeline.notes.error, + fetchNotesByDocumentIds: { type: 'http', status: 500 }, + }, + }, + }); + + render( + <TestProviders store={store}> + <DocumentDetailsContext.Provider value={panelContextValue}> + <NotesDetails /> + </DocumentDetailsContext.Provider> + </TestProviders> + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: FETCH_NOTES_ERROR, + }); + }); + + it('should not render the add note section for users without crud privileges', () => { useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, + kibanaSecuritySolutionsPrivileges: { crud: false }, }); + const { queryByTestId } = renderNotesDetails(); + expect(queryByTestId(ADD_NOTE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not render the callout and attach to timeline checkbox if not timeline flyout', () => { + (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); + + const { getByTestId, queryByTestId } = renderNotesDetails(); + + expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx index b2b07110d1c4d..b3dfbd53416be 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx @@ -5,36 +5,120 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { EuiSpacer } from '@elastic/eui'; -import { AddNote } from './add_note'; -import { NotesList } from './notes_list'; -import { fetchNotesByDocumentIds } from '../../../../notes/store/notes.slice'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Flyouts } from '../../shared/constants/flyouts'; +import { timelineSelectors } from '../../../../timelines/store'; +import { TimelineId } from '../../../../../common/types'; +import { AttachToActiveTimeline } from './attach_to_active_timeline'; +import { AddNote } from '../../../../notes/components/add_note'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { NOTES_LOADING_TEST_ID } from '../../../../notes/components/test_ids'; +import { NotesList } from '../../../../notes/components/notes_list'; +import type { State } from '../../../../common/store'; +import type { Note } from '../../../../../common/api/timeline'; +import { + fetchNotesByDocumentIds, + ReqStatus, + selectFetchNotesByDocumentIdsError, + selectFetchNotesByDocumentIdsStatus, + selectSortedNotesByDocumentId, +} from '../../../../notes/store/notes.slice'; import { useDocumentDetailsContext } from '../../shared/context'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; + +export const FETCH_NOTES_ERROR = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.fetchNotesErrorLabel', + { + defaultMessage: 'Error fetching notes', + } +); +export const NO_NOTES = i18n.translate('xpack.securitySolution.flyout.left.notes.noNotesLabel', { + defaultMessage: 'No notes have been created for this document', +}); /** * List all the notes for a document id and allows to create new notes associated with that document. * Displayed in the document details expandable flyout left section. */ export const NotesDetails = memo(() => { + const { addError: addErrorToast } = useAppToasts(); const dispatch = useDispatch(); const { eventId } = useDocumentDetailsContext(); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const canCreateNotes = kibanaSecuritySolutionsPrivileges.crud; + // will drive the value we send to the AddNote component + // if true (timeline is saved and the user kept the checkbox checked) we'll send the timelineId to the AddNote component + // if false (timeline is not saved or the user unchecked the checkbox manually ) we'll send an empty string + const [attachToTimeline, setAttachToTimeline] = useState<boolean>(true); + + // if the flyout is open from a timeline and that timeline is saved, we automatically check the checkbox to associate the note to it + const isTimelineFlyout = useWhichFlyout() === Flyouts.timeline; + + const timeline = useSelector((state: State) => + timelineSelectors.selectTimelineById(state, TimelineId.active) + ); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + + const notes: Note[] = useSelector((state: State) => + selectSortedNotesByDocumentId(state, { + documentId: eventId, + sort: { field: 'created', direction: 'asc' }, + }) + ); + const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state)); + const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state)); + + const fetchNotes = useCallback( + () => dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })), + [dispatch, eventId] + ); + useEffect(() => { - dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })); - }, [dispatch, eventId]); + fetchNotes(); + }, [fetchNotes]); + + // show a toast if the fetch notes call fails + useEffect(() => { + if (fetchStatus === ReqStatus.Failed && fetchError) { + addErrorToast(null, { + title: FETCH_NOTES_ERROR, + }); + } + }, [addErrorToast, fetchError, fetchStatus]); return ( <> - <NotesList eventId={eventId} /> + {fetchStatus === ReqStatus.Loading && ( + <EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" /> + )} + {fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <p>{NO_NOTES}</p> + </EuiFlexItem> + </EuiFlexGroup> + ) : ( + <NotesList notes={notes} options={{ hideFlyoutIcon: true }} /> + )} {canCreateNotes && ( <> <EuiSpacer /> - <AddNote eventId={eventId} /> + <AddNote + eventId={eventId} + timelineId={isTimelineFlyout && attachToTimeline ? timelineSavedObjectId : ''} + > + {isTimelineFlyout && ( + <AttachToActiveTimeline + setAttachToTimeline={setAttachToTimeline} + isCheckboxDisabled={timelineSavedObjectId.length === 0} + /> + )} + </AddNote> </> )} </> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx deleted file mode 100644 index f7a0cf814415b..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render, within } from '@testing-library/react'; -import React from 'react'; -import { - ADD_NOTE_LOADING_TEST_ID, - DELETE_NOTE_BUTTON_TEST_ID, - NOTE_AVATAR_TEST_ID, - NOTES_COMMENT_TEST_ID, - NOTES_LOADING_TEST_ID, - OPEN_TIMELINE_BUTTON_TEST_ID, -} from './test_ids'; -import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; -import { DELETE_NOTE_ERROR, FETCH_NOTES_ERROR, NO_NOTES, NotesList } from './notes_list'; -import { ReqStatus } from '../../../../notes/store/notes.slice'; -import { useQueryTimelineById } from '../../../../timelines/components/open_timeline/helpers'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; - -jest.mock('../../../../common/components/user_privileges'); -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; - -jest.mock('../../../../timelines/components/open_timeline/helpers'); - -const mockAddError = jest.fn(); -jest.mock('../../../../common/hooks/use_app_toasts', () => ({ - useAppToasts: () => ({ - addError: mockAddError, - }), -})); - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); - -const renderNotesList = () => - render( - <TestProviders> - <NotesList eventId={'1'} /> - </TestProviders> - ); - -describe('NotesList', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, - }); - }); - - it('should render a note as a comment', () => { - const { getByTestId, getByText } = renderNotesList(); - expect(getByTestId(`${NOTES_COMMENT_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByText('note-1')).toBeInTheDocument(); - expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)).toBeInTheDocument(); - }); - - it('should render loading spinner if notes are being fetched', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); - }); - - it('should render no data message if no notes are present', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Succeeded, - }, - }, - }); - - const { getByText } = render( - <TestProviders store={store}> - <NotesList eventId={'wrong-event-id'} /> - </TestProviders> - ); - - expect(getByText(NO_NOTES)).toBeInTheDocument(); - }); - - it('should render error toast if fetching notes fails', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Failed, - }, - error: { - ...mockGlobalState.notes.error, - fetchNotesByDocumentIds: { type: 'http', status: 500 }, - }, - }, - }); - - render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(mockAddError).toHaveBeenCalledWith(null, { - title: FETCH_NOTES_ERROR, - }); - }); - - it('should render ? in avatar is user is missing', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - entities: { - '1': { - eventId: '1', - noteId: '1', - note: 'note-1', - timelineId: '', - created: 1663882629000, - createdBy: 'elastic', - updated: 1663882629000, - updatedBy: null, - version: 'version', - }, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); - - expect(getByText('?')).toBeInTheDocument(); - }); - - it('should render create loading when user creates a new note', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - createNote: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(getByTestId(ADD_NOTE_LOADING_TEST_ID)).toBeInTheDocument(); - }); - - it('should dispatch delete action when user deletes a new note', () => { - const { getByTestId } = renderNotesList(); - - const deleteIcon = getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); - - expect(deleteIcon).toBeInTheDocument(); - expect(deleteIcon).not.toHaveAttribute('disabled'); - - deleteIcon.click(); - - expect(mockDispatch).toHaveBeenCalled(); - }); - - it('should have delete icons disabled and show spinner if a new note is being deleted', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - deleteNotes: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toHaveAttribute('disabled'); - }); - - it('should not render a delete icon when the user does not have crud privileges', () => { - useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, - }); - const { queryByTestId } = renderNotesList(); - - const deleteIcon = queryByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); - - expect(deleteIcon).not.toBeInTheDocument(); - }); - - it('should render error toast if deleting a note fails', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - deleteNotes: ReqStatus.Failed, - }, - error: { - ...mockGlobalState.notes.error, - deleteNotes: { type: 'http', status: 500 }, - }, - }, - }); - - render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(mockAddError).toHaveBeenCalledWith(null, { - title: DELETE_NOTE_ERROR, - }); - }); - - it('should open timeline if user clicks on the icon', () => { - const queryTimelineById = jest.fn(); - (useQueryTimelineById as jest.Mock).mockReturnValue(queryTimelineById); - - const { getByTestId } = renderNotesList(); - - getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`).click(); - - expect(queryTimelineById).toHaveBeenCalledWith({ - duplicate: false, - onOpenTimeline: undefined, - timelineId: 'timeline-1', - timelineType: undefined, - unifiedComponentsInTimelineDisabled: false, - }); - }); - - it('should not render timeline icon if no timeline is related to the note', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - entities: { - '1': { - eventId: '1', - noteId: '1', - note: 'note-1', - timelineId: '', - created: 1663882629000, - createdBy: 'elastic', - updated: 1663882629000, - updatedBy: 'elastic', - version: 'version', - }, - }, - }, - }); - - const { queryByTestId } = render( - <TestProviders store={store}> - <NotesList eventId={'1'} /> - </TestProviders> - ); - - expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx deleted file mode 100644 index ba7d0b961ddef..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useEffect, useState } from 'react'; -import { - EuiAvatar, - EuiButtonIcon, - EuiComment, - EuiCommentList, - EuiLoadingElastic, -} from '@elastic/eui'; -import { useDispatch, useSelector } from 'react-redux'; -import { FormattedRelative } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useQueryTimelineById } from '../../../../timelines/components/open_timeline/helpers'; -import { - ADD_NOTE_LOADING_TEST_ID, - DELETE_NOTE_BUTTON_TEST_ID, - NOTE_AVATAR_TEST_ID, - NOTES_COMMENT_TEST_ID, - NOTES_LOADING_TEST_ID, - OPEN_TIMELINE_BUTTON_TEST_ID, -} from './test_ids'; -import type { State } from '../../../../common/store'; -import type { Note } from '../../../../../common/api/timeline'; -import { - deleteNotes, - ReqStatus, - selectCreateNoteStatus, - selectDeleteNotesError, - selectDeleteNotesStatus, - selectFetchNotesByDocumentIdsError, - selectFetchNotesByDocumentIdsStatus, - selectSortedNotesByDocumentId, -} from '../../../../notes/store/notes.slice'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; - -export const ADDED_A_NOTE = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.addedANoteLabel', - { - defaultMessage: 'added a note', - } -); -export const FETCH_NOTES_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.fetchNotesErrorLabel', - { - defaultMessage: 'Error fetching notes', - } -); -export const NO_NOTES = i18n.translate('xpack.securitySolution.flyout.left.notes.noNotesLabel', { - defaultMessage: 'No notes have been created for this document', -}); -export const DELETE_NOTE = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.deleteNoteLabel', - { - defaultMessage: 'Delete note', - } -); -export const DELETE_NOTE_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.deleteNoteErrorLabel', - { - defaultMessage: 'Error deleting note', - } -); - -export interface NotesListProps { - /** - * Id of the document - */ - eventId: string; -} - -/** - * Renders a list of notes for the document. - * If a note belongs to a timeline, a timeline icon will be shown the top right corner. - * Also, a delete icon is shown in the top right corner to delete a note. - * When a note is being created, the component renders a loading spinner when the new note is about to be added. - */ -export const NotesList = memo(({ eventId }: NotesListProps) => { - const dispatch = useDispatch(); - const { addError: addErrorToast } = useAppToasts(); - const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); - const canDeleteNotes = kibanaSecuritySolutionsPrivileges.crud; - - const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( - 'unifiedComponentsInTimelineDisabled' - ); - - const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state)); - const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state)); - - const notes: Note[] = useSelector((state: State) => - selectSortedNotesByDocumentId(state, { - documentId: eventId, - sort: { field: 'created', direction: 'asc' }, - }) - ); - - const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - - const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state)); - const deleteError = useSelector((state: State) => selectDeleteNotesError(state)); - const [deletingNoteId, setDeletingNoteId] = useState(''); - - const deleteNoteFc = useCallback( - (noteId: string) => { - setDeletingNoteId(noteId); - dispatch(deleteNotes({ ids: [noteId] })); - }, - [dispatch] - ); - - const queryTimelineById = useQueryTimelineById(); - const openTimeline = useCallback( - ({ timelineId }: { timelineId: string }) => - queryTimelineById({ - duplicate: false, - onOpenTimeline: undefined, - timelineId, - timelineType: undefined, - unifiedComponentsInTimelineDisabled, - }), - [queryTimelineById, unifiedComponentsInTimelineDisabled] - ); - - // show a toast if the fetch notes call fails - useEffect(() => { - if (fetchStatus === ReqStatus.Failed && fetchError) { - addErrorToast(null, { - title: FETCH_NOTES_ERROR, - }); - } - }, [addErrorToast, fetchError, fetchStatus]); - - useEffect(() => { - if (deleteStatus === ReqStatus.Failed && deleteError) { - addErrorToast(null, { - title: DELETE_NOTE_ERROR, - }); - } - }, [addErrorToast, deleteError, deleteStatus]); - - if (fetchStatus === ReqStatus.Loading) { - return <EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" />; - } - - if (fetchStatus === ReqStatus.Succeeded && notes.length === 0) { - return <p>{NO_NOTES}</p>; - } - - return ( - <EuiCommentList> - {notes.map((note, index) => ( - <EuiComment - data-test-subj={`${NOTES_COMMENT_TEST_ID}-${index}`} - key={note.noteId} - username={note.createdBy} - timestamp={<>{note.created && <FormattedRelative value={new Date(note.created)} />}</>} - event={ADDED_A_NOTE} - actions={ - <> - {note.timelineId && note.timelineId.length > 0 && ( - <EuiButtonIcon - data-test-subj={`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`} - title="Open timeline" - aria-label="Open timeline" - color="text" - iconType="timeline" - onClick={() => openTimeline(note)} - /> - )} - {canDeleteNotes && ( - <EuiButtonIcon - data-test-subj={`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`} - title={DELETE_NOTE} - aria-label={DELETE_NOTE} - color="text" - iconType="trash" - onClick={() => deleteNoteFc(note.noteId)} - disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading} - isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading} - /> - )} - </> - } - timelineAvatar={ - <EuiAvatar - data-test-subj={`${NOTE_AVATAR_TEST_ID}-${index}`} - size="l" - name={note.updatedBy || '?'} - /> - } - > - <MarkdownRenderer>{note.note || ''}</MarkdownRenderer> - </EuiComment> - ))} - {createStatus === ReqStatus.Loading && ( - <EuiLoadingElastic size="xxl" data-test-subj={ADD_NOTE_LOADING_TEST_ID} /> - )} - </EuiCommentList> - ); -}); - -NotesList.displayName = 'NotesList'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 23be7cc9b801f..0779f3c135b2d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -125,12 +125,6 @@ export const INVESTIGATION_GUIDE_LOADING_TEST_ID = `${INVESTIGATION_GUIDE_TEST_I /* Notes */ -export const NOTES_LOADING_TEST_ID = `${PREFIX}NotesLoading` as const; -export const NOTES_COMMENT_TEST_ID = `${PREFIX}NotesComment` as const; -export const ADD_NOTE_LOADING_TEST_ID = `${PREFIX}AddNotesLoading` as const; -export const ADD_NOTE_MARKDOWN_TEST_ID = `${PREFIX}AddNotesMarkdown` as const; -export const ADD_NOTE_BUTTON_TEST_ID = `${PREFIX}AddNotesButton` as const; -export const NOTE_AVATAR_TEST_ID = `${PREFIX}NoteAvatar` as const; -export const DELETE_NOTE_BUTTON_TEST_ID = `${PREFIX}DeleteNotesButton` as const; +export const ATTACH_TO_TIMELINE_CALLOUT_TEST_ID = `${PREFIX}AttachToTimelineCallout` as const; export const ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID = `${PREFIX}AttachToTimelineCheckbox` as const; -export const OPEN_TIMELINE_BUTTON_TEST_ID = `${PREFIX}OpenTimelineButton` as const; +export const SAVE_TIMELINE_BUTTON_TEST_ID = `${PREFIX}SaveTimelineButton` as const; diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 4c9542458c304..eb25eed9f2816 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -75,6 +75,20 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => { return response; }; +/** + * Fetches all the notes for an array of saved object ids + */ +export const fetchNotesBySaveObjectIds = async (savedObjectIds: string[]) => { + const response = await KibanaServices.get().http.get<{ notes: Note[]; totalCount: number }>( + NOTE_URL, + { + query: { savedObjectIds }, + version: '2023-10-31', + } + ); + return response; +}; + /** * Deletes multiple notes */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx rename to x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx index 481776bb51413..f20323da6085d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx @@ -5,26 +5,18 @@ * 2.0. */ -import * as uuid from 'uuid'; import { render } from '@testing-library/react'; import React from 'react'; -import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; -import { AddNote, CREATE_NOTE_ERROR } from './add_note'; -import { - ADD_NOTE_BUTTON_TEST_ID, - ADD_NOTE_MARKDOWN_TEST_ID, - ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, -} from './test_ids'; -import { ReqStatus } from '../../../../notes/store/notes.slice'; -import { TimelineId } from '../../../../../common/types'; import userEvent from '@testing-library/user-event'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import { AddNote, CREATE_NOTE_ERROR } from './add_note'; +import { ADD_NOTE_BUTTON_TEST_ID, ADD_NOTE_MARKDOWN_TEST_ID } from './test_ids'; +import { ReqStatus } from '../store/notes.slice'; -jest.mock('../../shared/hooks/use_which_flyout'); +jest.mock('../../flyout/document_details/shared/hooks/use_which_flyout'); const mockAddError = jest.fn(); -jest.mock('../../../../common/hooks/use_app_toasts', () => ({ +jest.mock('../../common/hooks/use_app_toasts', () => ({ useAppToasts: () => ({ addError: mockAddError, }), @@ -52,7 +44,6 @@ describe('AddNote', () => { expect(getByTestId(ADD_NOTE_MARKDOWN_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toBeInTheDocument(); }); it('should create note', async () => { @@ -76,6 +67,19 @@ describe('AddNote', () => { expect(addButton).not.toHaveAttribute('disabled'); }); + it('should disable add button always is disableButton props is true', async () => { + const { getByTestId } = render( + <TestProviders> + <AddNote eventId={'event-id'} disableButton={true} /> + </TestProviders> + ); + + await userEvent.type(getByTestId('euiMarkdownEditorTextArea'), 'new note'); + + const addButton = getByTestId(ADD_NOTE_BUTTON_TEST_ID); + expect(addButton).toHaveAttribute('disabled'); + }); + it('should render the add note button in loading state while creating a new note', () => { const store = createMockStore({ ...mockGlobalState, @@ -123,63 +127,4 @@ describe('AddNote', () => { title: CREATE_NOTE_ERROR, }); }); - - it('should disable attach to timeline checkbox if flyout is not open from timeline', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); - - const { getByTestId } = renderAddNote(); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toHaveAttribute('disabled'); - }); - - it('should disable attach to timeline checkbox if active timeline is not saved', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - - const store = createMockStore({ - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - }, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <AddNote eventId={'event-id'} /> - </TestProviders> - ); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toHaveAttribute('disabled'); - }); - - it('should have attach to timeline checkbox enabled', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - - const store = createMockStore({ - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - savedObjectId: uuid.v4(), - }, - }, - }, - }); - - const { getByTestId } = render( - <TestProviders store={store}> - <AddNote eventId={'event-id'} /> - </TestProviders> - ); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toHaveAttribute('disabled'); - }); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx new file mode 100644 index 0000000000000..d54e0e42c86eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiButton, + EuiComment, + EuiCommentList, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../common/lib/kibana'; +import { ADD_NOTE_BUTTON_TEST_ID, ADD_NOTE_MARKDOWN_TEST_ID } from './test_ids'; +import { useAppToasts } from '../../common/hooks/use_app_toasts'; +import type { State } from '../../common/store'; +import { + createNote, + ReqStatus, + selectCreateNoteError, + selectCreateNoteStatus, +} from '../store/notes.slice'; +import { MarkdownEditor } from '../../common/components/markdown_editor'; + +export const MARKDOWN_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.notes.addNote.markdownAriaLabel', + { + defaultMessage: 'Note', + } +); +export const ADD_NOTE_BUTTON = i18n.translate('xpack.securitySolution.notes.addNote.buttonLabel', { + defaultMessage: 'Add note', +}); +export const CREATE_NOTE_ERROR = i18n.translate( + 'xpack.securitySolution.notes.createNote.errorLabel', + { + defaultMessage: 'Error create note', + } +); + +export interface AddNewNoteProps { + /** + * Id of the document + */ + eventId?: string; + /** + * Id of the timeline + */ + timelineId?: string | null | undefined; + /** + * Allows to override the default state of the add note button + */ + disableButton?: boolean; + /** + * Children to render between the markdown and the add note button + */ + children?: React.ReactNode; +} + +/** + * Renders a markdown editor and an add button to create new notes. + * The checkbox is automatically checked if the flyout is opened from a timeline and that timeline is saved. It is disabled if the flyout is NOT opened from a timeline. + */ +export const AddNote = memo( + ({ eventId, timelineId, disableButton = false, children }: AddNewNoteProps) => { + const { telemetry } = useKibana().services; + const dispatch = useDispatch(); + const { addError: addErrorToast } = useAppToasts(); + const [editorValue, setEditorValue] = useState(''); + const [isMarkdownInvalid, setIsMarkdownInvalid] = useState(false); + + const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); + const createError = useSelector((state: State) => selectCreateNoteError(state)); + + const addNote = useCallback(() => { + dispatch( + createNote({ + note: { + timelineId: timelineId || '', + eventId, + note: editorValue, + }, + }) + ); + telemetry.reportAddNoteFromExpandableFlyoutClicked({ + isRelatedToATimeline: timelineId != null, + }); + setEditorValue(''); + }, [dispatch, editorValue, eventId, telemetry, timelineId]); + + // show a toast if the create note call fails + useEffect(() => { + if (createStatus === ReqStatus.Failed && createError) { + addErrorToast(null, { + title: CREATE_NOTE_ERROR, + }); + } + }, [addErrorToast, createError, createStatus]); + + const buttonDisabled = useMemo( + () => disableButton || editorValue.trim().length === 0 || isMarkdownInvalid, + [disableButton, editorValue, isMarkdownInvalid] + ); + + return ( + <> + <EuiCommentList> + <EuiComment username=""> + <MarkdownEditor + dataTestSubj={ADD_NOTE_MARKDOWN_TEST_ID} + value={editorValue} + onChange={setEditorValue} + ariaLabel={MARKDOWN_ARIA_LABEL} + setIsMarkdownInvalid={setIsMarkdownInvalid} + /> + </EuiComment> + </EuiCommentList> + <EuiSpacer size="m" /> + {children && ( + <> + {children} + <EuiSpacer size="m" /> + </> + )} + <EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}> + <EuiFlexItem grow={false}> + <EuiButton + onClick={addNote} + isLoading={createStatus === ReqStatus.Loading} + disabled={buttonDisabled} + data-test-subj={ADD_NOTE_BUTTON_TEST_ID} + > + {ADD_NOTE_BUTTON} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); + } +); + +AddNote.displayName = 'AddNote'; diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx new file mode 100644 index 0000000000000..88224604a0ece --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { DELETE_NOTE_ERROR, DeleteNoteButtonIcon } from './delete_note_button'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import type { Note } from '../../../common/api/timeline'; +import { DELETE_NOTE_BUTTON_TEST_ID } from './test_ids'; +import { ReqStatus } from '..'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockAddError = jest.fn(); +jest.mock('../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + +const note: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timelineId', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const index = 0; + +describe('DeleteNoteButtonIcon', () => { + it('should render the delete icon', () => { + const { getByTestId } = render( + <TestProviders> + <DeleteNoteButtonIcon note={note} index={index} /> + </TestProviders> + ); + + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`)).toBeInTheDocument(); + }); + + it('should have delete icons disabled and show spinner if a new note is being deleted', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + deleteNotes: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + <TestProviders store={store}> + <DeleteNoteButtonIcon note={note} index={index} /> + </TestProviders> + ); + + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toHaveAttribute('disabled'); + }); + + it('should dispatch delete action when user deletes a new note', () => { + const { getByTestId } = render( + <TestProviders> + <DeleteNoteButtonIcon note={note} index={index} /> + </TestProviders> + ); + + const deleteIcon = getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); + + expect(deleteIcon).toBeInTheDocument(); + expect(deleteIcon).not.toHaveAttribute('disabled'); + + deleteIcon.click(); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it('should render error toast if deleting a note fails', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + deleteNotes: ReqStatus.Failed, + }, + error: { + ...mockGlobalState.notes.error, + deleteNotes: { type: 'http', status: 500 }, + }, + }, + }); + + render( + <TestProviders store={store}> + <DeleteNoteButtonIcon note={note} index={index} /> + </TestProviders> + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: DELETE_NOTE_ERROR, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx new file mode 100644 index 0000000000000..3f9e757d3f5a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { DELETE_NOTE_BUTTON_TEST_ID } from './test_ids'; +import type { State } from '../../common/store'; +import type { Note } from '../../../common/api/timeline'; +import { + deleteNotes, + ReqStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, +} from '../store/notes.slice'; +import { useAppToasts } from '../../common/hooks/use_app_toasts'; + +export const DELETE_NOTE = i18n.translate('xpack.securitySolution.notes.deleteNote.buttonLabel', { + defaultMessage: 'Delete note', +}); +export const DELETE_NOTE_ERROR = i18n.translate( + 'xpack.securitySolution.notes.deleteNote.errorLabel', + { + defaultMessage: 'Error deleting note', + } +); + +export interface DeleteNoteButtonIconProps { + /** + * The note that contains the id of the timeline to open + */ + note: Note; + /** + * The index of the note in the list of notes (used to have unique data-test-subj) + */ + index: number; +} + +/** + * Renders a button to delete a note + */ +export const DeleteNoteButtonIcon = memo(({ note, index }: DeleteNoteButtonIconProps) => { + const dispatch = useDispatch(); + const { addError: addErrorToast } = useAppToasts(); + + const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state)); + const deleteError = useSelector((state: State) => selectDeleteNotesError(state)); + const [deletingNoteId, setDeletingNoteId] = useState(''); + + const deleteNoteFc = useCallback( + (noteId: string) => { + setDeletingNoteId(noteId); + dispatch(deleteNotes({ ids: [noteId] })); + }, + [dispatch] + ); + + useEffect(() => { + if (deleteStatus === ReqStatus.Failed && deleteError) { + addErrorToast(null, { + title: DELETE_NOTE_ERROR, + }); + } + }, [addErrorToast, deleteError, deleteStatus]); + + return ( + <EuiButtonIcon + data-test-subj={`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`} + title={DELETE_NOTE} + aria-label={DELETE_NOTE} + color="text" + iconType="trash" + onClick={() => deleteNoteFc(note.noteId)} + disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading} + isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading} + /> + ); +}); + +DeleteNoteButtonIcon.displayName = 'DeleteNoteButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx new file mode 100644 index 0000000000000..d32b508d03037 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, within } from '@testing-library/react'; +import React from 'react'; +import { + ADD_NOTE_LOADING_TEST_ID, + DELETE_NOTE_BUTTON_TEST_ID, + NOTE_AVATAR_TEST_ID, + NOTES_COMMENT_TEST_ID, + OPEN_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import { NotesList } from './notes_list'; +import { ReqStatus } from '../store/notes.slice'; +import { useUserPrivileges } from '../../common/components/user_privileges'; +import type { Note } from '../../../common/api/timeline'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; + +jest.mock('../../common/hooks/use_experimental_features'); + +jest.mock('../../common/components/user_privileges'); +const useUserPrivilegesMock = useUserPrivileges as jest.Mock; + +const mockNote: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const mockOptions = { hideTimelineIcon: true }; + +describe('NotesList', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + + useUserPrivilegesMock.mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, + }); + }); + + it('should render a note as a comment', () => { + const { getByTestId, getByText } = render( + <TestProviders> + <NotesList notes={[mockNote]} /> + </TestProviders> + ); + + expect(getByTestId(`${NOTES_COMMENT_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByText('note-1')).toBeInTheDocument(); + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)).toBeInTheDocument(); + }); + + it('should render ? in avatar is user is missing', () => { + const customMockNotes = [ + { + ...mockNote, + updatedBy: undefined, + }, + ]; + + const { getByTestId } = render( + <TestProviders> + <NotesList notes={customMockNotes} /> + </TestProviders> + ); + const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); + + expect(getByText('?')).toBeInTheDocument(); + }); + + it('should render create loading when user creates a new note', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + createNote: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + <TestProviders store={store}> + <NotesList notes={[mockNote]} /> + </TestProviders> + ); + + expect(getByTestId(ADD_NOTE_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render a delete icon when the user does not have crud privileges', () => { + useUserPrivilegesMock.mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, + }); + const { queryByTestId } = render( + <TestProviders> + <NotesList notes={[mockNote]} /> + </TestProviders> + ); + + const deleteIcon = queryByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); + + expect(deleteIcon).not.toBeInTheDocument(); + }); + + it('should not render timeline icon if no timeline is related to the note', () => { + const customMockNotes = [ + { + ...mockNote, + timelineId: '', + }, + ]; + + const { queryByTestId } = render( + <TestProviders> + <NotesList notes={customMockNotes} /> + </TestProviders> + ); + + expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); + }); + + it('should not render timeline icon if it should be hidden', () => { + const { queryByTestId } = render( + <TestProviders> + <NotesList notes={[mockNote]} options={mockOptions} /> + </TestProviders> + ); + + expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx new file mode 100644 index 0000000000000..47dcf89b06452 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiAvatar, EuiComment, EuiCommentList, EuiLoadingElastic } from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import { FormattedRelative } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { OpenFlyoutButtonIcon } from './open_flyout_button'; +import { OpenTimelineButtonIcon } from './open_timeline_button'; +import { DeleteNoteButtonIcon } from './delete_note_button'; +import { MarkdownRenderer } from '../../common/components/markdown_editor'; +import { ADD_NOTE_LOADING_TEST_ID, NOTE_AVATAR_TEST_ID, NOTES_COMMENT_TEST_ID } from './test_ids'; +import type { State } from '../../common/store'; +import type { Note } from '../../../common/api/timeline'; +import { ReqStatus, selectCreateNoteStatus } from '../store/notes.slice'; +import { useUserPrivileges } from '../../common/components/user_privileges'; + +export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.notes.addedANoteLabel', { + defaultMessage: 'added a note', +}); +export const DELETE_NOTE = i18n.translate('xpack.securitySolution.notes.deleteNoteLabel', { + defaultMessage: 'Delete note', +}); + +export interface NotesListProps { + /** + * The notes to display as a EuiComment + */ + notes: Note[]; + /** + * Options to customize the rendering of the notes list + */ + options?: { + /** + * If true, the timeline icon will be hidden (this is useful for the timeline Notes tab) + */ + hideTimelineIcon?: boolean; + /** + * If true, the flyout icon will be hidden (this is useful for the flyout Notes tab) + */ + hideFlyoutIcon?: boolean; + }; +} + +/** + * Renders a list of notes for the document. + * If a note belongs to a timeline, a timeline icon will be shown the top right corner. + * Also, a delete icon is shown in the top right corner to delete a note. + * When a note is being created, the component renders a loading spinner when the new note is about to be added. + */ +export const NotesList = memo(({ notes, options }: NotesListProps) => { + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + const canDeleteNotes = kibanaSecuritySolutionsPrivileges.crud; + + const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); + + return ( + <EuiCommentList> + {notes.map((note, index) => ( + <EuiComment + data-test-subj={`${NOTES_COMMENT_TEST_ID}-${index}`} + key={note.noteId} + username={note.createdBy} + timestamp={<>{note.created && <FormattedRelative value={new Date(note.created)} />}</>} + event={ADDED_A_NOTE} + actions={ + <> + {note.eventId && !options?.hideFlyoutIcon && ( + <OpenFlyoutButtonIcon eventId={note.eventId} timelineId={note.timelineId} /> + )} + {note.timelineId && note.timelineId.length > 0 && !options?.hideTimelineIcon && ( + <OpenTimelineButtonIcon note={note} index={index} /> + )} + {canDeleteNotes && <DeleteNoteButtonIcon note={note} index={index} />} + </> + } + timelineAvatar={ + <EuiAvatar + data-test-subj={`${NOTE_AVATAR_TEST_ID}-${index}`} + size="l" + name={note.updatedBy || '?'} + /> + } + > + <MarkdownRenderer>{note.note || ''}</MarkdownRenderer> + </EuiComment> + ))} + {createStatus === ReqStatus.Loading && ( + <EuiLoadingElastic size="xxl" data-test-subj={ADD_NOTE_LOADING_TEST_ID} /> + )} + </EuiCommentList> + ); +}); + +NotesList.displayName = 'NotesList'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx new file mode 100644 index 0000000000000..eed5e5bcbd5da --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../common/mock'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from './test_ids'; +import { OpenFlyoutButtonIcon } from './open_flyout_button'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; +import { useSourcererDataView } from '../../sourcerer/containers'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../sourcerer/containers'); + +const mockEventId = 'eventId'; +const mockTimelineId = 'timelineId'; + +describe('OpenFlyoutButtonIcon', () => { + it('should render the chevron icon', () => { + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout: jest.fn() }); + (useSourcererDataView as jest.Mock).mockReturnValue({ selectedPatterns: [] }); + + const { getByTestId } = render( + <TestProviders> + <OpenFlyoutButtonIcon eventId={mockEventId} timelineId={mockTimelineId} /> + </TestProviders> + ); + + expect(getByTestId(OPEN_FLYOUT_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the expandable flyout api when the button is clicked', () => { + const openFlyout = jest.fn(); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout }); + (useSourcererDataView as jest.Mock).mockReturnValue({ selectedPatterns: ['test1', 'test2'] }); + + const { getByTestId } = render( + <TestProviders> + <OpenFlyoutButtonIcon eventId={mockEventId} timelineId={mockTimelineId} /> + </TestProviders> + ); + + const button = getByTestId(OPEN_FLYOUT_BUTTON_TEST_ID); + button.click(); + + expect(openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: mockEventId, + indexName: 'test1,test2', + scopeId: mockTimelineId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx new file mode 100644 index 0000000000000..0c541cc95740c --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from './test_ids'; +import { useSourcererDataView } from '../../sourcerer/containers'; +import { SourcererScopeName } from '../../sourcerer/store/model'; +import { useKibana } from '../../common/lib/kibana'; +import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; + +export const OPEN_FLYOUT_BUTTON = i18n.translate( + 'xpack.securitySolution.notes.openFlyoutButtonLabel', + { + defaultMessage: 'Expand event details', + } +); + +export interface OpenFlyoutButtonIconProps { + /** + * Id of the event to render in the flyout + */ + eventId: string; + /** + * Id of the timeline to pass to the flyout for scope + */ + timelineId: string; +} + +/** + * Renders a button to open the alert and event details flyout + */ +export const OpenFlyoutButtonIcon = memo(({ eventId, timelineId }: OpenFlyoutButtonIconProps) => { + const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + + const { telemetry } = useKibana().services; + const { openFlyout } = useExpandableFlyoutApi(); + + const handleClick = useCallback(() => { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName: selectedPatterns.join(','), + scopeId: timelineId, + }, + }, + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'right', + }); + }, [eventId, openFlyout, selectedPatterns, telemetry, timelineId]); + + return ( + <EuiButtonIcon + data-test-subj={OPEN_FLYOUT_BUTTON_TEST_ID} + title={OPEN_FLYOUT_BUTTON} + aria-label={OPEN_FLYOUT_BUTTON} + color="text" + iconType="arrowRight" + onClick={handleClick} + /> + ); +}); + +OpenFlyoutButtonIcon.displayName = 'OpenFlyoutButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx new file mode 100644 index 0000000000000..85ecfce68e5d9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { OpenTimelineButtonIcon } from './open_timeline_button'; +import type { Note } from '../../../common/api/timeline'; +import { OPEN_TIMELINE_BUTTON_TEST_ID } from './test_ids'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; + +jest.mock('../../common/hooks/use_experimental_features'); +jest.mock('../../timelines/components/open_timeline/helpers'); + +const note: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timelineId', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const index = 0; + +describe('OpenTimelineButtonIcon', () => { + it('should render the timeline icon', () => { + const { getByTestId } = render(<OpenTimelineButtonIcon note={note} index={index} />); + + expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`)).toBeInTheDocument(); + }); + + it('should call openTimeline with the correct values', () => { + const openTimeline = jest.fn(); + (useQueryTimelineById as jest.Mock).mockReturnValue(openTimeline); + + const unifiedComponentsInTimelineDisabled = false; + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue( + unifiedComponentsInTimelineDisabled + ); + + const { getByTestId } = render(<OpenTimelineButtonIcon note={note} index={index} />); + + const button = getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`); + button.click(); + + expect(openTimeline).toHaveBeenCalledWith({ + duplicate: false, + onOpenTimeline: undefined, + timelineId: note.timelineId, + timelineType: undefined, + unifiedComponentsInTimelineDisabled, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx new file mode 100644 index 0000000000000..531983429acd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; +import { OPEN_TIMELINE_BUTTON_TEST_ID } from './test_ids'; +import type { Note } from '../../../common/api/timeline'; + +export interface OpenTimelineButtonIconProps { + /** + * The note that contains the id of the timeline to open + */ + note: Note; + /** + * The index of the note in the list of notes (used to have unique data-test-subj) + */ + index: number; +} + +/** + * Renders a button to open the timeline associated with a note + */ +export const OpenTimelineButtonIcon = memo(({ note, index }: OpenTimelineButtonIconProps) => { + const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( + 'unifiedComponentsInTimelineDisabled' + ); + + const queryTimelineById = useQueryTimelineById(); + const openTimeline = useCallback( + ({ timelineId }: { timelineId: string }) => + queryTimelineById({ + duplicate: false, + onOpenTimeline: undefined, + timelineId, + timelineType: undefined, + unifiedComponentsInTimelineDisabled, + }), + [queryTimelineById, unifiedComponentsInTimelineDisabled] + ); + + return ( + <EuiButtonIcon + data-test-subj={`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`} + title="Open timeline" + aria-label="Open timeline" + color="text" + iconType="timeline" + onClick={() => openTimeline(note)} + /> + ); +}); + +OpenTimelineButtonIcon.displayName = 'OpenTimelineButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts new file mode 100644 index 0000000000000..6c63a43f365ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PREFIX = 'securitySolutionNotes' as const; + +export const NOTES_LOADING_TEST_ID = `${PREFIX}NotesLoading` as const; +export const NOTES_COMMENT_TEST_ID = `${PREFIX}NotesComment` as const; +export const ADD_NOTE_LOADING_TEST_ID = `${PREFIX}AddNotesLoading` as const; +export const ADD_NOTE_MARKDOWN_TEST_ID = `${PREFIX}AddNotesMarkdown` as const; +export const ADD_NOTE_BUTTON_TEST_ID = `${PREFIX}AddNotesButton` as const; +export const NOTE_AVATAR_TEST_ID = `${PREFIX}NoteAvatar` as const; +export const DELETE_NOTE_BUTTON_TEST_ID = `${PREFIX}DeleteNotesButton` as const; +export const OPEN_TIMELINE_BUTTON_TEST_ID = `${PREFIX}OpenTimelineButton` as const; +export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const; +export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 5825a170bf1cf..396940c892a6e 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -42,6 +42,9 @@ import { userSelectedRowForDeletion, userSortedNotes, selectSortedNotesByDocumentId, + fetchNotesBySavedObjectIds, + selectNotesBySavedObjectId, + selectSortedNotesBySavedObjectId, } from './notes.slice'; import type { NotesState } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; @@ -72,11 +75,18 @@ const initialNonEmptyState = { ids: [mockNote1.noteId, mockNote2.noteId], status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNotes: null, fetchNotes: null }, + error: { + fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, + createNote: null, + deleteNotes: null, + fetchNotes: null, + }, pagination: { page: 1, perPage: 10, @@ -180,6 +190,88 @@ describe('notesSlice', () => { }); }); + describe('fetchNotesBySavedObjectIds', () => { + it('should set correct status state when fetching notes by saved object ids', () => { + const action = { type: fetchNotesBySavedObjectIds.pending.type }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Loading, + }, + }); + }); + + it('should set correct state when success on fetch notes by saved object id ids on an empty state', () => { + const action = { + type: fetchNotesBySavedObjectIds.fulfilled.type, + payload: { + entities: { + notes: { + [mockNote1.noteId]: mockNote1, + }, + }, + result: [mockNote1.noteId], + }, + }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + entities: action.payload.entities.notes, + ids: action.payload.result, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }); + }); + + it('should replace notes when success on fetch notes by saved object id ids on a non-empty state', () => { + const newMockNote = { ...mockNote1, timelineId: 'timelineId' }; + const action = { + type: fetchNotesBySavedObjectIds.fulfilled.type, + payload: { + entities: { + notes: { + [newMockNote.noteId]: newMockNote, + }, + }, + result: [newMockNote.noteId], + }, + }; + + expect(notesReducer(initialNonEmptyState, action)).toEqual({ + ...initalEmptyState, + entities: { + [newMockNote.noteId]: newMockNote, + [mockNote2.noteId]: mockNote2, + }, + ids: [newMockNote.noteId, mockNote2.noteId], + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }); + }); + + it('should set correct error state when failing to fetch notes by saved object ids', () => { + const action = { type: fetchNotesBySavedObjectIds.rejected.type, error: 'error' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Failed, + }, + error: { + ...initalEmptyState.error, + fetchNotesBySavedObjectIds: 'error', + }, + }); + }); + }); + describe('createNote', () => { it('should set correct status state when creating a note', () => { const action = { type: createNote.pending.type }; @@ -516,7 +608,7 @@ describe('notesSlice', () => { expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0); }); - it('should return all notes sorted dor an existing document id', () => { + it('should return all notes sorted for an existing document id', () => { const oldestNote = { eventId: '1', // should be a valid id based on mockTimelineData noteId: '1', @@ -573,6 +665,89 @@ describe('notesSlice', () => { ).toHaveLength(0); }); + it('should return all notes for an existing saved object id', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, 'timeline-1')).toEqual([ + mockGlobalState.notes.entities['1'], + ]); + }); + + it('should return no notes if saved object id does not exist', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, 'wrong-saved-object-id')).toHaveLength(0); + }); + + it('should return no notes if saved object id is empty string', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, '')).toHaveLength(0); + }); + + it('should return all notes sorted for an existing saved object id', () => { + const oldestNote = { + eventId: '1', // should be a valid id based on mockTimelineData + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', + }; + const newestNote = { + ...oldestNote, + noteId: '2', + created: 1663882689000, + }; + + const state = { + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + entities: { + '1': oldestNote, + '2': newestNote, + }, + ids: ['1', '2'], + }, + }; + + const ascResult = selectSortedNotesBySavedObjectId(state, { + savedObjectId: 'timeline-1', + sort: { field: 'created', direction: 'asc' }, + }); + expect(ascResult[0]).toEqual(oldestNote); + expect(ascResult[1]).toEqual(newestNote); + + const descResult = selectSortedNotesBySavedObjectId(state, { + savedObjectId: 'timeline-1', + sort: { field: 'created', direction: 'desc' }, + }); + expect(descResult[0]).toEqual(newestNote); + expect(descResult[1]).toEqual(oldestNote); + }); + + it('should also return no notes if saved object id does not exist', () => { + expect( + selectSortedNotesBySavedObjectId(mockGlobalState, { + savedObjectId: 'wrong-document-id', + sort: { + field: 'created', + direction: 'desc', + }, + }) + ).toHaveLength(0); + }); + + it('should also return no notes if saved object id is empty string', () => { + expect( + selectSortedNotesBySavedObjectId(mockGlobalState, { + savedObjectId: '', + sort: { + field: 'created', + direction: 'desc', + }, + }) + ).toHaveLength(0); + }); + it('should select notes pagination', () => { const state = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 4f333103a2a25..3f0439e7298e4 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -14,6 +14,7 @@ import { deleteNotes as deleteNotesApi, fetchNotes as fetchNotesApi, fetchNotesByDocumentIds as fetchNotesByDocumentIdsApi, + fetchNotesBySaveObjectIds as fetchNotesBySaveObjectIdsApi, } from '../api/api'; import type { NormalizedEntities, NormalizedEntity } from './normalize'; import { normalizeEntities, normalizeEntity } from './normalize'; @@ -26,7 +27,7 @@ export enum ReqStatus { Failed = 'failed', } -interface HttpError { +export interface HttpError { type: 'http'; status: number; } @@ -34,12 +35,14 @@ interface HttpError { export interface NotesState extends EntityState<Note> { status: { fetchNotesByDocumentIds: ReqStatus; + fetchNotesBySavedObjectIds: ReqStatus; createNote: ReqStatus; deleteNotes: ReqStatus; fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; + fetchNotesBySavedObjectIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; deleteNotes: SerializedError | HttpError | null; fetchNotes: SerializedError | HttpError | null; @@ -66,12 +69,14 @@ const notesAdapter = createEntityAdapter<Note>({ export const initialNotesState: NotesState = notesAdapter.getInitialState({ status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, createNote: null, deleteNotes: null, fetchNotes: null, @@ -101,6 +106,16 @@ export const fetchNotesByDocumentIds = createAsyncThunk< return normalizeEntities(res.notes); }); +export const fetchNotesBySavedObjectIds = createAsyncThunk< + NormalizedEntities<Note>, + { savedObjectIds: string[] }, + {} +>('notes/fetchNotesBySavedObjectIds', async (args) => { + const { savedObjectIds } = args; + const res = await fetchNotesBySaveObjectIdsApi(savedObjectIds); + return normalizeEntities(res.notes); +}); + export const fetchNotes = createAsyncThunk< NormalizedEntities<Note> & { totalCount: number }, { @@ -198,6 +213,17 @@ const notesSlice = createSlice({ state.status.fetchNotesByDocumentIds = ReqStatus.Failed; state.error.fetchNotesByDocumentIds = action.payload ?? action.error; }) + .addCase(fetchNotesBySavedObjectIds.pending, (state) => { + state.status.fetchNotesBySavedObjectIds = ReqStatus.Loading; + }) + .addCase(fetchNotesBySavedObjectIds.fulfilled, (state, action) => { + notesAdapter.upsertMany(state, action.payload.entities.notes); + state.status.fetchNotesBySavedObjectIds = ReqStatus.Succeeded; + }) + .addCase(fetchNotesBySavedObjectIds.rejected, (state, action) => { + state.status.fetchNotesBySavedObjectIds = ReqStatus.Failed; + state.error.fetchNotesBySavedObjectIds = action.payload ?? action.error; + }) .addCase(createNote.pending, (state) => { state.status.createNote = ReqStatus.Loading; }) @@ -253,6 +279,12 @@ export const selectFetchNotesByDocumentIdsStatus = (state: State) => export const selectFetchNotesByDocumentIdsError = (state: State) => state.notes.error.fetchNotesByDocumentIds; +export const selectFetchNotesBySavedObjectIdsStatus = (state: State) => + state.notes.status.fetchNotesBySavedObjectIds; + +export const selectFetchNotesBySavedObjectIdsError = (state: State) => + state.notes.error.fetchNotesBySavedObjectIds; + export const selectCreateNoteStatus = (state: State) => state.notes.status.createNote; export const selectCreateNoteError = (state: State) => state.notes.error.createNote; @@ -280,6 +312,12 @@ export const selectNotesByDocumentId = createSelector( (notes, documentId) => notes.filter((note) => note.eventId === documentId) ); +export const selectNotesBySavedObjectId = createSelector( + [selectAllNotes, (state: State, savedObjectId: string) => savedObjectId], + (notes, savedObjectId) => + savedObjectId.length > 0 ? notes.filter((note) => note.timelineId === savedObjectId) : [] +); + export const selectSortedNotesByDocumentId = createSelector( [ selectAllNotes, @@ -305,6 +343,34 @@ export const selectSortedNotesByDocumentId = createSelector( } ); +export const selectSortedNotesBySavedObjectId = createSelector( + [ + selectAllNotes, + ( + state: State, + { + savedObjectId, + sort, + }: { savedObjectId: string; sort: { field: keyof Note; direction: 'asc' | 'desc' } } + ) => ({ savedObjectId, sort }), + ], + (notes, { savedObjectId, sort }) => { + const { field, direction } = sort; + if (savedObjectId.length === 0) { + return []; + } + return notes + .filter((note: Note) => note.timelineId === savedObjectId) + .sort((first: Note, second: Note) => { + const a = first[field]; + const b = second[field]; + if (a == null) return 1; + if (b == null) return -1; + return direction === 'asc' ? (a > b ? 1 : -1) : a > b ? -1 : 1; + }); + } +); + export const { userSelectedPage, userSelectedPerPage, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx index 2f923a12e3f33..8a05a42f7cd25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx @@ -62,6 +62,27 @@ describe('SaveTimelineButton', () => { expect(queryByTestId('save-timeline-modal')).not.toBeInTheDocument(); }); + it('should override the default text in the button', async () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true }, + }); + mockGetState.mockReturnValue({ + ...mockTimelineModel, + status: TimelineStatusEnum.active, + isSaving: false, + }); + (useCreateTimeline as jest.Mock).mockReturnValue({}); + + const { getByText, queryByText } = render( + <TestProviders> + <SaveTimelineButton timelineId="timeline-1" buttonText={'TEST'} /> + </TestProviders> + ); + + expect(queryByText('Save')).not.toBeInTheDocument(); + expect(getByText('TEST')).toBeInTheDocument(); + }); + it('should open the timeline save modal', async () => { (useUserPrivileges as jest.Mock).mockReturnValue({ kibanaSecuritySolutionsPrivileges: { crud: true }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx index f89471e36827f..3a85022db9fbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx @@ -21,60 +21,77 @@ export interface SaveTimelineButtonProps { * Id of the timeline to be displayed in the bottom bar and within the modal */ timelineId: string; + /** + * Ability to customize the text of the button + */ + buttonText?: string; + /** + * Optional data-test-subj value + */ + ['data-test-subj']?: string; } /** - * Button that allows user to save the timeline. Clicking it opens the `SaveTimelineModal` + * Button that allows user to save the timeline. Clicking it opens the `SaveTimelineModal`. + * The default 'Save' button text can be overridden by passing the `buttonText` prop. */ -export const SaveTimelineButton = React.memo<SaveTimelineButtonProps>(({ timelineId }) => { - const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState<boolean>(false); - const toggleSaveTimeline = useCallback(() => setShowEditTimelineOverlay((prev) => !prev), []); +export const SaveTimelineButton = React.memo<SaveTimelineButtonProps>( + ({ + timelineId, + buttonText = i18n.SAVE, + 'data-test-subj': dataTestSubj = 'timeline-modal-save-timeline', + }) => { + const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState<boolean>(false); + const toggleSaveTimeline = useCallback(() => setShowEditTimelineOverlay((prev) => !prev), []); - // Case: 1 - // check if user has crud privileges so that user can be allowed to edit the timeline - // Case: 2 - // TODO: User may have Crud privileges but they may not have access to timeline index. - // Do we need to check that? - const { - kibanaSecuritySolutionsPrivileges: { crud: canEditTimelinePrivilege }, - } = useUserPrivileges(); + // Case: 1 + // check if user has crud privileges so that user can be allowed to edit the timeline + // Case: 2 + // TODO: User may have Crud privileges but they may not have access to timeline index. + // Do we need to check that? + const { + kibanaSecuritySolutionsPrivileges: { crud: canEditTimelinePrivilege }, + } = useUserPrivileges(); - const { status, isSaving } = useSelector((state: State) => selectTimelineById(state, timelineId)); + const { status, isSaving } = useSelector((state: State) => + selectTimelineById(state, timelineId) + ); - const canSaveTimeline = canEditTimelinePrivilege && status !== TimelineStatusEnum.immutable; - const isUnsaved = status === TimelineStatusEnum.draft; - const unauthorizedMessage = canSaveTimeline ? null : i18n.CALL_OUT_UNAUTHORIZED_MSG; + const canSaveTimeline = canEditTimelinePrivilege && status !== TimelineStatusEnum.immutable; + const isUnsaved = status === TimelineStatusEnum.draft; + const unauthorizedMessage = canSaveTimeline ? null : i18n.CALL_OUT_UNAUTHORIZED_MSG; - return ( - <> - <EuiToolTip - content={unauthorizedMessage} - position="bottom" - data-test-subj="timeline-modal-save-timeline-tooltip" - > - <EuiButton - id={TIMELINE_TOUR_CONFIG_ANCHORS.SAVE_TIMELINE} - fill - size="s" - iconType="save" - isLoading={isSaving} - disabled={!canSaveTimeline} - data-test-subj="timeline-modal-save-timeline" - onClick={toggleSaveTimeline} + return ( + <> + <EuiToolTip + content={unauthorizedMessage} + position="bottom" + data-test-subj="timeline-modal-save-timeline-tooltip" > - {i18n.SAVE} - </EuiButton> - </EuiToolTip> - {showEditTimelineOverlay && canSaveTimeline ? ( - <SaveTimelineModal - initialFocusOn={isUnsaved ? 'title' : 'save'} - timelineId={timelineId} - showWarning={false} - closeSaveTimeline={toggleSaveTimeline} - /> - ) : null} - </> - ); -}); + <EuiButton + id={TIMELINE_TOUR_CONFIG_ANCHORS.SAVE_TIMELINE} + fill + size="s" + iconType="save" + isLoading={isSaving} + disabled={!canSaveTimeline} + data-test-subj={dataTestSubj} + onClick={toggleSaveTimeline} + > + {buttonText} + </EuiButton> + </EuiToolTip> + {showEditTimelineOverlay && canSaveTimeline ? ( + <SaveTimelineModal + initialFocusOn={isUnsaved ? 'title' : 'save'} + timelineId={timelineId} + showWarning={false} + closeSaveTimeline={toggleSaveTimeline} + /> + ) : null} + </> + ); + } +); SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx new file mode 100644 index 0000000000000..71432267ac0da --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx @@ -0,0 +1,202 @@ +/* + * 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 { filter, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiHorizontalRule, +} from '@elastic/eui'; + +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import type { EuiTheme } from '@kbn/react-kibana-context-styled'; +import { timelineActions } from '../../store'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { TimelineStatusEnum } from '../../../../common/api/timeline'; +import { appSelectors } from '../../../common/store/app'; +import { AddNote } from './add_note'; +import { CREATED_BY } from './translations'; +import { PARTICIPANTS } from '../timeline/translations'; +import { NotePreviews } from '../open_timeline/note_previews'; +import type { TimelineResultNote } from '../open_timeline/types'; +import { getTimelineNoteSelector } from '../timeline/tabs/notes/selectors'; +import { getScrollToTopSelector } from '../timeline/tabs/selectors'; +import { useScrollToTop } from '../../../common/components/scroll_to_top'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { FullWidthFlexGroup, VerticalRule } from '../timeline/tabs/shared/layout'; + +const ScrollableDiv = styled.div` + overflow-x: hidden; + overflow-y: auto; + padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM}; + padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS}; +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const Username = styled(EuiText)` + font-weight: bold; +`; + +interface UsernameWithAvatar { + username: string; +} + +const UsernameWithAvatarComponent: React.FC<UsernameWithAvatar> = ({ username }) => ( + <StyledEuiFlexGroup gutterSize="s" responsive={false} alignItems="center"> + <EuiFlexItem grow={false}> + <EuiAvatar data-test-subj="avatar" name={username} size="l" /> + </EuiFlexItem> + <EuiFlexItem> + <Username>{username}</Username> + </EuiFlexItem> + </StyledEuiFlexGroup> +); + +const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); + +interface ParticipantsProps { + users: TimelineResultNote[]; +} + +export const ParticipantsComponent: React.FC<ParticipantsProps> = ({ users }) => { + const List = useMemo( + () => + users.map((user) => ( + <Fragment key={user.updatedBy === null ? undefined : user.updatedBy}> + <UsernameWithAvatar + key={user.updatedBy === null ? undefined : user.updatedBy} + username={String(user.updatedBy)} + /> + <EuiSpacer size="s" /> + </Fragment> + )), + [users] + ); + + if (!users.length) { + return null; + } + + return ( + <> + <EuiTitle size="xs"> + <h4>{PARTICIPANTS}</h4> + </EuiTitle> + <EuiHorizontalRule margin="s" /> + {List} + </> + ); +}; + +ParticipantsComponent.displayName = 'ParticipantsComponent'; + +const Participants = React.memo(ParticipantsComponent); + +interface NotesTabContentProps { + timelineId: string; +} + +/** + * Renders the "old" notes tab content. This should be removed when we remove the securitySolutionNotesEnabled feature flag + */ +export const OldNotes: React.FC<NotesTabContentProps> = React.memo(({ timelineId }) => { + const dispatch = useDispatch(); + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + + const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); + const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); + + useScrollToTop('#scrollableNotes', !!scrollToTop); + + const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); + const { + createdBy, + eventIdToNoteIds, + noteIds, + status: timelineStatus, + } = useDeepEqualSelector((state) => getTimelineNotes(state, timelineId)); + const getNotesAsCommentsList = useMemo( + () => appSelectors.selectNotesAsCommentsListSelector(), + [] + ); + const [newNote, setNewNote] = useState(''); + const isImmutable = timelineStatus === TimelineStatusEnum.immutable; + const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); + + const allTimelineNoteIds = useMemo(() => { + const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>( + (acc, v) => [...acc, ...v], + [] + ); + return [...noteIds, ...eventNoteIds]; + }, [noteIds, eventIdToNoteIds]); + + const notes = useMemo( + () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')), + [appNotes, allTimelineNoteIds] + ); + + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); + + const associateNote = useCallback( + (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), + [dispatch, timelineId] + ); + + const SidebarContent = useMemo( + () => ( + <> + {createdBy && ( + <> + <EuiTitle size="xs"> + <h4>{CREATED_BY}</h4> + </EuiTitle> + <EuiHorizontalRule margin="s" /> + <UsernameWithAvatar username={createdBy} /> + <EuiSpacer size="xxl" /> + </> + )} + <Participants users={participants} /> + </> + ), + [createdBy, participants] + ); + + return ( + <FullWidthFlexGroup gutterSize="none" data-test-subj={'old-notes-screen'}> + <EuiFlexItem component={ScrollableDiv} grow={2} id="scrollableNotes"> + <NotePreviews notes={notes} timelineId={timelineId} showTimelineDescription /> + <EuiSpacer size="s" /> + {!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && ( + <AddNote + associateNote={associateNote} + newNote={newNote} + updateNewNote={setNewNote} + autoFocusDisabled={!!scrollToTop} + /> + )} + </EuiFlexItem> + <VerticalRule /> + <EuiFlexItem component={ScrollableDiv} grow={1}> + {SidebarContent} + </EuiFlexItem> + </FullWidthFlexGroup> + ); +}); + +OldNotes.displayName = 'OldNotes'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx new file mode 100644 index 0000000000000..270750e482a46 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { Participants } from './participants'; +import type { Note } from '../../../../common/api/timeline'; +import { + NOTE_AVATAR_WITH_NAME_TEST_ID, + NOTES_PARTICIPANTS_TITLE_TEST_ID, + TIMELINE_AVATAR_WITH_NAME_TEST_ID, + TIMELINE_PARTICIPANT_TITLE_TEST_ID, +} from './test_ids'; + +const mockNote: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const notes: Note[] = [ + mockNote, + { + ...mockNote, + noteId: '2', + updatedBy: 'elastic', + }, + { + ...mockNote, + noteId: '3', + updatedBy: 'another-elastic', + }, +]; +const username = 'elastic'; + +describe('Participants', () => { + it('should render the timeline username and the unique notes users', () => { + const { getByTestId } = render(<Participants notes={notes} timelineCreatedBy={username} />); + + expect(getByTestId(TIMELINE_PARTICIPANT_TITLE_TEST_ID)).toBeInTheDocument(); + + const timelineDescription = getByTestId(TIMELINE_AVATAR_WITH_NAME_TEST_ID); + expect(timelineDescription).toBeInTheDocument(); + expect(timelineDescription).toHaveTextContent(username); + + expect(getByTestId(NOTES_PARTICIPANTS_TITLE_TEST_ID)).toBeInTheDocument(); + + const firstNoteUser = getByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-0`); + expect(firstNoteUser).toBeInTheDocument(); + expect(firstNoteUser).toHaveTextContent(notes[0].updatedBy as string); + + const secondNoteUser = getByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-1`); + expect(secondNoteUser).toBeInTheDocument(); + expect(secondNoteUser).toHaveTextContent(notes[2].updatedBy as string); + }); + + it('should note render the timeline username if it is unavailable', () => { + const { queryByTestId } = render(<Participants notes={notes} timelineCreatedBy={undefined} />); + + expect(queryByTestId(TIMELINE_PARTICIPANT_TITLE_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should note render any note usernames if no notes have been created', () => { + const { queryByTestId } = render(<Participants notes={[]} timelineCreatedBy={username} />); + + expect(queryByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-0`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx new file mode 100644 index 0000000000000..15662e0a0e6d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx @@ -0,0 +1,144 @@ +/* + * 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 { filter, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiHorizontalRule, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { Fragment, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + NOTE_AVATAR_WITH_NAME_TEST_ID, + NOTES_PARTICIPANTS_TITLE_TEST_ID, + TIMELINE_AVATAR_WITH_NAME_TEST_ID, + TIMELINE_PARTICIPANT_TITLE_TEST_ID, +} from './test_ids'; +import { type Note } from '../../../../common/api/timeline'; + +export const PARTICIPANTS = i18n.translate( + 'xpack.securitySolution.timeline.notes.participantsTitle', + { + defaultMessage: 'Participants', + } +); +export const CREATED_BY = i18n.translate('xpack.securitySolution.timeline notes.createdByLabel', { + defaultMessage: 'Created by', +}); + +interface UsernameWithAvatar { + /** + * The username to display + */ + username: string; + /** + * Data test subject string for testing + */ + ['data-test-subj']?: string; +} + +/** + * Renders the username with an avatar + */ +const UsernameWithAvatar: React.FC<UsernameWithAvatar> = React.memo( + ({ username, 'data-test-subj': dataTestSubj }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <EuiFlexGroup + gutterSize="s" + responsive={false} + alignItems="center" + css={css` + flex-grow: 0; + `} + data-test-subj={dataTestSubj} + > + <EuiFlexItem grow={false}> + <EuiAvatar data-test-subj="avatar" name={username} size="l" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText + css={css` + font-weight: ${euiTheme.font.weight.bold}; + `} + > + {username} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + ); + } +); + +UsernameWithAvatar.displayName = 'UsernameWithAvatar'; + +interface ParticipantsProps { + /** + * The notes associated with the timeline + */ + notes: Note[]; + /** + * The user who created the timeline + */ + timelineCreatedBy: string | undefined; +} + +/** + * Renders all the users that are participating to the timeline + * - the user who created the timeline + * - all the unique users who created notes associated with the timeline + */ +export const Participants: React.FC<ParticipantsProps> = React.memo( + ({ notes, timelineCreatedBy }) => { + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('noteId', notes)), [notes]); + + return ( + <> + {timelineCreatedBy && ( + <> + <EuiTitle size="xs" data-test-subj={TIMELINE_PARTICIPANT_TITLE_TEST_ID}> + <h4>{CREATED_BY}</h4> + </EuiTitle> + <EuiHorizontalRule margin="s" /> + <UsernameWithAvatar + username={timelineCreatedBy} + data-test-subj={TIMELINE_AVATAR_WITH_NAME_TEST_ID} + /> + <EuiSpacer size="xxl" /> + </> + )} + <> + <EuiTitle size="xs" data-test-subj={NOTES_PARTICIPANTS_TITLE_TEST_ID}> + <h4>{PARTICIPANTS}</h4> + </EuiTitle> + <EuiHorizontalRule margin="s" /> + {participants.map((participant, index) => ( + <Fragment key={participant.updatedBy === null ? undefined : participant.updatedBy}> + <UsernameWithAvatar + key={participant.updatedBy === null ? undefined : participant.updatedBy} + username={String(participant.updatedBy)} + data-test-subj={`${NOTE_AVATAR_WITH_NAME_TEST_ID}-${index}`} + /> + <EuiSpacer size="s" /> + </Fragment> + ))} + </> + </> + ); + } +); + +Participants.displayName = 'Participants'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx new file mode 100644 index 0000000000000..7730424befee7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { SaveTimelineCallout } from './save_timeline'; +import { SAVE_TIMELINE_BUTTON_TEST_ID, SAVE_TIMELINE_CALLOUT_TEST_ID } from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../common/mock'; +import { TimelineId } from '../../../../common/types'; + +describe('SaveTimelineCallout', () => { + it('should render the callout and save components', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, + }); + + const { getByTestId, getByText, getAllByText } = render( + <TestProviders store={mockStore}> + <SaveTimelineCallout /> + </TestProviders> + ); + + expect(getByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + expect(getAllByText('Save timeline')).toHaveLength(2); + expect( + getByText('You need to save your timeline before creating notes for it.') + ).toBeInTheDocument(); + expect(getByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx new file mode 100644 index 0000000000000..f31822561c54b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { SAVE_TIMELINE_BUTTON_TEST_ID, SAVE_TIMELINE_CALLOUT_TEST_ID } from './test_ids'; +import { TimelineId } from '../../../../common/types'; +import { SaveTimelineButton } from '../modal/actions/save_timeline_button'; + +export const SAVE_TIMELINE_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.notes.saveTimeline.calloutTitle', + { + defaultMessage: 'Save timeline', + } +); +export const SAVE_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.timeline.notes.saveTimeline.calloutContent', + { + defaultMessage: 'You need to save your timeline before creating notes for it.', + } +); +export const SAVE_TIMELINE_BUTTON = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.savedTimelineButtonLabel', + { + defaultMessage: 'Save timeline', + } +); + +/** + * Renders a callout to let the user know they have to save the timeline before creating notes + */ +export const SaveTimelineCallout = memo(() => { + return ( + <EuiCallOut + title={SAVE_TIMELINE_CALLOUT_TITLE} + color="warning" + iconType="iInCircle" + data-test-subj={SAVE_TIMELINE_CALLOUT_TEST_ID} + css={css` + margin-left: 50px; + `} + > + <EuiFlexGroup justifyContent="spaceBetween" responsive={false}> + <EuiFlexItem> + <EuiText size="s">{SAVE_TIMELINE_CALLOUT_CONTENT}</EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <SaveTimelineButton + timelineId={TimelineId.active} + buttonText={SAVE_TIMELINE_BUTTON} + data-test-subj={SAVE_TIMELINE_BUTTON_TEST_ID} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiCallOut> + ); +}); + +SaveTimelineCallout.displayName = 'SaveTimelineCallout'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts b/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts new file mode 100644 index 0000000000000..5e5637b9b321f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const PREFIX = 'timelineNotes'; + +export const SAVE_TIMELINE_CALLOUT_TEST_ID = `${PREFIX}SaveTimelineCallout` as const; +export const TIMELINE_PARTICIPANT_TITLE_TEST_ID = `${PREFIX}TimelineParticipantTitle` as const; +export const TIMELINE_AVATAR_WITH_NAME_TEST_ID = `${PREFIX}TimelineAvatarWithName` as const; +export const NOTES_PARTICIPANTS_TITLE_TEST_ID = `${PREFIX}NotesParticipantsTitle` as const; +export const NOTE_AVATAR_WITH_NAME_TEST_ID = `${PREFIX}NoteAvatarWithName` as const; +export const SAVE_TIMELINE_BUTTON_TEST_ID = `${PREFIX}SaveTimelineButton` as const; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 7f117d88bf531..7c1e7eeca37a3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -264,7 +264,7 @@ describe('NotePreviews', () => { } ); - expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="notes-toggle-event-details"]`).exists()).toBeTruthy(); }); test('should not render toggle event details action when showToggleEventDetailsAction is false ', () => { @@ -293,7 +293,7 @@ describe('NotePreviews', () => { } ); - expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="notes-toggle-event-details"]`).exists()).toBeFalsy(); }); describe('Delete Notes', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx index 2401e0014fd8a..442f7548bcf73 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx @@ -9,9 +9,11 @@ import { EuiBadge, EuiSkeletonText, EuiTabs, EuiTab } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import type { Ref, ReactElement, ComponentType } from 'react'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import type { State } from '../../../../common/store'; import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability'; import type { SessionViewConfig } from '../../../../../common/types'; import type { RowRenderer, TimelineId } from '../../../../../common/types/timeline'; @@ -38,7 +40,8 @@ import { import * as i18n from './translations'; import { useLicense } from '../../../../common/hooks/use_license'; import { initializeTimelineSettings } from '../../../store/actions'; -import { selectTimelineESQLSavedSearchId } from '../../../store/selectors'; +import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../store/selectors'; +import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>( ({ $isVisible = false, isOverflowYScroll = false }) => ({ @@ -248,6 +251,10 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({ selectTimelineESQLSavedSearchId(state, timelineId) ); + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); + const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); const shouldShowESQLTab = useMemo(() => { @@ -273,6 +280,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({ const isEnterprisePlus = useLicense().isEnterprise(); + // old notes system (through timeline) const allTimelineNoteIds = useMemo(() => { const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>( (acc, v) => [...acc, ...v], @@ -281,13 +289,43 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({ return [...globalTimelineNoteIds, ...eventNoteIds]; }, [globalTimelineNoteIds, eventIdToNoteIds]); - const numberOfNotes = useMemo( + const numberOfNotesOldSystem = useMemo( () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length + (isEmpty(timelineDescription) ? 0 : 1), [appNotes, allTimelineNoteIds, timelineDescription] ); + const timeline = useSelector((state: State) => selectTimelineById(state, timelineId)); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] + ); + + // new note system + const fetchNotes = useCallback( + () => dispatch(fetchNotesBySavedObjectIds({ savedObjectIds: [timelineSavedObjectId] })), + [dispatch, timelineSavedObjectId] + ); + useEffect(() => { + if (isTimelineSaved) { + fetchNotes(); + } + }, [fetchNotes, isTimelineSaved]); + + const numberOfNotesNewSystem = useSelector((state: State) => + selectSortedNotesBySavedObjectId(state, { + savedObjectId: timelineSavedObjectId, + sort: { field: 'created', direction: 'asc' }, + }) + ); + + const numberOfNotes = useMemo( + () => (securitySolutionNotesEnabled ? numberOfNotesNewSystem.length : numberOfNotesOldSystem), + [numberOfNotesNewSystem, numberOfNotesOldSystem, securitySolutionNotesEnabled] + ); + const setActiveTab = useCallback( (tab: TimelineTabs) => { dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: tab })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx new file mode 100644 index 0000000000000..e70bd5946e3b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx @@ -0,0 +1,221 @@ +/* + * 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 { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import NotesTabContentComponent, { FETCH_NOTES_ERROR, NO_NOTES } from '.'; +import { render } from '@testing-library/react'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../../common/mock'; +import { ReqStatus } from '../../../../../notes'; +import { + NOTES_LOADING_TEST_ID, + TIMELINE_DESCRIPTION_COMMENT_TEST_ID, +} from '../../../../../notes/components/test_ids'; +import React from 'react'; +import { TimelineId } from '../../../../../../common/types'; +import { SAVE_TIMELINE_CALLOUT_TEST_ID } from '../../../notes/test_ids'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; + +jest.mock('../../../../../common/hooks/use_experimental_features'); +jest.mock('../../../../../common/components/user_privileges'); + +const mockAddError = jest.fn(); +jest.mock('../../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockGlobalStateWithSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, +}; +const mockGlobalStateWithUnSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, +}; + +describe('NotesTabContentComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useUserPrivileges as jest.Mock).mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true }, + }); + }); + + it('should show the old note system', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + + const { getByTestId, queryByTestId } = render( + <TestProviders> + <NotesTabContentComponent timelineId={TimelineId.test} /> + </TestProviders> + ); + + expect(getByTestId('old-notes-screen')).toBeInTheDocument(); + expect(queryByTestId('new-notes-screen')).not.toBeInTheDocument(); + }); + + it('should show the new note system', () => { + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + const { getByTestId, queryByTestId } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.test} /> + </TestProviders> + ); + + expect(getByTestId('new-notes-screen')).toBeInTheDocument(); + expect(queryByTestId('old-notes-screen')).not.toBeInTheDocument(); + }); + + it('should fetch notes for the saved object id if timeline has been saved and hide callout', () => { + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + const { queryByTestId } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.active} /> + </TestProviders> + ); + + expect(mockDispatch).toHaveBeenCalled(); + expect(queryByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not fetch notes if timeline is unsaved', () => { + const mockStore = createMockStore(mockGlobalStateWithUnSavedTimeline); + + const { getByTestId } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.test} /> + </TestProviders> + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + expect(getByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render loading spinner if notes are being fetched', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.test} /> + </TestProviders> + ); + + expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should render no data message if no notes are present and timeline has been saved', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }, + }); + + const { getByText } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.active} /> + </TestProviders> + ); + + expect(getByText(NO_NOTES)).toBeInTheDocument(); + }); + + it('should render error toast if fetching notes fails', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Failed, + }, + error: { + ...mockGlobalStateWithSavedTimeline.notes.error, + fetchNotesBySavedObjectIds: { type: 'http', status: 500 }, + }, + }, + }); + + render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.test} /> + </TestProviders> + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: FETCH_NOTES_ERROR, + }); + }); + + it('should render the timeline description at the top', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + timeline: { + ...mockGlobalStateWithSavedTimeline.timeline, + timelineById: { + ...mockGlobalStateWithSavedTimeline.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalStateWithSavedTimeline.timeline.timelineById[TimelineId.active], + description: 'description', + }, + }, + }, + }); + + const { getByTestId, getByText } = render( + <TestProviders store={mockStore}> + <NotesTabContentComponent timelineId={TimelineId.active} /> + </TestProviders> + ); + + expect(getByTestId(TIMELINE_DESCRIPTION_COMMENT_TEST_ID)).toBeInTheDocument(); + expect(getByText('description')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx index 27d82f01828fe..959581a241764 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx @@ -5,214 +5,212 @@ * 2.0. */ -import { filter, uniqBy } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { EuiAvatar, + EuiComment, EuiFlexGroup, EuiFlexItem, + EuiLoadingElastic, + EuiPanel, EuiSpacer, EuiText, EuiTitle, - EuiPanel, - EuiHorizontalRule, } from '@elastic/eui'; - -import React, { Fragment, useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; - -import type { EuiTheme } from '@kbn/react-kibana-context-styled'; -import { timelineActions } from '../../../../store'; +import { css } from '@emotion/react'; +import { useDispatch, useSelector } from 'react-redux'; +import { FormattedRelative } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { SaveTimelineCallout } from '../../../notes/save_timeline'; +import { AddNote } from '../../../../../notes/components/add_note'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { + NOTES_LOADING_TEST_ID, + TIMELINE_DESCRIPTION_COMMENT_TEST_ID, +} from '../../../../../notes/components/test_ids'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import { ADDED_A_DESCRIPTION } from '../../../open_timeline/note_previews/translations'; +import { defaultToEmptyTag, getEmptyValue } from '../../../../../common/components/empty_value'; +import { selectTimelineById } from '../../../../store/selectors'; import { - useDeepEqualSelector, - useShallowEqualSelector, -} from '../../../../../common/hooks/use_selector'; -import { TimelineStatusEnum } from '../../../../../../common/api/timeline'; -import { appSelectors } from '../../../../../common/store/app'; -import { AddNote } from '../../../notes/add_note'; -import { CREATED_BY, NOTES } from '../../../notes/translations'; -import { PARTICIPANTS } from '../../translations'; -import { NotePreviews } from '../../../open_timeline/note_previews'; -import type { TimelineResultNote } from '../../../open_timeline/types'; -import { getTimelineNoteSelector } from './selectors'; + fetchNotesBySavedObjectIds, + ReqStatus, + selectFetchNotesBySavedObjectIdsError, + selectFetchNotesBySavedObjectIdsStatus, + selectSortedNotesBySavedObjectId, +} from '../../../../../notes'; +import type { Note } from '../../../../../../common/api/timeline'; +import { NotesList } from '../../../../../notes/components/notes_list'; +import { OldNotes } from '../../../notes/old_notes'; +import { Participants } from '../../../notes/participants'; +import { NOTES } from '../../../notes/translations'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { getScrollToTopSelector } from '../selectors'; import { useScrollToTop } from '../../../../../common/components/scroll_to_top'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { FullWidthFlexGroup, VerticalRule } from '../shared/layout'; - -const ScrollableDiv = styled.div` - overflow-x: hidden; - overflow-y: auto; - padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM}; - padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS}; -`; - -const StyledPanel = styled(EuiPanel)` - border: 0; - box-shadow: none; -`; - -const StyledEuiFlexGroup = styled(EuiFlexGroup)` - flex: 0; -`; - -const Username = styled(EuiText)` - font-weight: bold; -`; - -interface UsernameWithAvatar { - username: string; -} +import type { State } from '../../../../../common/store'; -const UsernameWithAvatarComponent: React.FC<UsernameWithAvatar> = ({ username }) => ( - <StyledEuiFlexGroup gutterSize="s" responsive={false} alignItems="center"> - <EuiFlexItem grow={false}> - <EuiAvatar data-test-subj="avatar" name={username} size="l" /> - </EuiFlexItem> - <EuiFlexItem> - <Username>{username}</Username> - </EuiFlexItem> - </StyledEuiFlexGroup> -); - -const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); - -interface ParticipantsProps { - users: TimelineResultNote[]; -} - -const ParticipantsComponent: React.FC<ParticipantsProps> = ({ users }) => { - const List = useMemo( - () => - users.map((user) => ( - <Fragment key={user.updatedBy === null ? undefined : user.updatedBy}> - <UsernameWithAvatar - key={user.updatedBy === null ? undefined : user.updatedBy} - username={String(user.updatedBy)} - /> - <EuiSpacer size="s" /> - </Fragment> - )), - [users] - ); - - if (!users.length) { - return null; +export const FETCH_NOTES_ERROR = i18n.translate( + 'xpack.securitySolution.notes.fetchNotesErrorLabel', + { + defaultMessage: 'Error fetching notes', } - - return ( - <> - <EuiTitle size="xs"> - <h4>{PARTICIPANTS}</h4> - </EuiTitle> - <EuiHorizontalRule margin="s" /> - {List} - </> - ); -}; - -ParticipantsComponent.displayName = 'ParticipantsComponent'; - -const Participants = React.memo(ParticipantsComponent); +); +export const NO_NOTES = i18n.translate('xpack.securitySolution.notes.noNotesLabel', { + defaultMessage: 'No notes have yet been created for this timeline', +}); interface NotesTabContentProps { + /** + * The timeline id + */ timelineId: string; } -const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }) => { +/** + * Renders the notes tab content. + * At this time the component support the old notes system and the new notes system (via the securitySolutionNotesEnabled feature flag). + * The old notes system is deprecated and will be removed in the future. + * In both cases, the component fetches the notes for the timeline and renders: + * - the timeline description + * - the notes list + * - the participants list + * - the markdown to create a new note and the add note button + */ +const NotesTabContentComponent: React.FC<NotesTabContentProps> = React.memo(({ timelineId }) => { + const { addError: addErrorToast } = useAppToasts(); const dispatch = useDispatch(); + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + const canCreateNotes = kibanaSecuritySolutionsPrivileges.crud; + + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); - useScrollToTop('#scrollableNotes', !!scrollToTop); - const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); - const { - createdBy, - eventIdToNoteIds, - noteIds, - status: timelineStatus, - } = useDeepEqualSelector((state) => getTimelineNotes(state, timelineId)); - const getNotesAsCommentsList = useMemo( - () => appSelectors.selectNotesAsCommentsListSelector(), - [] + const timeline = useSelector((state: State) => selectTimelineById(state, timelineId)); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] ); - const [newNote, setNewNote] = useState(''); - const isImmutable = timelineStatus === TimelineStatusEnum.immutable; - const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); - - const allTimelineNoteIds = useMemo(() => { - const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>( - (acc, v) => [...acc, ...v], - [] - ); - return [...noteIds, ...eventNoteIds]; - }, [noteIds, eventIdToNoteIds]); - const notes = useMemo( - () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')), - [appNotes, allTimelineNoteIds] + const fetchNotes = useCallback( + () => dispatch(fetchNotesBySavedObjectIds({ savedObjectIds: [timelineSavedObjectId] })), + [dispatch, timelineSavedObjectId] ); - // filter for savedObjectId to make sure we don't display `elastic` user while saving the note - const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); - - const associateNote = useCallback( - (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - [dispatch, timelineId] + useEffect(() => { + if (isTimelineSaved) { + fetchNotes(); + } + }, [fetchNotes, isTimelineSaved]); + + const notes: Note[] = useSelector((state: State) => + selectSortedNotesBySavedObjectId(state, { + savedObjectId: timelineSavedObjectId, + sort: { field: 'created', direction: 'asc' }, + }) ); - - const SidebarContent = useMemo( - () => ( + const fetchStatus = useSelector((state: State) => selectFetchNotesBySavedObjectIdsStatus(state)); + const fetchError = useSelector((state: State) => selectFetchNotesBySavedObjectIdsError(state)); + + // show a toast if the fetch notes call fails + useEffect(() => { + if (fetchStatus === ReqStatus.Failed && fetchError) { + addErrorToast(null, { + title: FETCH_NOTES_ERROR, + }); + } + }, [addErrorToast, fetchError, fetchStatus]); + + // if timeline was saved with a description, we show it at the very top of the notes tab + const timelineDescription = useMemo(() => { + if (!timeline?.description) { + return null; + } + + return ( <> - {createdBy && ( - <> - <EuiTitle size="xs"> - <h4>{CREATED_BY}</h4> - </EuiTitle> - <EuiHorizontalRule margin="s" /> - <UsernameWithAvatar username={createdBy} /> - <EuiSpacer size="xxl" /> - </> - )} - <Participants users={participants} /> + <EuiComment + key={'note-preview-description'} + username={defaultToEmptyTag(timeline.updatedBy)} + timestamp={ + <> + {timeline.updated ? ( + <FormattedRelative data-test-subj="updated" value={new Date(timeline.updated)} /> + ) : ( + getEmptyValue() + )} + </> + } + event={ADDED_A_DESCRIPTION} + timelineAvatar={<EuiAvatar size="l" name={timeline.updatedBy || '?'} />} + data-test-subj={TIMELINE_DESCRIPTION_COMMENT_TEST_ID} + > + <EuiText size="s">{timeline.description}</EuiText> + </EuiComment> + <EuiSpacer /> </> - ), - [createdBy, participants] - ); + ); + }, [timeline.description, timeline.updated, timeline.updatedBy]); return ( - <FullWidthFlexGroup gutterSize="none"> - <EuiFlexItem component={ScrollableDiv} grow={2} id="scrollableNotes"> - <StyledPanel paddingSize="none"> + <EuiPanel> + <EuiFlexGroup direction="column"> + <EuiFlexItem grow={false}> <EuiTitle> <h3>{NOTES}</h3> </EuiTitle> - <NotePreviews notes={notes} timelineId={timelineId} showTimelineDescription /> - <EuiSpacer size="s" /> - {!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && ( - <AddNote - associateNote={associateNote} - newNote={newNote} - updateNewNote={setNewNote} - autoFocusDisabled={!!scrollToTop} - /> + </EuiFlexItem> + <EuiFlexItem> + {securitySolutionNotesEnabled ? ( + <EuiFlexGroup data-test-subj={'new-notes-screen'}> + <EuiFlexItem> + {timelineDescription} + {fetchStatus === ReqStatus.Loading && ( + <EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" /> + )} + {isTimelineSaved && fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? ( + <EuiFlexGroup justifyContent="center"> + <EuiFlexItem grow={false}> + <p>{NO_NOTES}</p> + </EuiFlexItem> + </EuiFlexGroup> + ) : ( + <NotesList notes={notes} options={{ hideTimelineIcon: true }} /> + )} + {canCreateNotes && ( + <> + <EuiSpacer /> + <AddNote timelineId={timeline.savedObjectId} disableButton={!isTimelineSaved}> + {!isTimelineSaved && <SaveTimelineCallout />} + </AddNote> + </> + )} + </EuiFlexItem> + <EuiFlexItem + css={css` + max-width: 350px; + `} + > + <Participants notes={notes} timelineCreatedBy={timeline.createdBy} /> + </EuiFlexItem> + </EuiFlexGroup> + ) : ( + <OldNotes timelineId={timelineId} /> )} - </StyledPanel> - </EuiFlexItem> - <VerticalRule /> - <EuiFlexItem component={ScrollableDiv} grow={1}> - {SidebarContent} - </EuiFlexItem> - </FullWidthFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> ); -}; +}); NotesTabContentComponent.displayName = 'NotesTabContentComponent'; -const NotesTabContent = React.memo(NotesTabContentComponent); - // eslint-disable-next-line import/no-default-export -export { NotesTabContent as default }; +export { NotesTabContentComponent as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx index 7e5e9a221ffee..2fa1b53881b08 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx @@ -37,6 +37,7 @@ import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/com import * as timelineActions from '../../../../store/actions'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from '../../../../../notes/components/test_ids'; jest.mock('../../../../../common/components/user_privileges'); @@ -1004,7 +1005,7 @@ describe('query tab with unified timeline', () => { fireEvent.click(screen.getByTestId('timeline-notes-button-small')); await waitFor(() => { - expect(screen.queryByTestId('notes-toggle-event-details')).not.toBeInTheDocument(); + expect(screen.queryByTestId(OPEN_FLYOUT_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); }, SPECIAL_TEST_TIMEOUT diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts index 30ae41c1da820..4fd39ade7df0c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import { serverMock, @@ -23,20 +24,20 @@ const getAllNotesRequest = (query?: GetNotesRequestQuery) => query, }); -const createMockedNotes = (numberOfNotes: number) => { - return Array.from({ length: numberOfNotes }, (_, index) => { - return { - id: index + 1, - timelineId: 'timeline', - eventId: 'event', - note: `test note ${index}`, - created: 1280120812453, - createdBy: 'test', - updated: 108712801280, - updatedBy: 'test', - }; - }); -}; +const createMockedNotes = ( + numberOfNotes: number, + options?: { documentId?: string; savedObjectId?: string } +) => + Array.from({ length: numberOfNotes }, () => ({ + id: uuidv4(), + timelineId: options?.savedObjectId || 'timeline', + eventId: options?.documentId || 'event', + note: `test note`, + created: 1280120812453, + createdBy: 'test', + updated: 108712801280, + updatedBy: 'test', + })); describe('get notes route', () => { let server: ReturnType<typeof serverMock.create>; @@ -45,7 +46,7 @@ describe('get notes route', () => { let mockGetAllSavedNote: jest.Mock; beforeEach(() => { - jest.clearAllMocks(); + jest.resetModules(); server = serverMock.create(); context = requestContextMock.createTools().context; @@ -61,14 +62,16 @@ describe('get notes route', () => { jest.doMock('../../saved_object/notes', () => ({ getAllSavedNote: mockGetAllSavedNote, })); + const getNotesRoute = jest.requireActual('.').getNotesRoute; getNotesRoute(server.router, createMockConfig(), securitySetup); }); test('should return a list of notes and the count by default', async () => { + const mockNotes = createMockedNotes(3); mockGetAllSavedNote.mockResolvedValue({ - notes: createMockedNotes(5), - totalCount: 5, + notes: mockNotes, + totalCount: mockNotes.length, }); const response = await server.inject( @@ -78,8 +81,88 @@ describe('get notes route', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({ - totalCount: 5, - notes: createMockedNotes(5), + notes: mockNotes, + totalCount: mockNotes.length, + }); + }); + + test('should return a list of notes filtered by an array of document ids', async () => { + const documentId = 'document1'; + const mockDocumentNotes = createMockedNotes(3, { documentId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ documentIds: [documentId] }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + }); + + test('should return a list of notes filtered by a single document id', async () => { + const documentId = 'document2'; + const mockDocumentNotes = createMockedNotes(3, { documentId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ documentIds: documentId }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + }); + + test('should return a list of notes filtered by an array of saved object ids', async () => { + const savedObjectId = 'savedObject1'; + const mockSavedObjectIdNotes = createMockedNotes(3, { savedObjectId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ savedObjectIds: [savedObjectId] }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + }); + + test('should return a list of notes filtered by a single saved object id', async () => { + const savedObjectId = 'savedObject2'; + const mockSavedObjectIdNotes = createMockedNotes(3, { savedObjectId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ savedObjectIds: savedObjectId }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 920a7ef763dd5..2794fd5d8cd7d 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -9,6 +9,7 @@ import type { IKibanaResponse } from '@kbn/core-http-server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { timelineSavedObjectType } from '../../saved_object_mappings'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { NOTE_URL } from '../../../../../common/constants'; @@ -39,6 +40,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { const queryParams = request.query; const frameworkRequest = await buildFrameworkRequest(context, request); const documentIds = queryParams.documentIds ?? null; + const savedObjectIds = queryParams.savedObjectIds ?? null; if (documentIds != null) { if (Array.isArray(documentIds)) { const docIdSearchString = documentIds?.join(' | '); @@ -61,6 +63,34 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { const res = await getAllSavedNote(frameworkRequest, options); return response.ok({ body: res ?? {} }); } + } else if (savedObjectIds != null) { + if (Array.isArray(savedObjectIds)) { + const soIdSearchString = savedObjectIds?.join(' | '); + const options = { + type: noteSavedObjectType, + hasReference: { + type: timelineSavedObjectType, + id: soIdSearchString, + }, + page: 1, + perPage: MAX_UNASSOCIATED_NOTES, + }; + const res = await getAllSavedNote(frameworkRequest, options); + const body: GetNotesResponse = res ?? {}; + return response.ok({ body }); + } else { + const options = { + type: noteSavedObjectType, + hasReference: { + type: timelineSavedObjectType, + id: savedObjectIds, + }, + perPage: MAX_UNASSOCIATED_NOTES, + }; + const res = await getAllSavedNote(frameworkRequest, options); + const body: GetNotesResponse = res ?? {}; + return response.ok({ body }); + } } else { const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1; From c7e44c834e1e4ed5581bfef4f95b5456203612d0 Mon Sep 17 00:00:00 2001 From: Tim Sullivan <tsullivan@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:45:48 -0700 Subject: [PATCH 10/11] [Reporting] Unskip tests on access to download link in stack management (#193353) ## Summary Closes #192014 Flaky test runner: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/7021 --- .../reporting_and_security/management.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index e1d124ede931e..f48dbd3271f70 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -15,8 +15,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { const reportingFunctional = getService('reportingFunctional'); const esArchiver = getService('esArchiver'); - // Failing: See https://github.com/elastic/kibana/issues/192014 - describe.skip('Access to Management > Reporting', () => { + describe('Access to Management > Reporting', () => { before(async () => { await reportingFunctional.initEcommerce(); }); @@ -59,22 +58,27 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { describe('Download report', () => { // use archived reports to allow reporting_user to view report jobs they've created - before(async () => { + before('log in as reporting user', async () => { await esArchiver.load('x-pack/test/functional/es_archives/reporting/archived_reports'); - - await reportingFunctional.loginReportingUser(); - await PageObjects.common.navigateToApp('reporting'); - await testSubjects.existOrFail('reportJobListing'); }); + after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/reporting/archived_reports'); }); it('user can access download link', async () => { + await reportingFunctional.loginReportingUser(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.existOrFail('reportJobListing'); + await testSubjects.existOrFail('reportDownloadLink-kraz9db6154g0763b5141viu'); }); it('user can access download link for export type that is no longer supported', async () => { + await reportingFunctional.loginReportingUser(); + await PageObjects.common.navigateToApp('reporting'); + await testSubjects.existOrFail('reportJobListing'); + // The "csv" export type, aka CSV V1, was removed and can no longer be created. // Downloading a report of this export type does still work await testSubjects.existOrFail('reportDownloadLink-krb7arhe164k0763b50bjm31'); From 4a2ae7aa5129d0f83c2da3c34d39aa42dc1c4f8e Mon Sep 17 00:00:00 2001 From: "elastic-renovate-prod[bot]" <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:54:00 -0700 Subject: [PATCH 11/11] Update ftr (main) (#194509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | Pending | |---|---|---|---|---| | [chromedriver](https://togithub.com/giggio/node-chromedriver) | devDependencies | major | [`^128.0.3` -> `^129.0.0`](https://renovatebot.com/diffs/npm/chromedriver/128.0.3/129.0.0) | `129.0.1` | | [selenium-webdriver](https://togithub.com/SeleniumHQ/selenium/tree/trunk/javascript/node/selenium-webdriver#readme) ([source](https://togithub.com/SeleniumHQ/selenium)) | devDependencies | patch | [`^4.24.1` -> `^4.25.0`](https://renovatebot.com/diffs/npm/selenium-webdriver/4.25.0/4.25.0) | | --- ### Release Notes <details> <summary>giggio/node-chromedriver (chromedriver)</summary> ### [`v129.0.0`](https://togithub.com/giggio/node-chromedriver/compare/128.0.3...129.0.0) [Compare Source](https://togithub.com/giggio/node-chromedriver/compare/128.0.3...129.0.0) </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://togithub.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://togithub.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjUuMSIsInVwZGF0ZWRJblZlciI6IjM3LjQyNS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJUZWFtOk9wZXJhdGlvbnMiLCJyZWxlYXNlX25vdGU6c2tpcCJdfQ==--> Co-authored-by: elastic-renovate-prod[bot] <174716857+elastic-renovate-prod[bot]@users.noreply.github.com> --- package.json | 4 ++-- yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9c19effacf164..525bedcab5ef9 100644 --- a/package.json +++ b/package.json @@ -1672,7 +1672,7 @@ "buildkite-test-collector": "^1.7.0", "callsites": "^3.1.0", "chance": "1.0.18", - "chromedriver": "^128.0.3", + "chromedriver": "^129.0.0", "clean-webpack-plugin": "^3.0.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.1", @@ -1797,7 +1797,7 @@ "rxjs-marbles": "^7.0.1", "sass-embedded": "^1.78.0", "sass-loader": "^10.5.1", - "selenium-webdriver": "^4.24.1", + "selenium-webdriver": "^4.25.0", "sharp": "0.32.6", "simple-git": "^3.16.0", "sinon": "^7.4.2", diff --git a/yarn.lock b/yarn.lock index ac4dfd08d254d..8146185716b37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14211,10 +14211,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^128.0.3: - version "128.0.3" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-128.0.3.tgz#7c2cd2d160f269e78f40840ee7a043dac3687148" - integrity sha512-Xn/bknOpGlY9tKinwS/hVWeNblSeZvbbJbF8XZ73X1jeWfAFPRXx3fMLdNNz8DqruDbx3cKEJ5wR3mnst6G3iw== +chromedriver@^129.0.0: + version "129.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-129.0.1.tgz#4a7215f57c419e87468f04f3b011db51e6bc80fc" + integrity sha512-thJqK3c7p9rIhmjBvs/cgaK0Hk30g7LbnmMXQ2aLnn75ZOiEl/2GBcgc6fw+4GIw1SmOYhnNmaEI1iTP3qob0w== dependencies: "@testim/chrome-version" "^1.1.4" axios "^1.7.4" @@ -28374,7 +28374,7 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@^4.24.1: +selenium-webdriver@^4.25.0: version "4.25.0" resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.25.0.tgz#3562b49668817974bb1d13d25a50e8bc0264fcf3" integrity sha512-zl9IX93caOT8wbcCpZzAkEtYa+hNgJ4C5GUN8uhpzggqRLvsg1asfKi0p1uNZC8buYVvsBZbx8S+9MjVAjs4oA==