From 0da7bbe3186d9dd9b5b53bb0d9994aa3fcb61349 Mon Sep 17 00:00:00 2001 From: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:27:09 +0200 Subject: [PATCH 01/13] [ESQL] Update inline docs for 8.15 (#186835) Aggregations, operators, and commands still need to be added manually - `::` is the only one so far landing in 8.15.0 --------- Co-authored-by: Stratoula Kalafateli --- .../src/esql_documentation_sections.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx b/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx index 080151acbb829..df32875a33665 100644 --- a/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx +++ b/packages/kbn-text-based-editor/src/esql_documentation_sections.tsx @@ -4456,6 +4456,33 @@ The following boolean operators are supported: /> ), }, + { + label: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.castOperator', + { + defaultMessage: 'Cast (::)', + } + ), + description: ( + \` type conversion functions. + +Example: +\`\`\` +ROW ver = CONCAT(("0"::INT + 1)::STRING, ".2.3")::VERSION +\`\`\` + `, + description: + 'Text is in markdown. Do not translate function names, special characters, or field names like sum(bytes)', + } + )} + /> + ), + }, { label: i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator', From 1fa094b7ac9b90e321338770aad9fed028533840 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 2 Jul 2024 03:51:28 -0400 Subject: [PATCH 02/13] [Security Solution][Timeline] Notes management table (#187214) --- .../link_to/redirect_to_timelines.tsx | 2 +- .../public/common/mock/global_state.ts | 23 +- .../left/components/notes_list.test.tsx | 26 +- .../left/components/notes_list.tsx | 12 +- .../public/management/links.ts | 20 - .../security_solution/public/notes/api/api.ts | 38 +- .../notes/components/delete_confirm_modal.tsx | 50 ++ .../public/notes/components/search_row.tsx | 64 +++ .../public/notes/components/translations.ts | 104 ++++ .../public/notes/components/utility_bar.tsx | 105 ++++ .../security_solution/public/notes/index.ts | 9 + .../notes/pages/note_management_page.tsx | 201 ++++++- .../public/notes/store/notes.slice.test.ts | 520 ++++++++++++++---- .../public/notes/store/notes.slice.ts | 163 +++++- .../edit_timeline_batch_actions.tsx | 2 +- .../components/open_timeline/index.tsx | 20 +- .../open_timeline/open_timeline.tsx | 159 +++--- .../open_timeline_modal_body.tsx | 8 +- .../timelines_table/actions_columns.tsx | 42 +- .../timelines_table/common_columns.tsx | 10 +- .../timelines_table/extended_columns.tsx | 4 +- .../timelines_table/icon_header_columns.tsx | 9 +- .../open_timeline/timelines_table/index.tsx | 92 ++-- .../open_timeline/timelines_table/mocks.ts | 1 + .../components/open_timeline/types.ts | 10 +- .../open_timeline/use_timeline_types.test.tsx | 31 +- .../open_timeline/use_timeline_types.tsx | 35 +- .../public/timelines/links.ts | 13 +- .../public/timelines/pages/index.tsx | 2 +- .../public/timelines/pages/timelines_page.tsx | 5 +- 30 files changed, 1412 insertions(+), 368 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/search_row.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/translations.ts create mode 100644 x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/index.ts 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 2423d3493d9eb..98f47334ceca7 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 { TimelineTypeLiteral } from '../../../../common/api/timeline'; import { appendSearch } from './helpers'; -export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => +export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral | 'notes', search?: string) => `/${tabName}${appendSearch(search)}`; export const getTimelineUrl = (id: string, graphEventId?: string) => 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 6aa38d25806a8..0a8aebee35f55 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 @@ -504,10 +504,9 @@ export const mockGlobalState: State = { discover: getMockDiscoverInTimelineState(), dataViewPicker: dataViewPickerInitialState, notes: { - ids: ['1'], entities: { '1': { - eventId: 'event-id', + eventId: 'document-id-1', noteId: '1', note: 'note-1', timelineId: 'timeline-1', @@ -518,15 +517,31 @@ export const mockGlobalState: State = { version: 'version', }, }, + ids: ['1'], status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, - deleteNote: null, + deleteNotes: null, + fetchNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 0, + }, + sort: { + field: 'created' as const, + direction: 'desc' as const, }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }, }; 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 index 8491804e1a572..e35d71ec28d55 100644 --- 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 @@ -41,7 +41,7 @@ jest.mock('react-redux', () => { const renderNotesList = () => render( - + ); @@ -69,7 +69,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); @@ -115,7 +115,7 @@ describe('NotesList', () => { render( - + ); @@ -131,7 +131,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, entities: { '1': { - eventId: 'event-id', + eventId: 'document-id-1', noteId: '1', note: 'note-1', timelineId: '', @@ -147,7 +147,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); @@ -169,7 +169,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); @@ -196,14 +196,14 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Loading, + deleteNotes: ReqStatus.Loading, }, }, }); const { getByTestId } = render( - + ); @@ -217,18 +217,18 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Failed, + deleteNotes: ReqStatus.Failed, }, error: { ...mockGlobalState.notes.error, - deleteNote: { type: 'http', status: 500 }, + deleteNotes: { type: 'http', status: 500 }, }, }, }); render( - + ); @@ -261,7 +261,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, entities: { '1': { - eventId: 'event-id', + eventId: 'document-id-1', noteId: '1', note: 'note-1', timelineId: '', @@ -277,7 +277,7 @@ describe('NotesList', () => { const { queryByTestId } = render( - + ); 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 index c27f8441c103a..51ee119499fd1 100644 --- 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 @@ -30,11 +30,11 @@ import { import type { State } from '../../../../common/store'; import type { Note } from '../../../../../common/api/timeline'; import { - deleteNote, + deleteNotes, ReqStatus, selectCreateNoteStatus, - selectDeleteNoteError, - selectDeleteNoteStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, selectFetchNotesByDocumentIdsError, selectFetchNotesByDocumentIdsStatus, selectNotesByDocumentId, @@ -91,14 +91,14 @@ export const NotesList = memo(({ eventId }: NotesListProps) => { const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - const deleteStatus = useSelector((state: State) => selectDeleteNoteStatus(state)); - const deleteError = useSelector((state: State) => selectDeleteNoteError(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(deleteNote({ id: noteId })); + dispatch(deleteNotes({ ids: [noteId] })); }, [dispatch] ); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 06d47e2936115..91bf4e958f6fb 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -22,7 +22,6 @@ import { EVENT_FILTERS_PATH, HOST_ISOLATION_EXCEPTIONS_PATH, MANAGE_PATH, - NOTES_MANAGEMENT_PATH, POLICIES_PATH, RESPONSE_ACTIONS_HISTORY_PATH, SecurityPageName, @@ -40,7 +39,6 @@ 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'; @@ -87,12 +85,6 @@ const categories = [ }), linkIds: [SecurityPageName.cloudDefendPolicies], }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.investigations', { - defaultMessage: 'Investigations', - }), - linkIds: [SecurityPageName.notesManagement], - }, ]; export const links: LinkItem = { @@ -223,18 +215,6 @@ export const links: LinkItem = { hideTimeline: true, }, cloudDefendLink, - { - id: SecurityPageName.notesManagement, - title: NOTES, - description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', { - defaultMessage: 'Visualize and delete notes.', - }), - landingIcon: IconTool, // TODO get new icon - path: NOTES_MANAGEMENT_PATH, - skipUrlState: true, - hideTimeline: true, - experimentalKey: 'securitySolutionNotesEnabled', - }, ], }; 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 91455d71a8d17..4c9542458c304 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -29,6 +29,38 @@ export const createNote = async ({ note }: { note: BareNote }) => { } }; +export const fetchNotes = async ({ + page, + perPage, + sortField, + sortOrder, + filter, + search, +}: { + page: number; + perPage: number; + sortField: string; + sortOrder: string; + filter: string; + search: string; +}) => { + const response = await KibanaServices.get().http.get<{ totalCount: number; notes: Note[] }>( + NOTE_URL, + { + query: { + page, + perPage, + sortField, + sortOrder, + filter, + search, + }, + version: '2023-10-31', + } + ); + return response; +}; + /** * Fetches all the notes for an array of document ids */ @@ -44,11 +76,11 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => { }; /** - * Deletes a note + * Deletes multiple notes */ -export const deleteNote = async (noteId: string) => { +export const deleteNotes = async (noteIds: string[]) => { const response = await KibanaServices.get().http.delete<{ data: unknown }>(NOTE_URL, { - body: JSON.stringify({ noteId }), + body: JSON.stringify({ noteIds }), version: '2023-10-31', }); return response; diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx new file mode 100644 index 0000000000000..e4a37d6594e14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from './translations'; +import { + deleteNotes, + userClosedDeleteModal, + selectNotesTablePendingDeleteIds, + selectDeleteNotesStatus, + ReqStatus, +} from '..'; + +export const DeleteConfirmModal = React.memo(() => { + const dispatch = useDispatch(); + const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); + const deleteNotesStatus = useSelector(selectDeleteNotesStatus); + const deleteLoading = deleteNotesStatus === ReqStatus.Loading; + + const onCancel = useCallback(() => { + dispatch(userClosedDeleteModal()); + }, [dispatch]); + + const onConfirm = useCallback(() => { + dispatch(deleteNotes({ ids: pendingDeleteIds })); + }, [dispatch, pendingDeleteIds]); + + return ( + + {i18n.DELETE_NOTES_CONFIRM(pendingDeleteIds.length)} + + ); +}); + +DeleteConfirmModal.displayName = 'DeleteConfirmModal'; diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx new file mode 100644 index 0000000000000..1e88a47b2e2d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; +import React, { useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { userSearchedNotes, selectNotesTableSearch } from '..'; + +const SearchRowContainer = styled.div` + &:not(:last-child) { + margin-bottom: ${(props) => props.theme.eui.euiSizeL}; + } +`; + +SearchRowContainer.displayName = 'SearchRowContainer'; + +const SearchRowFlexGroup = styled(EuiFlexGroup)` + margin-bottom: ${(props) => props.theme.eui.euiSizeXS}; +`; + +SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; + +export const SearchRow = React.memo(() => { + const dispatch = useDispatch(); + const searchBox = useMemo( + () => ({ + placeholder: 'Search note contents', + incremental: false, + 'data-test-subj': 'notes-search-bar', + }), + [] + ); + + const notesSearch = useSelector(selectNotesTableSearch); + + const onQueryChange = useCallback( + ({ queryText }) => { + dispatch(userSearchedNotes(queryText.trim())); + }, + [dispatch] + ); + + return ( + + + + + + + + ); +}); + +SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/plugins/security_solution/public/notes/components/translations.ts b/x-pack/plugins/security_solution/public/notes/components/translations.ts new file mode 100644 index 0000000000000..471c28cbc9d4c --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const BATCH_ACTIONS = i18n.translate( + 'xpack.securitySolution.notes.management.batchActionsTitle', + { + defaultMessage: 'Bulk actions', + } +); + +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: 'Document ID', + } +); + +export const TIMELINE_ID_COLUMN = i18n.translate( + 'xpack.securitySolution.notes.management.timelineIdColumnTitle', + { + defaultMessage: 'Timeline ID', + } +); + +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 NOTES_MANAGEMENT_TITLE = i18n.translate( + 'xpack.securitySolution.notes.management.pageTitle', + { + defaultMessage: 'Notes management', + } +); + +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', + { + defaultMessage: 'Delete notes?', + } +); + +export const DELETE_NOTES_CONFIRM = (selectedNotes: number) => + i18n.translate('xpack.securitySolution.notes.management.deleteNotesConfirm', { + values: { selectedNotes }, + defaultMessage: + 'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, one {note} other {notes}}?', + }); + +export const DELETE_NOTES_CANCEL = i18n.translate( + 'xpack.securitySolution.notes.management.deleteNotesCancel', + { + defaultMessage: 'Cancel', + } +); + +export const DELETE_SELECTED = i18n.translate( + 'xpack.securitySolution.notes.management.deleteSelected', + { + defaultMessage: 'Delete selected notes', + } +); + +export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.refresh', { + defaultMessage: 'Refresh', +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx new file mode 100644 index 0000000000000..c6b54e473ae5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../../common/components/utility_bar'; +import { + selectNotesPagination, + selectNotesTableSort, + fetchNotes, + selectNotesTableSelectedIds, + selectNotesTableSearch, + userSelectedBulkDelete, +} from '..'; +import * as i18n from './translations'; + +export const NotesUtilityBar = React.memo(() => { + const dispatch = useDispatch(); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const totalItems = pagination.total ?? 0; + const selectedItems = useSelector(selectNotesTableSelectedIds); + const resultsCount = useMemo(() => { + const { perPage, page } = pagination; + const startOfCurrentPage = perPage * (page - 1) + 1; + const endOfCurrentPage = Math.min(perPage * page, totalItems); + return perPage === 0 ? 'All' : `${startOfCurrentPage}-${endOfCurrentPage} of ${totalItems}`; + }, [pagination, totalItems]); + const deleteSelectedNotes = useCallback(() => { + dispatch(userSelectedBulkDelete()); + }, [dispatch]); + const notesSearch = useSelector(selectNotesTableSearch); + + const BulkActionPopoverContent = useCallback(() => { + return ( + + {i18n.DELETE_SELECTED} + + ); + }, [deleteSelectedNotes, selectedItems.length]); + const refresh = useCallback(() => { + dispatch( + fetchNotes({ + page: pagination.page, + perPage: pagination.perPage, + sortField: sort.field, + sortOrder: sort.direction, + filter: '', + search: notesSearch, + }) + ); + }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + return ( + + + + + {`Showing: ${resultsCount}`} + + + + + {selectedItems.length > 0 ? `${selectedItems.length} selected` : ''} + + + + {i18n.BATCH_ACTIONS} + + + + {i18n.REFRESH} + + + + + ); +}); + +NotesUtilityBar.displayName = 'NotesUtilityBar'; diff --git a/x-pack/plugins/security_solution/public/notes/index.ts b/x-pack/plugins/security_solution/public/notes/index.ts new file mode 100644 index 0000000000000..2c8f3548cfb53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { NoteManagementPage } from './pages/note_management_page'; +export * from './store/notes.slice'; 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 1964fa65fd96f..dca13ce2eed7b 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 @@ -5,14 +5,207 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useMemo, useEffect } from 'react'; +import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +// 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'; +import { + userSelectedPage, + userSelectedPerPage, + userSelectedRow, + userSortedNotes, + selectAllNotes, + selectNotesPagination, + selectNotesTableSort, + fetchNotes, + selectNotesTableSearch, + selectFetchNotesStatus, + selectNotesTablePendingDeleteIds, + userSelectedRowForDeletion, + selectFetchNotesError, + ReqStatus, +} from '..'; +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'; + +const columns: Array> = [ + { + field: 'created', + name: i18n.CREATED_COLUMN, + sortable: true, + render: (created: Note['created']) => , + }, + { + field: 'createdBy', + name: i18n.CREATED_BY_COLUMN, + }, + { + field: 'eventId', + name: i18n.EVENT_ID_COLUMN, + sortable: true, + }, + { + field: 'timelineId', + name: i18n.TIMELINE_ID_COLUMN, + }, + { + field: 'note', + name: i18n.NOTE_CONTENT_COLUMN, + }, +]; + +const pageSizeOptions = [50, 25, 10, 0]; /** - * Page to allow users to manage notes. The page is accessible via the Investigations section within the Manage page. - * // TODO to be implemented + * Allows user to search and delete notes. + * 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 = () => { - return <>; + const dispatch = useDispatch(); + const notes = useSelector(selectAllNotes); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const totalItems = pagination.total ?? 0; + const notesSearch = useSelector(selectNotesTableSearch); + const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); + const isDeleteModalVisible = pendingDeleteIds.length > 0; + const fetchNotesStatus = useSelector(selectFetchNotesStatus); + const fetchLoading = fetchNotesStatus === ReqStatus.Loading; + const fetchError = fetchNotesStatus === ReqStatus.Failed; + const fetchErrorData = useSelector(selectFetchNotesError); + + const fetchData = useCallback(() => { + dispatch( + fetchNotes({ + page: pagination.page, + perPage: pagination.perPage, + sortField: sort.field, + sortOrder: sort.direction, + filter: '', + search: notesSearch, + }) + ); + }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const onTableChange = useCallback( + ({ + page, + sort: newSort, + }: { + page?: { index: number; size: number }; + sort?: NotesState['sort']; + }) => { + if (page) { + dispatch(userSelectedPage(page.index + 1)); + dispatch(userSelectedPerPage(page.size)); + } + if (newSort) { + dispatch(userSortedNotes({ field: newSort.field, direction: newSort.direction })); + } + }, + [dispatch] + ); + + const selectRowForDeletion = useCallback( + (id: string) => { + dispatch(userSelectedRowForDeletion(id)); + }, + [dispatch] + ); + + const onSelectionChange = useCallback( + (selection: Note[]) => { + const rowIds = selection.map((item) => item.noteId); + dispatch(userSelectedRow(rowIds)); + }, + [dispatch] + ); + + const itemIdSelector = useCallback((item: Note) => { + return item.noteId; + }, []); + + const columnWithActions = useMemo(() => { + const actions: Array> = [ + { + name: i18n.DELETE, + description: i18n.DELETE_SINGLE_NOTE_DESCRIPTION, + color: 'primary', + icon: 'trash', + type: 'icon', + onClick: (note: Note) => selectRowForDeletion(note.noteId), + }, + ]; + return [ + ...columns, + { + name: 'actions', + actions, + }, + ]; + }, [selectRowForDeletion]); + + const currentPagination = useMemo(() => { + return { + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: totalItems, + pageSizeOptions, + }; + }, [pagination, totalItems]); + + const selection = useMemo(() => { + return { + onSelectionChange, + selectable: () => true, + }; + }, [onSelectionChange]); + + const sorting: { sort: { field: keyof Note; direction: 'asc' | 'desc' } } = useMemo(() => { + return { + sort, + }; + }, [sort]); + + if (fetchError) { + return ( + {i18n.TABLE_ERROR}} + body={

{fetchErrorData}

} + /> + ); + } + + return ( + <> + + + + {isDeleteModalVisible && } + + ); }; NoteManagementPage.displayName = 'NoteManagementPage'; 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 59f196b6a5af5..8290edb049e1e 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 @@ -5,113 +5,137 @@ * 2.0. */ import * as uuid from 'uuid'; +import { miniSerializeError } from '@reduxjs/toolkit'; +import type { SerializedError } from '@reduxjs/toolkit'; import { createNote, - deleteNote, + deleteNotes, fetchNotesByDocumentIds, + fetchNotes, initialNotesState, notesReducer, ReqStatus, selectAllNotes, selectCreateNoteError, selectCreateNoteStatus, - selectDeleteNoteError, - selectDeleteNoteStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, selectFetchNotesByDocumentIdsError, selectFetchNotesByDocumentIdsStatus, + selectFetchNotesError, + selectFetchNotesStatus, selectNoteById, selectNoteIds, selectNotesByDocumentId, + selectNotesPagination, + selectNotesTablePendingDeleteIds, + selectNotesTableSearch, + selectNotesTableSelectedIds, + selectNotesTableSort, + userClosedDeleteModal, + userFilteredNotes, + userSearchedNotes, + userSelectedBulkDelete, + userSelectedPage, + userSelectedPerPage, + userSelectedRow, + userSelectedRowForDeletion, + userSortedNotes, } from './notes.slice'; +import type { NotesState } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; +import type { Note } from '../../../common/api/timeline'; const initalEmptyState = initialNotesState; -export const generateNoteMock = (documentIds: string[]) => - documentIds.map((documentId: string) => ({ - noteId: uuid.v4(), - version: 'WzU1MDEsMV0=', - timelineId: '', - eventId: documentId, - note: 'This is a mocked note', - created: new Date().getTime(), - createdBy: 'elastic', - updated: new Date().getTime(), - updatedBy: 'elastic', - })); - -const mockNote = { ...generateNoteMock(['1'])[0] }; +const generateNoteMock = (documentId: string): Note => ({ + noteId: uuid.v4(), + version: 'WzU1MDEsMV0=', + timelineId: '', + eventId: documentId, + note: 'This is a mocked note', + created: new Date().getTime(), + createdBy: 'elastic', + updated: new Date().getTime(), + updatedBy: 'elastic', +}); + +const mockNote1 = generateNoteMock('1'); +const mockNote2 = generateNoteMock('2'); + const initialNonEmptyState = { entities: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, + [mockNote2.noteId]: mockNote2, }, - ids: [mockNote.noteId], + ids: [mockNote1.noteId, mockNote2.noteId], status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, + }, + error: { fetchNotesByDocumentIds: null, createNote: null, deleteNotes: null, fetchNotes: null }, + pagination: { + page: 1, + perPage: 10, + total: 0, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + sort: { + field: 'created' as const, + direction: 'desc' as const, + }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }; describe('notesSlice', () => { describe('notesReducer', () => { it('should handle an unknown action and return the initial state', () => { - expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual({ - entities: {}, - ids: [], - status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, - }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, - }); + expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual(initalEmptyState); }); describe('fetchNotesByDocumentIds', () => { - it('should set correct status state when fetching notes by document id', () => { + it('should set correct status state when fetching notes by document ids', () => { const action = { type: fetchNotesByDocumentIds.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Loading, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on fetch notes by document id on an empty state', () => { + it('should set correct state when success on fetch notes by document ids on an empty state', () => { const action = { type: fetchNotesByDocumentIds.fulfilled.type, payload: { entities: { notes: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, }, }, - result: [mockNote.noteId], + result: [mockNote1.noteId], }, }; expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, entities: action.payload.entities.notes, ids: action.payload.result, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Succeeded, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should replace notes when success on fetch notes by document id on a non-empty state', () => { - const newMockNote = { ...mockNote, timelineId: 'timelineId' }; + it('should replace notes when success on fetch notes by document ids on a non-empty state', () => { + const newMockNote = { ...mockNote1, timelineId: 'timelineId' }; const action = { type: fetchNotesByDocumentIds.fulfilled.type, payload: { @@ -125,173 +149,336 @@ describe('notesSlice', () => { }; expect(notesReducer(initialNonEmptyState, action)).toEqual({ - entities: action.payload.entities.notes, - ids: action.payload.result, + ...initalEmptyState, + entities: { + [newMockNote.noteId]: newMockNote, + [mockNote2.noteId]: mockNote2, + }, + ids: [newMockNote.noteId, mockNote2.noteId], status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Succeeded, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct error state when failing to fetch notes by document id', () => { + it('should set correct error state when failing to fetch notes by document ids', () => { const action = { type: fetchNotesByDocumentIds.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Failed, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, error: { + ...initalEmptyState.error, fetchNotesByDocumentIds: 'error', - createNote: null, - deleteNote: null, }, }); }); }); describe('createNote', () => { - it('should set correct status state when creating a note by document id', () => { + it('should set correct status state when creating a note', () => { const action = { type: createNote.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Loading, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on create a note by document id on an empty state', () => { + it('should set correct state when success on create a note on an empty state', () => { const action = { type: createNote.fulfilled.type, payload: { entities: { notes: { - [mockNote.noteId]: mockNote, + [mockNote1.noteId]: mockNote1, }, }, - result: mockNote.noteId, + result: mockNote1.noteId, }, }; expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, entities: action.payload.entities.notes, ids: [action.payload.result], status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Succeeded, - deleteNote: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct error state when failing to create a note by document id', () => { + it('should set correct error state when failing to create a note', () => { const action = { type: createNote.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, + ...initalEmptyState.status, createNote: ReqStatus.Failed, - deleteNote: ReqStatus.Idle, }, error: { - fetchNotesByDocumentIds: null, + ...initalEmptyState.error, createNote: 'error', - deleteNote: null, }, }); }); }); - describe('deleteNote', () => { - it('should set correct status state when deleting a note', () => { - const action = { type: deleteNote.pending.type }; + describe('deleteNotes', () => { + it('should set correct status state when deleting notes', () => { + const action = { type: deleteNotes.pending.type }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Loading, + ...initalEmptyState.status, + deleteNotes: ReqStatus.Loading, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, }); }); - it('should set correct state when success on deleting a note', () => { + it('should set correct state when success on deleting notes', () => { const action = { - type: deleteNote.fulfilled.type, - payload: mockNote.noteId, + type: deleteNotes.fulfilled.type, + payload: [mockNote1.noteId], + }; + const state = { + ...initialNonEmptyState, + pendingDeleteIds: [mockNote1.noteId], }; - expect(notesReducer(initialNonEmptyState, action)).toEqual({ - entities: {}, - ids: [], + expect(notesReducer(state, action)).toEqual({ + ...initialNonEmptyState, + entities: { + [mockNote2.noteId]: mockNote2, + }, + ids: [mockNote2.noteId], status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Succeeded, + ...initialNonEmptyState.status, + deleteNotes: ReqStatus.Succeeded, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + pendingDeleteIds: [], }); }); - it('should set correct state when failing to create a note by document id', () => { - const action = { type: deleteNote.rejected.type, error: 'error' }; + it('should set correct state when failing to delete notes', () => { + const action = { type: deleteNotes.rejected.type, error: 'error' }; expect(notesReducer(initalEmptyState, action)).toEqual({ - entities: {}, - ids: [], + ...initalEmptyState, status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Failed, + ...initalEmptyState.status, + deleteNotes: ReqStatus.Failed, }, error: { - fetchNotesByDocumentIds: null, - createNote: null, - deleteNote: 'error', + ...initalEmptyState.error, + deleteNotes: 'error', + }, + }); + }); + + it('should set correct status when fetching notes', () => { + const action = { type: fetchNotes.pending.type }; + expect(notesReducer(initialNotesState, action)).toEqual({ + ...initialNotesState, + status: { + ...initialNotesState.status, + fetchNotes: ReqStatus.Loading, + }, + }); + }); + + it('should set notes and update pagination when fetch is successful', () => { + const action = { + type: fetchNotes.fulfilled.type, + payload: { + entities: { + notes: { [mockNote2.noteId]: mockNote2, '2': { ...mockNote2, noteId: '2' } }, + }, + totalCount: 2, + }, + }; + const state = notesReducer(initialNotesState, action); + expect(state.entities).toEqual(action.payload.entities.notes); + expect(state.ids).toHaveLength(2); + expect(state.pagination.total).toBe(2); + expect(state.status.fetchNotes).toBe(ReqStatus.Succeeded); + }); + + it('should set error when fetch fails', () => { + const action = { type: fetchNotes.rejected.type, error: { message: 'Failed to fetch' } }; + const state = notesReducer(initialNotesState, action); + expect(state.status.fetchNotes).toBe(ReqStatus.Failed); + expect(state.error.fetchNotes).toEqual({ message: 'Failed to fetch' }); + }); + + it('should set correct status when deleting multiple notes', () => { + const action = { type: deleteNotes.pending.type }; + expect(notesReducer(initialNotesState, action)).toEqual({ + ...initialNotesState, + status: { + ...initialNotesState.status, + deleteNotes: ReqStatus.Loading, + }, + }); + }); + + it('should remove multiple notes when delete is successful', () => { + const initialState = { + ...initialNotesState, + entities: { '1': mockNote1, '2': { ...mockNote2, noteId: '2' } }, + ids: ['1', '2'], + }; + const action = { type: deleteNotes.fulfilled.type, payload: ['1', '2'] }; + const state = notesReducer(initialState, action); + expect(state.entities).toEqual({}); + expect(state.ids).toHaveLength(0); + expect(state.status.deleteNotes).toBe(ReqStatus.Succeeded); + }); + + it('should set error when delete fails', () => { + const action = { type: deleteNotes.rejected.type, error: { message: 'Failed to delete' } }; + const state = notesReducer(initialNotesState, action); + expect(state.status.deleteNotes).toBe(ReqStatus.Failed); + expect(state.error.deleteNotes).toEqual({ message: 'Failed to delete' }); + }); + }); + + describe('userSelectedPage', () => { + it('should set correct value for the selected page', () => { + const action = { type: userSelectedPage.type, payload: 2 }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pagination: { + ...initalEmptyState.pagination, + page: 2, + }, + }); + }); + }); + + describe('userSelectedPerPage', () => { + it('should set correct value for number of notes per page', () => { + const action = { type: userSelectedPerPage.type, payload: 25 }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pagination: { + ...initalEmptyState.pagination, + perPage: 25, + }, + }); + }); + }); + + describe('userSortedNotes', () => { + it('should set correct value for sorting notes', () => { + const action = { type: userSortedNotes.type, payload: { field: 'note', direction: 'asc' } }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + sort: { + field: 'note', + direction: 'asc', }, }); }); }); + + describe('userFilteredNotes', () => { + it('should set correct value to filter notes', () => { + const action = { type: userFilteredNotes.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + filter: 'abc', + }); + }); + }); + + describe('userSearchedNotes', () => { + it('should set correct value to search notes', () => { + const action = { type: userSearchedNotes.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + search: 'abc', + }); + }); + }); + + describe('userSelectedRow', () => { + it('should set correct ids for selected rows', () => { + const action = { type: userSelectedRow.type, payload: ['1'] }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + selectedIds: ['1'], + }); + }); + }); + + describe('userClosedDeleteModal', () => { + it('should reset pendingDeleteIds when closing modal', () => { + const action = { type: userClosedDeleteModal.type }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pendingDeleteIds: [], + }); + }); + }); + + describe('userSelectedRowForDeletion', () => { + it('should set correct id when user selects a row', () => { + const action = { type: userSelectedRowForDeletion.type, payload: '1' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + pendingDeleteIds: ['1'], + }); + }); + }); + + describe('userSelectedBulkDelete', () => { + it('should update pendingDeleteIds when user chooses bulk delete', () => { + const action = { type: userSelectedBulkDelete.type }; + const state = { + ...initalEmptyState, + selectedIds: ['1'], + }; + + expect(notesReducer(state, action)).toEqual({ + ...state, + pendingDeleteIds: ['1'], + }); + }); + }); }); describe('selectors', () => { it('should return all notes', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectAllNotes(state)).toEqual([mockNote]); + expect(selectAllNotes(mockGlobalState)).toEqual( + Object.values(mockGlobalState.notes.entities) + ); }); it('should return note by id', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectNoteById(state, mockNote.noteId)).toEqual(mockNote); + expect(selectNoteById(mockGlobalState, '1')).toEqual(mockGlobalState.notes.entities['1']); }); it('should return note ids', () => { - const state = mockGlobalState; - state.notes.entities = initialNonEmptyState.entities; - state.notes.ids = initialNonEmptyState.ids; - expect(selectNoteIds(state)).toEqual([mockNote.noteId]); + expect(selectNoteIds(mockGlobalState)).toEqual(['1']); }); it('should return fetch notes by document id status', () => { @@ -311,19 +498,110 @@ describe('notesSlice', () => { }); it('should return delete note status', () => { - expect(selectDeleteNoteStatus(mockGlobalState)).toEqual(ReqStatus.Idle); + expect(selectDeleteNotesStatus(mockGlobalState)).toEqual(ReqStatus.Idle); }); it('should return delete note error', () => { - expect(selectDeleteNoteError(mockGlobalState)).toEqual(null); + expect(selectDeleteNotesError(mockGlobalState)).toEqual(null); }); it('should return all notes for an existing document id', () => { - expect(selectNotesByDocumentId(mockGlobalState, '1')).toEqual([mockNote]); + expect(selectNotesByDocumentId(mockGlobalState, 'document-id-1')).toEqual([ + mockGlobalState.notes.entities['1'], + ]); }); it('should return no notes if document id does not exist', () => { - expect(selectNotesByDocumentId(mockGlobalState, '2')).toHaveLength(0); + expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0); + }); + + it('should select notes pagination', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, pagination: { page: 2, perPage: 20, total: 100 } }, + }; + expect(selectNotesPagination(state)).toEqual({ page: 2, perPage: 20, total: 100 }); + }); + + it('should select notes table sort', () => { + const notes: NotesState = { + ...initialNotesState, + sort: { field: 'created', direction: 'asc' }, + }; + const state = { + ...mockGlobalState, + notes, + }; + expect(selectNotesTableSort(state)).toEqual({ field: 'created', direction: 'asc' }); + }); + + it('should select notes table total items', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + pagination: { ...initialNotesState.pagination, total: 100 }, + }, + }; + expect(selectNotesPagination(state).total).toBe(100); + }); + + it('should select notes table selected ids', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, selectedIds: ['1', '2'] }, + }; + expect(selectNotesTableSelectedIds(state)).toEqual(['1', '2']); + }); + + it('should select notes table search', () => { + const state = { ...mockGlobalState, notes: { ...initialNotesState, search: 'test search' } }; + expect(selectNotesTableSearch(state)).toBe('test search'); + }); + + it('should select notes table pending delete ids', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, pendingDeleteIds: ['1', '2'] }, + }; + expect(selectNotesTablePendingDeleteIds(state)).toEqual(['1', '2']); + }); + + it('should select delete notes status', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + status: { ...initialNotesState.status, deleteNotes: ReqStatus.Loading }, + }, + }; + expect(selectDeleteNotesStatus(state)).toBe(ReqStatus.Loading); + }); + + it('should select fetch notes error', () => { + const error = new Error('Error fetching notes'); + const reudxToolKItError = miniSerializeError(error); + const notes: NotesState = { + ...initialNotesState, + error: { ...initialNotesState.error, fetchNotes: reudxToolKItError }, + }; + const state = { + ...mockGlobalState, + notes, + }; + const selectedError = selectFetchNotesError(state) as SerializedError; + expect(selectedError.message).toBe('Error fetching notes'); + }); + + it('should select fetch notes status', () => { + const state = { + ...mockGlobalState, + notes: { + ...initialNotesState, + status: { ...initialNotesState.status, fetchNotes: ReqStatus.Succeeded }, + }, + }; + expect(selectFetchNotesStatus(state)).toBe(ReqStatus.Succeeded); }); }); }); 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 303313d180306..6b466d62f53b6 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 @@ -11,7 +11,8 @@ import { createSelector } from 'reselect'; import type { State } from '../../common/store'; import { createNote as createNoteApi, - deleteNote as deleteNoteApi, + deleteNotes as deleteNotesApi, + fetchNotes as fetchNotesApi, fetchNotesByDocumentIds as fetchNotesByDocumentIdsApi, } from '../api/api'; import type { NormalizedEntities, NormalizedEntity } from './normalize'; @@ -34,13 +35,28 @@ export interface NotesState extends EntityState { status: { fetchNotesByDocumentIds: ReqStatus; createNote: ReqStatus; - deleteNote: ReqStatus; + deleteNotes: ReqStatus; + fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; - deleteNote: SerializedError | HttpError | null; + deleteNotes: SerializedError | HttpError | null; + fetchNotes: SerializedError | HttpError | null; }; + pagination: { + page: number; + perPage: number; + total: number; + }; + sort: { + field: keyof Note; + direction: 'asc' | 'desc'; + }; + filter: string; + search: string; + selectedIds: string[]; + pendingDeleteIds: string[]; } const notesAdapter = createEntityAdapter({ @@ -51,13 +67,28 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, - deleteNote: null, + deleteNotes: null, + fetchNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 0, }, + sort: { + field: 'created', + direction: 'desc', + }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }); export const fetchNotesByDocumentIds = createAsyncThunk< @@ -70,6 +101,23 @@ export const fetchNotesByDocumentIds = createAsyncThunk< return normalizeEntities(res.notes); }); +export const fetchNotes = createAsyncThunk< + NormalizedEntities & { totalCount: number }, + { + page: number; + perPage: number; + sortField: string; + sortOrder: string; + filter: string; + search: string; + }, + {} +>('notes/fetchNotes', async (args) => { + const { page, perPage, sortField, sortOrder, filter, search } = args; + const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search }); + return { ...normalizeEntities(res.notes), totalCount: res.totalCount }; +}); + export const createNote = createAsyncThunk, { note: BareNote }, {}>( 'notes/createNote', async (args) => { @@ -79,19 +127,50 @@ export const createNote = createAsyncThunk, { note: BareN } ); -export const deleteNote = createAsyncThunk( - 'notes/deleteNote', +export const deleteNotes = createAsyncThunk( + 'notes/deleteNotes', async (args) => { - const { id } = args; - await deleteNoteApi(id); - return id; + const { ids } = args; + await deleteNotesApi(ids); + return ids; } ); const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, - reducers: {}, + reducers: { + userSelectedPage: (state, action: { payload: number }) => { + state.pagination.page = action.payload; + }, + userSelectedPerPage: (state, action: { payload: number }) => { + state.pagination.perPage = action.payload; + }, + userSortedNotes: ( + state, + action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } } + ) => { + state.sort = action.payload; + }, + userFilteredNotes: (state, action: { payload: string }) => { + state.filter = action.payload; + }, + userSearchedNotes: (state, action: { payload: string }) => { + state.search = action.payload; + }, + userSelectedRow: (state, action: { payload: string[] }) => { + state.selectedIds = action.payload; + }, + userClosedDeleteModal: (state) => { + state.pendingDeleteIds = []; + }, + userSelectedRowForDeletion: (state, action: { payload: string }) => { + state.pendingDeleteIds = [action.payload]; + }, + userSelectedBulkDelete: (state) => { + state.pendingDeleteIds = state.selectedIds; + }, + }, extraReducers(builder) { builder .addCase(fetchNotesByDocumentIds.pending, (state) => { @@ -116,16 +195,32 @@ const notesSlice = createSlice({ state.status.createNote = ReqStatus.Failed; state.error.createNote = action.payload ?? action.error; }) - .addCase(deleteNote.pending, (state) => { - state.status.deleteNote = ReqStatus.Loading; + .addCase(deleteNotes.pending, (state) => { + state.status.deleteNotes = ReqStatus.Loading; + }) + .addCase(deleteNotes.fulfilled, (state, action) => { + notesAdapter.removeMany(state, action.payload); + state.status.deleteNotes = ReqStatus.Succeeded; + state.pendingDeleteIds = state.pendingDeleteIds.filter( + (value) => !action.payload.includes(value) + ); + }) + .addCase(deleteNotes.rejected, (state, action) => { + state.status.deleteNotes = ReqStatus.Failed; + state.error.deleteNotes = action.payload ?? action.error; }) - .addCase(deleteNote.fulfilled, (state, action) => { - notesAdapter.removeOne(state, action.payload); - state.status.deleteNote = ReqStatus.Succeeded; + .addCase(fetchNotes.pending, (state) => { + state.status.fetchNotes = ReqStatus.Loading; }) - .addCase(deleteNote.rejected, (state, action) => { - state.status.deleteNote = ReqStatus.Failed; - state.error.deleteNote = action.payload ?? action.error; + .addCase(fetchNotes.fulfilled, (state, action) => { + notesAdapter.setAll(state, action.payload.entities.notes); + state.pagination.total = action.payload.totalCount; + state.status.fetchNotes = ReqStatus.Succeeded; + state.selectedIds = []; + }) + .addCase(fetchNotes.rejected, (state, action) => { + state.status.fetchNotes = ReqStatus.Failed; + state.error.fetchNotes = action.payload ?? action.error; }); }, }); @@ -148,11 +243,37 @@ export const selectCreateNoteStatus = (state: State) => state.notes.status.creat export const selectCreateNoteError = (state: State) => state.notes.error.createNote; -export const selectDeleteNoteStatus = (state: State) => state.notes.status.deleteNote; +export const selectDeleteNotesStatus = (state: State) => state.notes.status.deleteNotes; + +export const selectDeleteNotesError = (state: State) => state.notes.error.deleteNotes; + +export const selectNotesPagination = (state: State) => state.notes.pagination; + +export const selectNotesTableSort = (state: State) => state.notes.sort; + +export const selectNotesTableSelectedIds = (state: State) => state.notes.selectedIds; -export const selectDeleteNoteError = (state: State) => state.notes.error.deleteNote; +export const selectNotesTableSearch = (state: State) => state.notes.search; + +export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; + +export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes; + +export const selectFetchNotesStatus = (state: State) => state.notes.status.fetchNotes; export const selectNotesByDocumentId = createSelector( [selectAllNotes, (state, documentId) => documentId], (notes, documentId) => notes.filter((note) => note.eventId === documentId) ); + +export const { + userSelectedPage, + userSelectedPerPage, + userSortedNotes, + userFilteredNotes, + userSearchedNotes, + userSelectedRow, + userClosedDeleteModal, + userSelectedRowForDeletion, + userSelectedBulkDelete, +} = notesSlice.actions; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 67d0c5a9e4599..073e9c486ac6d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -25,7 +25,7 @@ export const useEditTimelineBatchActions = ({ }: { deleteTimelines?: DeleteTimelines; selectedItems?: OpenTimelineResult[]; - tableRef: React.MutableRefObject | undefined>; + tableRef: React.MutableRefObject | null>; timelineType: TimelineType | null; }) => { const { 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 aa80df5a33ba7..11e35ce4a800a 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 @@ -69,7 +69,7 @@ interface OwnProps { export type OpenTimelineOwnProps = OwnProps & Pick< OpenTimelineProps, - 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' + 'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' | 'tabName' >; /** Returns a collection of selected timeline ids */ @@ -130,6 +130,7 @@ export const StatefulOpenTimelineComponent = React.memo( importDataModalToggle, onOpenTimeline, setImportDataModalToggle, + tabName, title, }) => { const dispatch = useDispatch(); @@ -305,12 +306,16 @@ export const StatefulOpenTimelineComponent = React.memo( /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ const onTableChange: OnTableChange = useCallback(({ page, sort }: OnTableChangeParams) => { - const { index, size } = page; - const { field, direction } = sort; - setPageIndex(index); - setPageSize(size); - setSortDirection(direction); - setSortField(field); + if (page != null) { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + } + if (sort != null) { + const { field, direction } = sort; + setSortDirection(direction); + setSortField(field); + } }, []); /** Invoked when the user toggles the option to only view favorite timelines */ @@ -414,6 +419,7 @@ export const StatefulOpenTimelineComponent = React.memo( 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 28e42b3aa2020..3dc686229e4fa 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 @@ -5,8 +5,8 @@ * 2.0. */ -import type { EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo, useRef } from 'react'; +import type { EuiBasicTable } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; @@ -29,7 +29,8 @@ import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import * as i18n from './translations'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import type { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; +import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from './types'; +import { NoteManagementPage } from '../../../notes'; const QueryText = styled.span` white-space: normal; @@ -63,13 +64,13 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, + tabName, timelineType = TimelineType.default, timelineStatus, timelineFilter, templateTimelineFilter, totalSearchResultsCount, }) => { - const tableRef = useRef>(); const { actionItem, enableExportTimelineDownloader, @@ -78,7 +79,7 @@ export const OpenTimeline = React.memo( onOpenDeleteTimelineModal, onCompleteEditTimelineAction, } = useEditTimelineActions(); - + const tableRef = useRef | null>(null); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const { getBatchItemsPopoverContent } = useEditTimelineBatchActions({ deleteTimelines: kibanaSecuritySolutionsPrivileges.crud ? deleteTimelines : undefined, @@ -227,84 +228,92 @@ export const OpenTimeline = React.memo(
{!!timelineFilter && timelineFilter} - - {SearchRowContent} - + {tabName !== 'notes' ? ( + <> + + {SearchRowContent} + - - - - - <> - {i18n.SHOWING}{' '} - {timelineType === TimelineType.template ? nTemplates : nTimelines} - - - - - {timelineStatus !== TimelineStatus.immutable && ( - <> - - {timelineType === TimelineType.template - ? i18n.SELECTED_TEMPLATES(selectedItems.length) - : i18n.SELECTED_TIMELINES(selectedItems.length)} + + + + + <> + {i18n.SHOWING}{' '} + {timelineType === TimelineType.template ? nTemplates : nTimelines} + + + + {timelineStatus !== TimelineStatus.immutable && ( + <> + + {timelineType === TimelineType.template + ? i18n.SELECTED_TEMPLATES(selectedItems.length) + : i18n.SELECTED_TIMELINES(selectedItems.length)} + + + + {i18n.BATCH_ACTIONS} + + + + )} - {i18n.BATCH_ACTIONS} + {i18n.REFRESH} - - )} - - {i18n.REFRESH} - - - - + + + - + + + ) : ( + + )}
); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 6e012c65478c8..7eb3c65f427ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -6,10 +6,11 @@ */ import { EuiModalBody, EuiModalHeader, EuiSpacer } from '@elastic/eui'; -import React, { Fragment, memo, useMemo } from 'react'; +import type { EuiBasicTable } from '@elastic/eui'; +import React, { Fragment, memo, useMemo, useRef } from 'react'; import styled from 'styled-components'; -import type { OpenTimelineProps, ActionTimelineToShow } from '../types'; +import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from '../types'; import { SearchRow } from '../search_row'; import { TimelinesTable } from '../timelines_table'; import { TitleRow } from '../title_row'; @@ -49,6 +50,8 @@ export const OpenTimelineModalBody = memo( title, totalSearchResultsCount, }) => { + const tableRef = useRef | null>(null); + const actionsToShow = useMemo(() => { const actions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; @@ -118,6 +121,7 @@ export const OpenTimelineModalBody = memo( sortField={sortField} timelineType={timelineType} totalSearchResultsCount={totalSearchResultsCount} + tableRef={tableRef} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index 9c58b30fbfc53..a5eab754a7290 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { ICON_TYPES, EuiTableActionsColumnType } from '@elastic/eui'; import type { ActionTimelineToShow, DeleteTimelines, @@ -13,10 +14,11 @@ import type { OnOpenTimeline, OpenTimelineResult, OnOpenDeleteTimelineModal, - TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; + +type Action = EuiTableActionsColumnType['actions'][number]; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ @@ -38,10 +40,10 @@ export const getActionsColumns = ({ onCreateRule?: OnCreateRuleFromTimeline; onCreateRuleFromEql?: OnCreateRuleFromTimeline; hasCrudAccess: boolean; -}): [TimelineActionsOverflowColumns] => { +}): Array> => { const createTimelineFromTemplate = { name: i18n.CREATE_TIMELINE_FROM_TEMPLATE, - icon: 'timeline', + icon: 'timeline' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -56,11 +58,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'create-from-template', available: (item: OpenTimelineResult) => item.timelineType === TimelineType.template && actionTimelineToShow.includes('createFrom'), - }; + } as Action; const createTemplateFromTimeline = { name: i18n.CREATE_TEMPLATE_FROM_TIMELINE, - icon: 'visText', + icon: 'visText' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -75,11 +77,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'create-template-from-timeline', available: (item: OpenTimelineResult) => item.timelineType !== TimelineType.template && actionTimelineToShow.includes('createFrom'), - }; + } as Action; const openAsDuplicateColumn = { name: i18n.OPEN_AS_DUPLICATE, - icon: 'copy', + icon: 'copy' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -92,11 +94,11 @@ export const getActionsColumns = ({ 'data-test-subj': 'open-duplicate', available: (item: OpenTimelineResult) => item.timelineType !== TimelineType.template && actionTimelineToShow.includes('duplicate'), - }; + } as Action; const openAsDuplicateTemplateColumn = { name: i18n.OPEN_AS_DUPLICATE_TEMPLATE, - icon: 'copy', + icon: 'copy' as typeof ICON_TYPES[number], onClick: ({ savedObjectId }: OpenTimelineResult) => { onOpenTimeline({ duplicate: true, @@ -109,11 +111,12 @@ export const getActionsColumns = ({ 'data-test-subj': 'open-duplicate-template', available: (item: OpenTimelineResult) => item.timelineType === TimelineType.template && actionTimelineToShow.includes('duplicate'), - }; + } as Action; const exportTimelineAction = { name: i18n.EXPORT_SELECTED, - icon: 'exportAction', + icon: 'exportAction' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); }, @@ -123,11 +126,12 @@ export const getActionsColumns = ({ description: i18n.EXPORT_SELECTED, 'data-test-subj': 'export-timeline', available: () => actionTimelineToShow.includes('export'), - }; + } as Action; const deleteTimelineColumn = { name: i18n.DELETE_SELECTED, - icon: 'trash', + icon: 'trash' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); }, @@ -136,11 +140,12 @@ export const getActionsColumns = ({ description: i18n.DELETE_SELECTED, 'data-test-subj': 'delete-timeline', available: () => actionTimelineToShow.includes('delete') && deleteTimelines != null, - }; + } as Action; const createRuleFromTimeline = { name: i18n.CREATE_RULE_FROM_TIMELINE, - icon: 'indexEdit', + icon: 'indexEdit' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onCreateRule != null && selectedTimeline.savedObjectId) onCreateRule(selectedTimeline.savedObjectId); @@ -156,11 +161,12 @@ export const getActionsColumns = ({ onCreateRule != null && queryType != null && queryType.hasQuery, - }; + } as Action; const createRuleFromTimelineCorrelation = { name: i18n.CREATE_RULE_FROM_TIMELINE_CORRELATION, - icon: 'indexEdit', + icon: 'indexEdit' as typeof ICON_TYPES[number], + type: 'icon', onClick: (selectedTimeline: OpenTimelineResult) => { if (onCreateRuleFromEql != null && selectedTimeline.savedObjectId) onCreateRuleFromEql(selectedTimeline.savedObjectId); @@ -176,7 +182,7 @@ export const getActionsColumns = ({ onCreateRuleFromEql != null && queryType != null && queryType.hasEql, - }; + } as Action; return [ { width: hasCrudAccess ? '80px' : '150px', diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index a040434f0a7df..7c1e0a419683e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -6,6 +6,7 @@ */ import { EuiButtonIcon, EuiLink } from '@elastic/eui'; +import type { EuiBasicTableColumn, EuiTableDataType } from '@elastic/eui'; import { omit } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; @@ -42,8 +43,9 @@ export const getCommonColumns = ({ onToggleShowNotes: OnToggleShowNotes; itemIdToExpandedNotesRowMap: Record; timelineType: TimelineType | null; -}) => [ +}): Array> => [ { + dataType: 'auto' as EuiTableDataType, isExpander: true, render: ({ notes, savedObjectId }: OpenTimelineResult) => notes != null && notes.length > 0 && savedObjectId != null ? ( @@ -64,7 +66,7 @@ export const getCommonColumns = ({ width: ACTION_COLUMN_WIDTH, }, { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'title', name: timelineType === TimelineType.default ? i18n.TIMELINE_NAME : i18n.TIMELINE_TEMPLATE_NAME, render: (title: string, timelineResult: OpenTimelineResult) => @@ -92,7 +94,7 @@ export const getCommonColumns = ({ sortable: false, }, { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'description', name: i18n.DESCRIPTION, render: (description: string) => ( @@ -103,7 +105,7 @@ export const getCommonColumns = ({ sortable: false, }, { - dataType: 'date', + dataType: 'date' as EuiTableDataType, field: 'updated', name: i18n.LAST_MODIFIED, render: (date: number, timelineResult: OpenTimelineResult) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx index 454ecce7bf2af..3451d260da4f0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; - +import type { EuiTableDataType } from '@elastic/eui'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; import * as i18n from '../translations'; @@ -21,7 +21,7 @@ export const getExtendedColumns = (showExtendedColumns: boolean) => { return [ { - dataType: 'string', + dataType: 'string' as EuiTableDataType, field: 'updatedBy', name: i18n.MODIFIED_BY, render: (updatedBy: OpenTimelineResult['updatedBy']) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx index f43a713315d1b..412ccd72c815c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,6 +6,7 @@ */ import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import type { EuiTableFieldDataColumnType, HorizontalAlignment } from '@elastic/eui'; import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; @@ -22,10 +23,10 @@ export const getIconHeaderColumns = ({ timelineType, }: { timelineType: TimelineTypeLiteralWithNull; -}) => { +}): Array> => { const columns = { note: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'eventIdToNoteIds', name: ( @@ -40,7 +41,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, pinnedEvent: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'pinnedEventIds', name: ( @@ -57,7 +58,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, favorite: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'favorite', name: ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index b4841b68810f7..1e49028326b5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; import React, { useMemo } from 'react'; -import styled from 'styled-components'; +import { css } from '@emotion/react'; import * as i18n from '../translations'; import type { @@ -29,19 +30,6 @@ import { getIconHeaderColumns } from './icon_header_columns'; import type { TimelineTypeLiteralWithNull } from '../../../../../common/api/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; -// there are a number of type mismatches across this file -const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any - -const BasicTable = styled(EuiBasicTable)` - .euiTableCellContent { - animation: none; /* Prevents applying max-height from animation */ - } - - .euiTableRow-isExpandedRow .euiTableCellContent__text { - width: 100%; /* Fixes collapsing nested flex content in IE11 */ - } -`; -BasicTable.displayName = 'BasicTable'; /** * Returns the column definitions (passed as the `columns` prop to @@ -77,7 +65,7 @@ export const getTimelinesTableColumns = ({ showExtendedColumns: boolean; timelineType: TimelineTypeLiteralWithNull; hasCrudAccess: boolean; -}) => { +}): Array> => { return [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, @@ -123,8 +111,7 @@ export interface TimelinesTableProps { sortDirection: 'asc' | 'desc'; sortField: string; timelineType: TimelineTypeLiteralWithNull; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; + tableRef: React.MutableRefObject | null>; totalSearchResultsCount: number; } @@ -157,33 +144,39 @@ export const TimelinesTable = React.memo( timelineType, totalSearchResultsCount, }) => { - const pagination = { - showPerPageOptions: showExtendedColumns, - pageIndex, - pageSize, - pageSizeOptions: [ - Math.floor(Math.max(defaultPageSize, 1) / 2), - defaultPageSize, - defaultPageSize * 2, - ], - totalItemCount: totalSearchResultsCount, - }; + const pagination = useMemo(() => { + return { + showPerPageOptions: showExtendedColumns, + pageIndex, + pageSize, + pageSizeOptions: [ + Math.floor(Math.max(defaultPageSize, 1) / 2), + defaultPageSize, + defaultPageSize * 2, + ], + totalItemCount: totalSearchResultsCount, + }; + }, [defaultPageSize, pageIndex, pageSize, showExtendedColumns, totalSearchResultsCount]); - const sorting = { - sort: { - field: sortField as keyof OpenTimelineResult, - direction: sortDirection, - }, - }; + const sorting = useMemo(() => { + return { + sort: { + field: sortField as keyof OpenTimelineResult, + direction: sortDirection, + }, + }; + }, [sortField, sortDirection]); - const selection = { - selectable: (timelineResult: OpenTimelineResult) => - timelineResult.savedObjectId != null && timelineResult.status !== TimelineStatus.immutable, - selectableMessage: (selectable: boolean) => - !selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined, - onSelectionChange, - }; - const basicTableProps = tableRef != null ? { ref: tableRef } : {}; + const selection = useMemo(() => { + return { + selectable: (timelineResult: OpenTimelineResult) => + timelineResult.savedObjectId != null && + timelineResult.status !== TimelineStatus.immutable, + selectableMessage: (selectable: boolean) => + !selectable ? i18n.MISSING_SAVED_OBJECT_ID : '', + onSelectionChange, + }; + }, [onSelectionChange]); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const columns = useMemo( () => @@ -227,7 +220,7 @@ export const TimelinesTable = React.memo( : i18n.ZERO_TIMELINES_MATCH; return ( - ( pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} - {...basicTableProps} + css={css` + .euiTableCellContent { + animation: none; /* Prevents applying max-height from animation */ + } + + .euiTableRow-isExpandedRow .euiTableCellContent__text { + width: 100%; /* Fixes collapsing nested flex content in IE11 */ + } + `} + ref={tableRef} /> ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts index 804d1625df842..075f4aca49f3f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts @@ -33,4 +33,5 @@ export const getMockTimelinesTableProps = ( sortField: DEFAULT_SORT_FIELD, timelineType: TimelineType.default, totalSearchResultsCount: mockOpenTimelineResults.length, + tableRef: { current: null }, }); 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 1373870c0b8aa..fd0fca18adc7f 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 @@ -6,6 +6,7 @@ */ import type React from 'react'; +import type { IconType } from '@elastic/eui'; import type { TimelineModel } from '../../store/model'; import type { RowRendererId, @@ -39,11 +40,11 @@ export interface TimelineActionsOverflowColumns { width: string; actions: Array<{ name: string; - icon?: string; + icon: IconType; onClick?: (timeline: OpenTimelineResult) => void; description: string; render?: (timeline: OpenTimelineResult) => JSX.Element; - } | null>; + }>; } /** The results of the query run by the OpenTimeline component */ @@ -117,11 +118,11 @@ export type OnToggleShowNotes = (itemIdToExpandedNotesRowMap: Record { + const original = jest.requireActual('react-router-dom'); return { + ...original, useParams: jest.fn().mockReturnValue('default'), useHistory: jest.fn().mockReturnValue([]), }; @@ -50,7 +53,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); expect(result.current).toEqual({ timelineType: 'default', @@ -66,7 +71,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -84,7 +91,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -110,7 +119,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(result.current.timelineTabs); @@ -138,7 +149,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}); @@ -156,7 +169,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}); @@ -182,7 +197,9 @@ describe('useTimelineTypes', () => { const { result, waitForNextUpdate } = renderHook< UseTimelineTypesArgs, UseTimelineTypesResult - >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), { + wrapper: TestProviders, + }); await waitForNextUpdate(); const { container } = render(<>{result.current.timelineFilters}); 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 5eefa23b0750a..d8943b0f674e7 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 @@ -18,6 +18,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 +43,18 @@ export const useTimelineTypes = ({ : TimelineType.default ); - const timelineUrl = formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)); - const templateUrl = formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)); + const notesEnabled = useIsExperimentalFeatureEnabled('securitySolutionNotesEnabled'); + + const timelineUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)); + }, [formatUrl, urlSearch]); + const templateUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)); + }, [formatUrl, urlSearch]); + + const notesUrl = useMemo(() => { + return formatUrl(getTimelineTabsUrl('notes', urlSearch)); + }, [formatUrl, urlSearch]); const goToTimeline = useCallback( (ev) => { @@ -60,6 +71,15 @@ export const useTimelineTypes = ({ }, [navigateToUrl, templateUrl] ); + + const goToNotes = useCallback( + (ev) => { + ev.preventDefault(); + navigateToUrl(notesUrl); + }, + [navigateToUrl, notesUrl] + ); + const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback( (timelineTabsStyle: TimelineTabsStyle) => [ { @@ -113,6 +133,17 @@ export const useTimelineTypes = ({ {tab.name} ))} + {notesEnabled && ( + + {'Notes'} + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 9315417d97646..97667c0ce8aa3 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 } from '../app/translations'; +import { TIMELINES, NOTES } from '../app/translations'; import type { LinkItem } from '../common/links/types'; export const links: LinkItem = { @@ -30,5 +30,16 @@ 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`, + skipUrlState: true, + hideTimeline: true, + 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 384a0b86ff62c..0fc2c87246a70 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(${TimelineType.default}|${TimelineType.template})`; +const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template}|notes)`; const timelinesDefaultPath = `${TIMELINES_PATH}/${TimelineType.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 547bedf1caea3..459c37a4133f8 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 ? ( - {capabilitiesCanUserCRUD && ( + {capabilitiesCanUserCRUD && tabName !== 'notes' ? ( { - )} + ) : null} { setImportDataModalToggle={setImportDataModal} title={i18n.ALL_TIMELINES_PANEL_TITLE} data-test-subj="stateful-open-timeline" + tabName={tabName} /> ) : ( From e5c1b2596b0fe8c50c8ad9caebb4842dbea57d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 2 Jul 2024 10:00:23 +0200 Subject: [PATCH 03/13] [APM] Co-locate data fetcher and return format for AI Assistant Alert insights (#186971) This co-locates data fetchers with their return values. Before the all data fetching happens at once, then later joined with return values. This makes it easier to make changes and add new data fetchers. --- .../utils/flatten_object.test.ts | 0 .../utils/flatten_object.ts | 0 .../shared/key_value_table/index.tsx | 2 +- .../shared/stacktrace/variables.tsx | 2 +- .../get_log_categories/index.ts | 45 +-- .../index.ts | 292 +++++++++--------- .../observability/server/services/index.ts | 2 +- 7 files changed, 179 insertions(+), 164 deletions(-) rename x-pack/plugins/observability_solution/apm/{public => common}/utils/flatten_object.test.ts (100%) rename x-pack/plugins/observability_solution/apm/{public => common}/utils/flatten_object.ts (100%) diff --git a/x-pack/plugins/observability_solution/apm/public/utils/flatten_object.test.ts b/x-pack/plugins/observability_solution/apm/common/utils/flatten_object.test.ts similarity index 100% rename from x-pack/plugins/observability_solution/apm/public/utils/flatten_object.test.ts rename to x-pack/plugins/observability_solution/apm/common/utils/flatten_object.test.ts diff --git a/x-pack/plugins/observability_solution/apm/public/utils/flatten_object.ts b/x-pack/plugins/observability_solution/apm/common/utils/flatten_object.ts similarity index 100% rename from x-pack/plugins/observability_solution/apm/public/utils/flatten_object.ts rename to x-pack/plugins/observability_solution/apm/common/utils/flatten_object.ts diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx index e5403397f8425..fdd993fab6c28 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/key_value_table/index.tsx @@ -8,7 +8,7 @@ import { castArray } from 'lodash'; import React, { TableHTMLAttributes } from 'react'; import { EuiTable, EuiTableProps, EuiTableBody, EuiTableRow, EuiTableRowCell } from '@elastic/eui'; import { FormattedValue } from './formatted_value'; -import { KeyValuePair } from '../../../utils/flatten_object'; +import { KeyValuePair } from '../../../../common/utils/flatten_object'; export function KeyValueTable({ keyValuePairs, diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx index 81a7c2f8e18ff..5dc9a8a5073ba 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/stacktrace/variables.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { Stackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { KeyValueTable } from '../key_value_table'; -import { flattenObject } from '../../../utils/flatten_object'; +import { flattenObject } from '../../../../common/utils/flatten_object'; const VariablesContainer = euiStyled.div` background: ${({ theme }) => theme.eui.euiColorEmptyShade}; diff --git a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts index 9685cb920c17f..c55df1122a71e 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_log_categories/index.ts @@ -9,15 +9,9 @@ import datemath from '@elastic/datemath'; import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { CoreRequestHandlerContext } from '@kbn/core/server'; import { aiAssistantLogsIndexPattern } from '@kbn/observability-ai-assistant-plugin/server'; +import { flattenObject, KeyValuePair } from '../../../../common/utils/flatten_object'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -import { - SERVICE_NAME, - CONTAINER_ID, - HOST_NAME, - KUBERNETES_POD_NAME, - PROCESSOR_EVENT, - TRACE_ID, -} from '../../../../common/es_fields/apm'; +import { PROCESSOR_EVENT, TRACE_ID } from '../../../../common/es_fields/apm'; import { getTypedSearch } from '../../../utils/create_typed_es_client'; import { getDownstreamServiceResource } from '../get_observability_alert_details_context/get_downstream_dependency_name'; @@ -40,24 +34,25 @@ export async function getLogCategories({ arguments: { start: string; end: string; - 'service.name'?: string; - 'host.name'?: string; - 'container.id'?: string; - 'kubernetes.pod.name'?: string; + entities: { + 'service.name'?: string; + 'host.name'?: string; + 'container.id'?: string; + 'kubernetes.pod.name'?: string; + }; }; -}): Promise { +}): Promise<{ + logCategories: LogCategory[]; + entities: KeyValuePair[]; +}> { const start = datemath.parse(args.start)?.valueOf()!; const end = datemath.parse(args.end)?.valueOf()!; - const keyValueFilters = getShouldMatchOrNotExistFilter([ - { field: SERVICE_NAME, value: args[SERVICE_NAME] }, - { field: CONTAINER_ID, value: args[CONTAINER_ID] }, - { field: HOST_NAME, value: args[HOST_NAME] }, - { field: KUBERNETES_POD_NAME, value: args[KUBERNETES_POD_NAME] }, - ]); + const keyValueFilters = getShouldMatchOrNotExistFilter( + Object.entries(args.entities).map(([key, value]) => ({ field: key, value })) + ); const index = await coreContext.uiSettings.client.get(aiAssistantLogsIndexPattern); - const search = getTypedSearch(esClient); const query = { @@ -93,7 +88,8 @@ export async function getLogCategories({ const categorizedLogsRes = await search({ index, - size: 0, + size: 1, + _source: Object.keys(args.entities), track_total_hits: 0, query, aggs: { @@ -144,7 +140,12 @@ export async function getLogCategories({ } ); - return Promise.all(promises ?? []); + const sampleDoc = categorizedLogsRes.hits.hits?.[0]?._source as Record; + + return { + logCategories: await Promise.all(promises ?? []), + entities: flattenObject(sampleDoc), + }; } // field/value pairs should match, or the field should not exist diff --git a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts index c3a3eb5500869..4e9ae1b546aa7 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/assistant_functions/get_observability_alert_details_context/index.ts @@ -6,9 +6,9 @@ */ import { Logger } from '@kbn/core/server'; -import { - AlertDetailsContextualInsightsHandlerQuery, - AlertDetailsContextualInsightsRequestContext, +import type { + AlertDetailsContextualInsight, + AlertDetailsContextualInsightsHandler, } from '@kbn/observability-plugin/server/services'; import moment from 'moment'; import { isEmpty } from 'lodash'; @@ -18,8 +18,11 @@ import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client'; import { getMlClient } from '../../../lib/helpers/get_ml_client'; import { getRandomSampler } from '../../../lib/helpers/get_random_sampler'; import { getApmServiceSummary } from '../get_apm_service_summary'; -import { getAssistantDownstreamDependencies } from '../get_apm_downstream_dependencies'; -import { getLogCategories } from '../get_log_categories'; +import { + APMDownstreamDependency, + getAssistantDownstreamDependencies, +} from '../get_apm_downstream_dependencies'; +import { getLogCategories, LogCategory } from '../get_log_categories'; import { getAnomalies } from '../get_apm_service_summary/get_anomalies'; import { getServiceNameFromSignals } from './get_service_name_from_signals'; import { getContainerIdFromSignals } from './get_container_id_from_signals'; @@ -30,11 +33,8 @@ import { getApmErrors } from './get_apm_errors'; export const getAlertDetailsContextHandler = ( resourcePlugins: APMRouteHandlerResources['plugins'], logger: Logger -) => { - return async ( - requestContext: AlertDetailsContextualInsightsRequestContext, - query: AlertDetailsContextualInsightsHandlerQuery - ) => { +): AlertDetailsContextualInsightsHandler => { + return async (requestContext, query) => { const resources = { getApmIndices: async () => { const coreContext = await requestContext.core; @@ -91,6 +91,7 @@ export const getAlertDetailsContextHandler = ( const serviceEnvironment = query['service.environment']; const hostName = query['host.name']; const kubernetesPodName = query['kubernetes.pod.name']; + const [serviceName, containerId] = await Promise.all([ getServiceNameFromSignals({ query, @@ -106,169 +107,182 @@ export const getAlertDetailsContextHandler = ( }), ]); - async function handleError(cb: () => Promise): Promise { - try { - return await cb(); - } catch (error) { - logger.error('Error while fetching observability alert details context'); - logger.error(error); - return; - } - } - - const serviceSummaryPromise = serviceName - ? handleError(() => - getApmServiceSummary({ - apmEventClient, - annotationsClient, - esClient, - apmAlertsClient, - mlClient, - logger, - arguments: { - 'service.name': serviceName, - 'service.environment': serviceEnvironment, - start: moment(alertStartedAt).subtract(5, 'minute').toISOString(), - end: alertStartedAt, - }, - }) - ) - : undefined; - const downstreamDependenciesPromise = serviceName - ? handleError(() => - getAssistantDownstreamDependencies({ - apmEventClient, - arguments: { - 'service.name': serviceName, - 'service.environment': serviceEnvironment, - start: moment(alertStartedAt).subtract(24, 'hours').toISOString(), - end: alertStartedAt, - }, - randomSampler, - }) - ) + ? getAssistantDownstreamDependencies({ + apmEventClient, + arguments: { + 'service.name': serviceName, + 'service.environment': serviceEnvironment, + start: moment(alertStartedAt).subtract(24, 'hours').toISOString(), + end: alertStartedAt, + }, + randomSampler, + }) : undefined; - const logCategoriesPromise = handleError(() => - getLogCategories({ + const dataFetchers: Array<() => Promise> = []; + + // service summary + if (serviceName) { + dataFetchers.push(async () => { + const serviceSummary = await getApmServiceSummary({ + apmEventClient, + annotationsClient, + esClient, + apmAlertsClient, + mlClient, + logger, + arguments: { + 'service.name': serviceName, + 'service.environment': serviceEnvironment, + start: moment(alertStartedAt).subtract(5, 'minute').toISOString(), + end: alertStartedAt, + }, + }); + + return { + key: 'serviceSummary', + description: `Metadata for the service "${serviceName}" that produced the alert. The alert might be caused by an issue in the service itself or one of its dependencies.`, + data: serviceSummary, + }; + }); + } + + // downstream dependencies + if (serviceName) { + dataFetchers.push(async () => { + const downstreamDependencies = await downstreamDependenciesPromise; + return { + key: 'downstreamDependencies', + description: `Downstream dependencies from the service "${serviceName}". Problems in these services can negatively affect the performance of "${serviceName}"`, + data: downstreamDependencies, + }; + }); + } + + // log categories + dataFetchers.push(async () => { + const downstreamDependencies = await downstreamDependenciesPromise; + const { logCategories, entities } = await getLogCategories({ apmEventClient, esClient, coreContext, arguments: { start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), end: alertStartedAt, - 'service.name': serviceName, - 'host.name': hostName, - 'container.id': containerId, - 'kubernetes.pod.name': kubernetesPodName, + entities: { + 'service.name': serviceName, + 'host.name': hostName, + 'container.id': containerId, + 'kubernetes.pod.name': kubernetesPodName, + }, }, - }) - ); + }); - const apmErrorsPromise = serviceName - ? handleError(() => - getApmErrors({ - apmEventClient, - start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), - end: alertStartedAt, - serviceName, - serviceEnvironment, - }) - ) - : undefined; + const entitiesAsString = entities.map(({ key, value }) => `${key}:${value}`).join(', '); + + return { + key: 'logCategories', + description: `Log events occurring up to 15 minutes before the alert was triggered. Filtered by the entities: ${entitiesAsString}`, + data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), + }; + }); + + // apm errors + if (serviceName) { + dataFetchers.push(async () => { + const apmErrors = await getApmErrors({ + apmEventClient, + start: moment(alertStartedAt).subtract(15, 'minute').toISOString(), + end: alertStartedAt, + serviceName, + serviceEnvironment, + }); + + const downstreamDependencies = await downstreamDependenciesPromise; + const errorsWithDownstreamServiceName = getApmErrorsWithDownstreamServiceName( + apmErrors, + downstreamDependencies + ); - const serviceChangePointsPromise = handleError(() => - getServiceChangePoints({ + return { + key: 'apmErrors', + description: `Exceptions (errors) thrown by the service "${serviceName}". If an error contains a downstream service name this could be a possible root cause. If relevant please describe what the error means and what it could be caused by.`, + data: errorsWithDownstreamServiceName, + }; + }); + } + + // exit span change points + dataFetchers.push(async () => { + const exitSpanChangePoints = await getExitSpanChangePoints({ apmEventClient, start: moment(alertStartedAt).subtract(6, 'hours').toISOString(), end: alertStartedAt, serviceName, serviceEnvironment, - transactionType: query['transaction.type'], - transactionName: query['transaction.name'], - }) - ); + }); + + return { + key: 'exitSpanChangePoints', + description: `Significant change points for the dependencies of "${serviceName}". Use this to spot dips or spikes in throughput, latency and failure rate for downstream dependencies`, + data: exitSpanChangePoints, + }; + }); - const exitSpanChangePointsPromise = handleError(() => - getExitSpanChangePoints({ + // service change points + dataFetchers.push(async () => { + const serviceChangePoints = await getServiceChangePoints({ apmEventClient, start: moment(alertStartedAt).subtract(6, 'hours').toISOString(), end: alertStartedAt, serviceName, serviceEnvironment, - }) - ); + transactionType: query['transaction.type'], + transactionName: query['transaction.name'], + }); + + return { + key: 'serviceChangePoints', + description: `Significant change points for "${serviceName}". Use this to spot dips and spikes in throughput, latency and failure rate`, + data: serviceChangePoints, + }; + }); - const anomaliesPromise = handleError(() => - getAnomalies({ + // Anomalies + dataFetchers.push(async () => { + const anomalies = await getAnomalies({ start: moment(alertStartedAt).subtract(1, 'hour').valueOf(), end: moment(alertStartedAt).valueOf(), environment: serviceEnvironment, mlClient, logger, - }) - ); - - const [ - serviceSummary, - downstreamDependencies, - logCategories, - apmErrors, - serviceChangePoints, - exitSpanChangePoints, - anomalies, - ] = await Promise.all([ - serviceSummaryPromise, - downstreamDependenciesPromise, - logCategoriesPromise, - apmErrorsPromise, - serviceChangePointsPromise, - exitSpanChangePointsPromise, - anomaliesPromise, - ]); + }); - return [ - { - key: 'serviceSummary', - description: `Metadata for the service "${serviceName}" that produced the alert. The alert might be caused by an issue in the service itself or one of its dependencies.`, - data: serviceSummary, - }, - { - key: 'downstreamDependencies', - description: `Downstream dependencies from the service "${serviceName}". Problems in these services can negatively affect the performance of "${serviceName}"`, - data: downstreamDependencies, - }, - { - key: 'serviceChangePoints', - description: `Significant change points for "${serviceName}". Use this to spot dips and spikes in throughput, latency and failure rate`, - data: serviceChangePoints, - }, - { - key: 'exitSpanChangePoints', - description: `Significant change points for the dependencies of "${serviceName}". Use this to spot dips or spikes in throughput, latency and failure rate for downstream dependencies`, - data: exitSpanChangePoints, - }, - { - key: 'logCategories', - description: `Related log events occurring shortly before the alert was triggered.`, - data: logCategoriesWithDownstreamServiceName(logCategories, downstreamDependencies), - }, - { - key: 'apmErrors', - description: `Exceptions for the service "${serviceName}". If a downstream service name is included this could be a possible root cause. If relevant please describe what the error means and what it could be caused by.`, - data: apmErrorsWithDownstreamServiceName(apmErrors, downstreamDependencies), - }, - { + return { key: 'anomalies', description: `Anomalies for services running in the environment "${serviceEnvironment}". Anomalies are detected using machine learning and can help you spot unusual patterns in your data.`, data: anomalies, - }, - ].filter(({ data }) => !isEmpty(data)); + }; + }); + + const items = await Promise.all( + dataFetchers.map(async (dataFetcher) => { + try { + return await dataFetcher(); + } catch (error) { + logger.error('Error while fetching observability alert details context'); + logger.error(error); + return; + } + }) + ); + + return items.filter((item) => item && !isEmpty(item.data)) as AlertDetailsContextualInsight[]; }; }; -function apmErrorsWithDownstreamServiceName( +function getApmErrorsWithDownstreamServiceName( apmErrors?: Awaited>, downstreamDependencies?: Awaited> ) { @@ -290,8 +304,8 @@ function apmErrorsWithDownstreamServiceName( } function logCategoriesWithDownstreamServiceName( - logCategories?: Awaited>, - downstreamDependencies?: Awaited> + logCategories?: LogCategory[], + downstreamDependencies?: APMDownstreamDependency[] ) { return logCategories?.map( ({ errorCategory, docCount, sampleMessage, downstreamServiceResource }) => { diff --git a/x-pack/plugins/observability_solution/observability/server/services/index.ts b/x-pack/plugins/observability_solution/observability/server/services/index.ts index 840bac95ee48b..3325a9d1dbfea 100644 --- a/x-pack/plugins/observability_solution/observability/server/services/index.ts +++ b/x-pack/plugins/observability_solution/observability/server/services/index.ts @@ -57,7 +57,7 @@ export interface AlertDetailsContextualInsightsRequestContext { }>; licensing: Promise; } -type AlertDetailsContextualInsightsHandler = ( +export type AlertDetailsContextualInsightsHandler = ( context: AlertDetailsContextualInsightsRequestContext, query: AlertDetailsContextualInsightsHandlerQuery ) => Promise; From 6b61af3fdebc79034458c3797fd9353734740150 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Tue, 2 Jul 2024 10:16:07 +0200 Subject: [PATCH 04/13] [CI] Use elastic-images-prod everywhere (#185952) ## Summary Some references to `elastic-images-qa` were left in the code, probably as these pipelines were on a pending PR when the rest got changed. Optionally, we should remove all the `imageProject` fields, and everything we're setting defaults - it's just generating bloat. --- .buildkite/pipelines/artifacts.yml | 20 +++++++++---------- .../pipelines/artifacts_container_image.yml | 2 +- .buildkite/pipelines/artifacts_trigger.yml | 2 +- .../rewrite_buildkite_agent_rules.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index 3f8a671a2d88a..8a4f407c9f225 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -3,7 +3,7 @@ steps: label: Build Kibana Artifacts agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: c2-standard-16 timeout_in_minutes: 75 @@ -18,7 +18,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -34,7 +34,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -50,7 +50,7 @@ steps: label: Artifact Testing agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp enableNestedVirtualization: true localSsds: 1 @@ -66,7 +66,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -81,7 +81,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -96,7 +96,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-2 timeout_in_minutes: 30 @@ -109,7 +109,7 @@ steps: label: 'Docker Context Verification' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -127,7 +127,7 @@ steps: - exit_status: -1 agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme @@ -154,7 +154,7 @@ steps: label: 'Publish Kibana Artifacts' agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp localSsds: 1 localSsdInterface: nvme diff --git a/.buildkite/pipelines/artifacts_container_image.yml b/.buildkite/pipelines/artifacts_container_image.yml index 8f4436fb7db9e..4788625c142d2 100644 --- a/.buildkite/pipelines/artifacts_container_image.yml +++ b/.buildkite/pipelines/artifacts_container_image.yml @@ -3,7 +3,7 @@ steps: label: Build serverless container images agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-16 timeout_in_minutes: 60 diff --git a/.buildkite/pipelines/artifacts_trigger.yml b/.buildkite/pipelines/artifacts_trigger.yml index 98851ddea31ad..760281dd4e584 100644 --- a/.buildkite/pipelines/artifacts_trigger.yml +++ b/.buildkite/pipelines/artifacts_trigger.yml @@ -3,7 +3,7 @@ steps: label: Trigger artifacts build agents: image: family/kibana-ubuntu-2004 - imageProject: elastic-images-qa + imageProject: elastic-images-prod provider: gcp machineType: n2-standard-2 timeout_in_minutes: 10 diff --git a/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts b/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts index 571e6de458365..6d33c3465c59f 100644 --- a/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts +++ b/src/dev/buildkite_migration/rewrite_buildkite_agent_rules.ts @@ -210,7 +210,7 @@ function getFullAgentTargetingRule(queue: string): GobldGCPConfig { // Mapping based on expected fields in https://github.com/elastic/ci/blob/0df8430357109a19957dcfb1d867db9cfdd27937/docs/gobld/providers.mdx#L96 return removeNullish({ image: 'family/kibana-ubuntu-2004', - imageProject: 'elastic-images-qa', + imageProject: 'elastic-images-prod', provider: 'gcp', assignExternalIP: agent.disableExternalIp === true ? false : undefined, diskSizeGb: agent.diskSizeGb, From 2758dbbeca298f15000ab578d4be23e43008efc5 Mon Sep 17 00:00:00 2001 From: Jill Guyonnet Date: Tue, 2 Jul 2024 10:01:26 +0100 Subject: [PATCH 05/13] [Fleet] Use API key for standalone agent onboarding (#187133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes https://github.com/elastic/kibana/issues/167218 This PR replaces the username and password with API key config in standalone agent onboarding. Note: the Observability Logs onboarding (at `app/observabilityOnboarding/systemLogs/?category=logs`) shows the encoded API key, I thought it would make more sense to show it in the Beats format (see https://www.elastic.co/guide/en/fleet/current/grant-access-to-elasticsearch.html). ### Testing Below are the steps for testing standalone agent install from the main Fleet UI (Agents table). I am not entirely sure how to test [the component used in CreatePackagePolicyPage](https://github.com/elastic/kibana/blob/main/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), so I'd appreciate a comment on that. 🙏 ⚠️ Ideally, this should also be tested in serverless config. 1. Open the Add agent flyout and select Standalone. Notice that the agent yaml config contains an `api_key` entry instead of `username` and `password`. 4. Click the new "Create API key" button. This should create a new API key and that can be copied (you can find this key under Stack Management -> Api keys, it should be called `standalone_agent-{randomString}`). 5. Check that the `{API_KEY}` placeholder in the agent yaml was updated with the API key value. 6. Download an Elastic Agent, e.g. on a VM. 7. Modify the `elastic-agent.yml` file of the agent to the yaml from the UI. If using a Multipass VM, you can for instance download it from the UI and copy it using `multipass transfer :./`. 8. Install the agent as standalone: `sudo ./elastic-agent install` (answer `n` when asked about enrolling in Fleet). 9. Check the agent status and logs with `sudo elastic-agent status` and `sudo elastic-agent logs`. 10. In the UI, go to Discover and search for the agent host name in the logs, it should appear. ### Screenshots Current behaviour (on `main`): Screenshot 2024-06-28 at 10 38 44 With this change: Screenshot 2024-06-28 at 10 31 35 After having clicked "Create API key": Screenshot 2024-06-28 at 10 31 58 ### Checklist - [ ] 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 - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] 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)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: criamico Co-authored-by: Elastic Machine --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../plugins/fleet/common/constants/routes.ts | 3 +- .../services/full_agent_policy_to_yaml.ts | 16 ++- .../fleet/common/types/rest_spec/index.ts | 1 + .../rest_spec/standalone_agent_api_key.ts | 19 +++ .../install_agent_standalone.tsx | 63 ++------- .../agent_enrollment_flyout/hooks.tsx | 120 +++++++++++++++++- .../steps/compute_steps.tsx | 95 ++------------ .../steps/configure_standalone_agent_step.tsx | 92 ++++++++++++-- .../fleet/public/hooks/use_request/index.ts | 1 + .../use_request/standalone_agent_api_key.ts | 24 ++++ x-pack/plugins/fleet/public/types/index.ts | 2 + .../plugins/fleet/server/constants/index.ts | 1 + .../server/routes/agent_policy/handlers.ts | 4 +- x-pack/plugins/fleet/server/routes/index.ts | 2 + .../standalone_agent_api_key/handler.ts | 26 ++++ .../routes/standalone_agent_api_key/index.ts | 34 +++++ .../agent_policies/full_agent_policy.test.ts | 7 +- .../agent_policies/full_agent_policy.ts | 4 +- .../create_standalone_agent_api_key.ts | 31 +++++ .../fleet/server/services/api_keys/index.ts | 1 + .../fleet/server/types/rest_spec/index.ts | 1 + .../rest_spec/standalone_agent_api_key.ts | 14 ++ 24 files changed, 404 insertions(+), 159 deletions(-) create mode 100644 x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts create mode 100644 x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts create mode 100644 x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts create mode 100644 x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts create mode 100644 x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts create mode 100644 x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index a135d8a58ae96..951bdbb531d60 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -848,6 +848,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D datastreamsDownsampling: `${ELASTICSEARCH_DOCS}downsampling.html`, installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, installElasticAgentStandalone: `${FLEET_DOCS}install-standalone-elastic-agent.html`, + grantESAccessToStandaloneAgents: `${FLEET_DOCS}grant-access-to-elasticsearch.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: isServerless ? `${SERVERLESS_DOCS}api-keys` : `${KIBANA_DOCS}api-keys.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 7970d7dadb4b9..7a296aac6d8ba 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -537,6 +537,7 @@ export interface DocLinks { datastreamsDownsampling: string; installElasticAgent: string; installElasticAgentStandalone: string; + grantESAccessToStandaloneAgents: string; packageSignatures: string; upgradeElasticAgent: string; learnMoreBlog: string; diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 2f32d66f4ec74..0ff598fc0dd47 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -212,8 +212,9 @@ export const DOWNLOAD_SOURCE_API_ROUTES = { DELETE_PATTERN: `${API_ROOT}/agent_download_sources/{sourceId}`, }; -// Fleet debug routes +export const CREATE_STANDALONE_AGENT_API_KEY_ROUTE = `${INTERNAL_ROOT}/create_standalone_agent_api_key`; +// Fleet debug routes export const FLEET_DEBUG_ROUTES = { INDEX_PATTERN: `${INTERNAL_ROOT}/debug/index`, SAVED_OBJECTS_PATTERN: `${INTERNAL_ROOT}/debug/saved_objects`, diff --git a/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts b/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts index 18d995c96f2b8..4d464427a998e 100644 --- a/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts +++ b/x-pack/plugins/fleet/common/services/full_agent_policy_to_yaml.ts @@ -28,15 +28,20 @@ const POLICY_KEYS_ORDER = [ 'signed', ]; -export const fullAgentPolicyToYaml = (policy: FullAgentPolicy, toYaml: typeof safeDump): string => { +export const fullAgentPolicyToYaml = ( + policy: FullAgentPolicy, + toYaml: typeof safeDump, + apiKey?: string +): string => { const yaml = toYaml(policy, { skipInvalid: true, sortKeys: _sortYamlKeys, }); + const formattedYml = apiKey ? replaceApiKey(yaml, apiKey) : yaml; - if (!policy?.secret_references?.length) return yaml; + if (!policy?.secret_references?.length) return formattedYml; - return _formatSecrets(policy.secret_references, yaml); + return _formatSecrets(policy.secret_references, formattedYml); }; export function _sortYamlKeys(keyA: string, keyB: string) { @@ -67,3 +72,8 @@ function _formatSecrets( return formattedText; } + +function replaceApiKey(ymlText: string, apiKey: string) { + const regex = new RegExp(/\'\${API_KEY}\'/, 'g'); + return ymlText.replace(regex, `'${apiKey}'`); +} diff --git a/x-pack/plugins/fleet/common/types/rest_spec/index.ts b/x-pack/plugins/fleet/common/types/rest_spec/index.ts index 34613da13f9d3..7aeaad859803b 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/index.ts @@ -20,3 +20,4 @@ export * from './package_policy'; export * from './settings'; export * from './health_check'; export * from './fleet_server_hosts'; +export * from './standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts b/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.ts new file mode 100644 index 0000000000000..3bb4e3f05ed64 --- /dev/null +++ b/x-pack/plugins/fleet/common/types/rest_spec/standalone_agent_api_key.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. + */ + +import type { SecurityCreateApiKeyResponse } from '@elastic/elasticsearch/lib/api/types'; + +export interface PostStandaloneAgentAPIKeyRequest { + body: { + name: string; + }; +} + +export interface PostStandaloneAgentAPIKeyResponse { + action: string; + item: SecurityCreateApiKeyResponse; +} 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 e2f92331759ac..1b0e791fbfd8c 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 @@ -5,79 +5,37 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSteps, EuiSpacer } from '@elastic/eui'; -import { safeDump } from 'js-yaml'; -import type { FullAgentPolicy } from '../../../../../../../../../../common/types/models/agent_policy'; -import { API_VERSIONS } from '../../../../../../../../../../common/constants'; import { getRootIntegrations } from '../../../../../../../../../../common/services'; import { AgentStandaloneBottomBar, StandaloneModeWarningCallout, NotObscuredByBottomBar, } from '../..'; -import { - fullAgentPolicyToYaml, - agentPolicyRouteService, -} from '../../../../../../../../../services'; import { Error as FleetError } from '../../../../../../../components'; -import { - useKibanaVersion, - useStartServices, - sendGetOneAgentPolicyFull, -} from '../../../../../../../../../hooks'; +import { useKibanaVersion } from '../../../../../../../../../hooks'; import { InstallStandaloneAgentStep, ConfigureStandaloneAgentStep, } from '../../../../../../../../../components/agent_enrollment_flyout/steps'; import { StandaloneInstructions } from '../../../../../../../../../components/enrollment_instructions'; +import { useFetchFullPolicy } from '../../../../../../../../../components/agent_enrollment_flyout/hooks'; + import type { InstallAgentPageProps } from './types'; export const InstallElasticAgentStandalonePageStep: React.FC = (props) => { const { setIsManaged, agentPolicy, cancelUrl, onNext, cancelClickHandler } = props; - const core = useStartServices(); + const kibanaVersion = useKibanaVersion(); - const [yaml, setYaml] = useState(''); const [commandCopied, setCommandCopied] = useState(false); const [policyCopied, setPolicyCopied] = useState(false); - const [fullAgentPolicy, setFullAgentPolicy] = useState(); - useEffect(() => { - async function fetchFullPolicy() { - try { - if (!agentPolicy?.id) { - return; - } - const query = { standalone: true, kubernetes: false }; - const res = await sendGetOneAgentPolicyFull(agentPolicy?.id, query); - if (res.error) { - throw res.error; - } - - if (!res.data) { - throw new Error('No data while fetching full agent policy'); - } - setFullAgentPolicy(res.data.item); - } catch (error) { - core.notifications.toasts.addError(error, { - title: 'Error', - }); - } - } - fetchFullPolicy(); - }, [core.http.basePath, agentPolicy?.id, core.notifications.toasts]); - - useEffect(() => { - if (!fullAgentPolicy) { - return; - } - - setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); - }, [fullAgentPolicy]); + const { yaml, onCreateApiKey, apiKey, downloadYaml } = useFetchFullPolicy(agentPolicy); if (!agentPolicy) { return ( @@ -95,16 +53,13 @@ export const InstallElasticAgentStandalonePageStep: React.FC setPolicyCopied(true), }), diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx index 139268fbb2408..326a3c973ea00 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/hooks.tsx @@ -4,11 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useState, useEffect, useMemo } from 'react'; +import crypto from 'crypto'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; +import { safeDump } from 'js-yaml'; + import type { PackagePolicy, AgentPolicy } from '../../types'; -import { sendGetOneAgentPolicy, useGetPackageInfoByKeyQuery, useStartServices } from '../../hooks'; +import { + sendGetOneAgentPolicy, + sendGetOneAgentPolicyFull, + useGetPackageInfoByKeyQuery, + useStartServices, +} from '../../hooks'; import { FLEET_KUBERNETES_PACKAGE, FLEET_CLOUD_SECURITY_POSTURE_PACKAGE, @@ -23,6 +32,12 @@ import { SUPPORTED_TEMPLATES_URL_FROM_AGENT_POLICY_CONFIG, } from '../cloud_security_posture/services'; +import { sendCreateStandaloneAgentAPIKey } from '../../hooks'; + +import type { FullAgentPolicy } from '../../../common'; + +import { fullAgentPolicyToYaml } from '../../services'; + import type { K8sMode, CloudSecurityIntegrationType, @@ -190,3 +205,104 @@ const getCloudSecurityPackagePolicyFromAgentPolicy = ( (input) => input.package?.name === FLEET_CLOUD_SECURITY_POSTURE_PACKAGE ); }; + +export function useGetCreateApiKey() { + const core = useStartServices(); + + const [apiKey, setApiKey] = useState(undefined); + const onCreateApiKey = useCallback(async () => { + try { + const res = await sendCreateStandaloneAgentAPIKey({ + name: crypto.randomBytes(16).toString('hex'), + }); + const newApiKey = `${res.data?.item.id}:${res.data?.item.api_key}`; + setApiKey(newApiKey); + } catch (err) { + core.notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.standaloneAgentPage.errorCreatingAgentAPIKey', { + defaultMessage: 'Error creating Agent API Key', + }), + }); + } + }, [core.notifications.toasts]); + return { + apiKey, + onCreateApiKey, + }; +} + +export function useFetchFullPolicy(agentPolicy: AgentPolicy | undefined, isK8s?: K8sMode) { + const core = useStartServices(); + const [yaml, setYaml] = useState(''); + const [fullAgentPolicy, setFullAgentPolicy] = useState(); + const { apiKey, onCreateApiKey } = useGetCreateApiKey(); + + useEffect(() => { + async function fetchFullPolicy() { + try { + if (!agentPolicy?.id) { + return; + } + let query = { standalone: true, kubernetes: false }; + if (isK8s === 'IS_KUBERNETES') { + query = { standalone: true, kubernetes: true }; + } + const res = await sendGetOneAgentPolicyFull(agentPolicy?.id, query); + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data while fetching full agent policy'); + } + setFullAgentPolicy(res.data.item); + } catch (error) { + core.notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.standaloneAgentPage.errorFetchingFullAgentPolicy', { + defaultMessage: 'Error fetching full agent policy', + }), + }); + } + } + + if (isK8s === 'IS_NOT_KUBERNETES' || isK8s !== 'IS_LOADING') { + fetchFullPolicy(); + } + }, [core.http.basePath, agentPolicy?.id, core.notifications.toasts, apiKey, isK8s, agentPolicy]); + + useEffect(() => { + if (!fullAgentPolicy) { + return; + } + + if (isK8s === 'IS_KUBERNETES') { + if (typeof fullAgentPolicy === 'object') { + return; + } + setYaml(fullAgentPolicy); + } else { + if (typeof fullAgentPolicy === 'string') { + return; + } + setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump, apiKey)); + } + }, [apiKey, fullAgentPolicy, isK8s]); + + const downloadYaml = useMemo( + () => () => { + const link = document.createElement('a'); + link.href = `data:text/json;charset=utf-8,${yaml}`; + link.download = `elastic-agent.yaml`; + link.click(); + }, + [yaml] + ); + + return { + yaml, + onCreateApiKey, + fullAgentPolicy, + apiKey, + downloadYaml, + }; +} 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 615c6399b202f..bc4e7755044e7 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 @@ -5,28 +5,20 @@ * 2.0. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiSteps, EuiLoadingSpinner } from '@elastic/eui'; -import { safeDump } from 'js-yaml'; import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; -import type { FullAgentPolicy } from '../../../../common/types/models/agent_policy'; -import { API_VERSIONS } from '../../../../common/constants'; import { getRootIntegrations } from '../../../../common/services'; -import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services'; import { getGcpIntegrationDetailsFromAgentPolicy } from '../../cloud_security_posture/services'; import { StandaloneInstructions, ManualInstructions } from '../../enrollment_instructions'; -import { - useGetOneEnrollmentAPIKey, - useStartServices, - sendGetOneAgentPolicyFull, - useAgentVersion, -} from '../../../hooks'; +import { useGetOneEnrollmentAPIKey, useStartServices, useAgentVersion } from '../../../hooks'; +import { useFetchFullPolicy } from '../hooks'; import type { InstructionProps } from '../types'; import { usePollingAgentCount } from '../confirm_agent_enrollment'; @@ -62,74 +54,7 @@ export const StandaloneSteps: React.FunctionComponent = ({ isK8s, cloudSecurityIntegration, }) => { - const core = useStartServices(); - const { notifications } = core; - const [fullAgentPolicy, setFullAgentPolicy] = useState(); - const [yaml, setYaml] = useState(''); - - let downloadLink = ''; - - if (selectedPolicy?.id) { - downloadLink = - isK8s === 'IS_KUBERNETES' - ? core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath( - selectedPolicy?.id - )}?kubernetes=true&standalone=true&apiVersion=${API_VERSIONS.public.v1}` - ) - : core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath( - selectedPolicy?.id - )}?standalone=true&apiVersion=${API_VERSIONS.public.v1}` - ); - } - - useEffect(() => { - async function fetchFullPolicy() { - try { - if (!selectedPolicy?.id) { - return; - } - let query = { standalone: true, kubernetes: false }; - if (isK8s === 'IS_KUBERNETES') { - query = { standalone: true, kubernetes: true }; - } - const res = await sendGetOneAgentPolicyFull(selectedPolicy?.id, query); - if (res.error) { - throw res.error; - } - - if (!res.data) { - throw new Error('No data while fetching full agent policy'); - } - setFullAgentPolicy(res.data.item); - } catch (error) { - notifications.toasts.addError(error, { - title: 'Error', - }); - } - } - if (isK8s !== 'IS_LOADING') { - fetchFullPolicy(); - } - }, [selectedPolicy, notifications.toasts, isK8s, core.http.basePath]); - - useEffect(() => { - if (!fullAgentPolicy) { - return; - } - if (isK8s === 'IS_KUBERNETES') { - if (typeof fullAgentPolicy === 'object') { - return; - } - setYaml(fullAgentPolicy); - } else { - if (typeof fullAgentPolicy === 'string') { - return; - } - setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); - } - }, [fullAgentPolicy, isK8s]); + const { yaml, onCreateApiKey, apiKey, downloadYaml } = useFetchFullPolicy(selectedPolicy, isK8s); const agentVersion = useAgentVersion(); @@ -160,7 +85,9 @@ export const StandaloneSteps: React.FunctionComponent = ({ isK8s, selectedPolicyId: selectedPolicy?.id, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, }) ); @@ -176,8 +103,6 @@ export const StandaloneSteps: React.FunctionComponent = ({ return steps; }, [ agentVersion, - isK8s, - cloudSecurityIntegration, agentPolicy, selectedPolicy, agentPolicies, @@ -186,8 +111,12 @@ export const StandaloneSteps: React.FunctionComponent = ({ setSelectedPolicyId, refreshAgentPolicies, selectionType, + isK8s, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, + cloudSecurityIntegration, mode, setMode, ]); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx index 289c5b8ad8df2..30b08bf3a808e 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps/configure_standalone_agent_step.tsx @@ -16,6 +16,9 @@ import { EuiCopy, EuiCodeBlock, EuiLink, + EuiCallOut, + EuiFieldText, + EuiButtonIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -27,21 +30,25 @@ import { useStartServices } from '../../../hooks'; export const ConfigureStandaloneAgentStep = ({ isK8s, - selectedPolicyId, yaml, - downloadLink, + downloadYaml, + apiKey, + onCreateApiKey, isComplete, onCopy, }: { isK8s?: K8sMode; selectedPolicyId?: string; yaml: string; - downloadLink: string; + downloadYaml: () => void; + apiKey: string | undefined; + onCreateApiKey: () => void; isComplete?: boolean; onCopy?: () => void; }): EuiContainedStepProps => { const core = useStartServices(); const { docLinks } = core; + const policyMsg = isK8s === 'IS_KUBERNETES' ? ( elastic-agent.yml, - ESUsernameVariable: ES_USERNAME, - ESPasswordVariable: ES_PASSWORD, + apiKeyVariable: API_KEY, outputSection: outputs, + guideLink: ( + + + + ), }} /> ); @@ -89,6 +107,7 @@ export const ConfigureStandaloneAgentStep = ({ defaultMessage="Download Policy" /> ); + return { title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigureAgentTitle', { defaultMessage: 'Configure the agent', @@ -99,7 +118,63 @@ export const ConfigureStandaloneAgentStep = ({ <>{policyMsg} + {apiKey && ( + +

+ {i18n.translate('xpack.fleet.agentEnrollment.apiKeyBanner.created.description', { + defaultMessage: + 'Remember to store this information in a safe place. It won’t be displayed anymore after you continue.', + })} +

+ + {(copy) => ( + svg.euiIcon': { + borderRadius: '0 !important', + }, + }} + aria-label={i18n.translate('xpack.fleet.apiKeyBanner.field.copyButton', { + defaultMessage: 'Copy to clipboard', + })} + /> + )} + + } + /> +
+ )} + + + + + + {(copy) => ( @@ -119,14 +194,13 @@ export const ConfigureStandaloneAgentStep = ({ - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { if (onCopy) onCopy(); + downloadYaml(); }} - isDisabled={!downloadLink} + isDisabled={!downloadYaml} > <>{downloadMsg} diff --git a/x-pack/plugins/fleet/public/hooks/use_request/index.ts b/x-pack/plugins/fleet/public/hooks/use_request/index.ts index 448b934bb3411..dc2f1292220cb 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/index.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/index.ts @@ -12,6 +12,7 @@ export * from './data_stream'; export * from './agents'; export * from './enrollment_api_keys'; export * from './epm'; +export * from './standalone_agent_api_key'; export * from './outputs'; export * from './settings'; export * from './setup'; diff --git a/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts b/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts new file mode 100644 index 0000000000000..3df53fd4f35f1 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_request/standalone_agent_api_key.ts @@ -0,0 +1,24 @@ +/* + * 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 { + PostStandaloneAgentAPIKeyRequest, + PostStandaloneAgentAPIKeyResponse, +} from '../../types'; + +import { API_VERSIONS, CREATE_STANDALONE_AGENT_API_KEY_ROUTE } from '../../../common/constants'; + +import { sendRequest } from './use_request'; + +export function sendCreateStandaloneAgentAPIKey(body: PostStandaloneAgentAPIKeyRequest['body']) { + return sendRequest({ + method: 'post', + path: CREATE_STANDALONE_AGENT_API_KEY_ROUTE, + version: API_VERSIONS.internal.v1, + body, + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index 82c13c6fc0e52..aeb6d302adaa8 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -80,6 +80,8 @@ export type { GetOneEnrollmentAPIKeyResponse, PostEnrollmentAPIKeyRequest, PostEnrollmentAPIKeyResponse, + PostStandaloneAgentAPIKeyRequest, + PostStandaloneAgentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, GetCurrentUpgradesResponse, diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index adb7858094865..d727cd30c6385 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -38,6 +38,7 @@ export { PRECONFIGURATION_API_ROUTES, DOWNLOAD_SOURCE_API_ROOT, DOWNLOAD_SOURCE_API_ROUTES, + CREATE_STANDALONE_AGENT_API_KEY_ROUTE, FLEET_DEBUG_ROUTES, // Saved Object indices INGEST_SAVED_OBJECT_INDEX, diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index b6355bf0c7f78..8b7a93f6f332e 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -377,7 +377,9 @@ export const getFullAgentPolicy: FleetRequestHandler< const fullAgentPolicy = await agentPolicyService.getFullAgentPolicy( soClient, request.params.agentPolicyId, - { standalone: request.query.standalone === true } + { + standalone: request.query.standalone === true, + } ); if (fullAgentPolicy) { const body: GetFullAgentPolicyResponse = { diff --git a/x-pack/plugins/fleet/server/routes/index.ts b/x-pack/plugins/fleet/server/routes/index.ts index 77c4fa9eb4249..29efa03967ea0 100644 --- a/x-pack/plugins/fleet/server/routes/index.ts +++ b/x-pack/plugins/fleet/server/routes/index.ts @@ -26,6 +26,7 @@ import { registerRoutes as registerFleetServerHostRoutes } from './fleet_server_ import { registerRoutes as registerFleetProxiesRoutes } from './fleet_proxies'; import { registerRoutes as registerMessageSigningServiceRoutes } from './message_signing_service'; import { registerRoutes as registerUninstallTokenRoutes } from './uninstall_token'; +import { registerRoutes as registerStandaloneAgentApiKeyRoutes } from './standalone_agent_api_key'; import { registerRoutes as registerDebugRoutes } from './debug'; export function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: FleetConfigType) { @@ -48,6 +49,7 @@ export function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: Fleet registerHealthCheckRoutes(fleetAuthzRouter); registerMessageSigningServiceRoutes(fleetAuthzRouter); registerUninstallTokenRoutes(fleetAuthzRouter, config); + registerStandaloneAgentApiKeyRoutes(fleetAuthzRouter); registerDebugRoutes(fleetAuthzRouter); // Conditional config routes diff --git a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts new file mode 100644 index 0000000000000..99c349899aaa6 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/handler.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; + +import { createStandaloneAgentApiKey } from '../../services/api_keys'; +import type { FleetRequestHandler, PostStandaloneAgentAPIKeyRequestSchema } from '../../types'; + +export const createStandaloneAgentApiKeyHandler: FleetRequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asCurrentUser; + const key = await createStandaloneAgentApiKey(esClient, request.body.name); + return response.ok({ + body: { + item: key, + }, + }); +}; diff --git a/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts new file mode 100644 index 0000000000000..9255f058aee46 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/standalone_agent_api_key/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { FleetAuthzRouter } from '../../services/security'; + +import { API_VERSIONS } from '../../../common/constants'; + +import { CREATE_STANDALONE_AGENT_API_KEY_ROUTE } from '../../constants'; + +import { PostStandaloneAgentAPIKeyRequestSchema } from '../../types'; + +import { createStandaloneAgentApiKeyHandler } from './handler'; + +export const registerRoutes = (router: FleetAuthzRouter) => { + router.versioned + .post({ + path: CREATE_STANDALONE_AGENT_API_KEY_ROUTE, + access: 'internal', + fleetAuthz: { + fleet: { all: true }, + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: { request: PostStandaloneAgentAPIKeyRequestSchema }, + }, + createStandaloneAgentApiKeyHandler + ); +}; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts index 5705f569d9fa4..5701a60b56d03 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts @@ -817,7 +817,7 @@ ssl.test: 123 `); }); - it('should return placeholder ES_USERNAME and ES_PASSWORD for elasticsearch output type in standalone ', () => { + it('should return placeholder API_KEY for elasticsearch output type in standalone ', () => { const policyOutput = transformOutputToFullPolicyOutput( { id: 'id123', @@ -833,18 +833,17 @@ ssl.test: 123 expect(policyOutput).toMatchInlineSnapshot(` Object { + "api_key": "\${API_KEY}", "hosts": Array [ "http://host.fr", ], - "password": "\${ES_PASSWORD}", "preset": "balanced", "type": "elasticsearch", - "username": "\${ES_USERNAME}", } `); }); - it('should not return placeholder ES_USERNAME and ES_PASSWORD for logstash output type in standalone ', () => { + it('should not return placeholder API_KEY for logstash output type in standalone ', () => { const policyOutput = transformOutputToFullPolicyOutput( { id: 'id123', diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts index b8e64be494651..efc3a732149d6 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts @@ -491,8 +491,8 @@ export function transformOutputToFullPolicyOutput( } if (output.type === outputType.Elasticsearch && standalone) { - newOutput.username = '${ES_USERNAME}'; - newOutput.password = '${ES_PASSWORD}'; + // adding a place_holder as API_KEY + newOutput.api_key = '${API_KEY}'; } if (output.type === outputType.RemoteElasticsearch) { diff --git a/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts new file mode 100644 index 0000000000000..011d8dfe8ec82 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/api_keys/create_standalone_agent_api_key.ts @@ -0,0 +1,31 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; + +export function createStandaloneAgentApiKey(esClient: ElasticsearchClient, name: string) { + // Based on https://www.elastic.co/guide/en/fleet/master/grant-access-to-elasticsearch.html#create-api-key-standalone-agent + return esClient.security.createApiKey({ + body: { + name: `standalone_agent-${name}`, + metadata: { + managed: true, + }, + role_descriptors: { + standalone_agent: { + cluster: ['monitor'], + indices: [ + { + names: ['logs-*-*', 'metrics-*-*', 'traces-*-*', 'synthetics-*-*'], + privileges: ['auto_configure', 'create_doc'], + }, + ], + }, + }, + }, + }); +} diff --git a/x-pack/plugins/fleet/server/services/api_keys/index.ts b/x-pack/plugins/fleet/server/services/api_keys/index.ts index 7b96d71c7ac9c..6421de567b742 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/index.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/index.ts @@ -8,3 +8,4 @@ export { invalidateAPIKeys } from './security'; export { generateLogstashApiKey, canCreateLogstashApiKey } from './logstash_api_keys'; export * from './enrollment_api_key'; +export { createStandaloneAgentApiKey } from './create_standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/index.ts b/x-pack/plugins/fleet/server/types/rest_spec/index.ts index ebdaa02902e37..04f9322354104 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/index.ts @@ -23,3 +23,4 @@ export * from './tags'; export * from './health_check'; export * from './message_signing_service'; export * from './app'; +export * from './standalone_agent_api_key'; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts b/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts new file mode 100644 index 0000000000000..f63db720a97cc --- /dev/null +++ b/x-pack/plugins/fleet/server/types/rest_spec/standalone_agent_api_key.ts @@ -0,0 +1,14 @@ +/* + * 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 PostStandaloneAgentAPIKeyRequestSchema = { + body: schema.object({ + name: schema.string(), + }), +}; From d003ae302372d4140f33c48dc05463a78f7e8896 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Tue, 2 Jul 2024 11:26:26 +0200 Subject: [PATCH 06/13] [Dataset quality] EBT for Datasets table and Dataset Details (#187125) Implement the Event Based Telemetry for Datasets table (main page) and the Dataset details (the flyout at the time of writing). The following EBT events are reported: - `"Dataset Quality Navigated"` - `"Dataset Quality Dataset Details Opened"` - `"Dataset Quality Dataset Details Navigated"` The above allow to track the following: 1. Used query, available and chosen filters for "Integrations", "Namespaces" and "Qualities" when user clicks on the degraded documents percentage link (with `_ignored` filter) from main table's column or "Open" link from row actions. 2. Dataset health, percentage of degraded documents, duration to load and breakdown field when user opens the flyout. 3. All included in 2 plus whether `_ignored` filter is present in navigation, source and target of the navigation when user navigates away from the flyout. 4. All events also track selected date range and user's privileges state for the respective data stream. ### Main page - Datasets table Event Name: `"Dataset Quality Navigated"` This event is reported whenever a degraded percentage link or "Open" link is navigated to on the main datasets table. The following properties are tracked: ### Properties | Property | Type | Schema Type | Description | | --- | --- | --- | --- | | `index_name` | string | keyword | The name of the index e.g. `logs-apache.access-default` | | `data_stream` | object | object | Object containing [ECS Data Stream Fields](https://www.elastic.co/guide/en/ecs/current/ecs-data_stream.html) i.e. `dataset`, `namespace`, and `type` | | `data_stream_health` | object | keyword | Any of `"poor"`, `"degraded"` and `"good"` representing the health/quality of data stream | | `data_stream_aggregatable` | boolean | boolean | A boolean indicating whether the data stream is aggregatable for the `_ignored` field | | `from` | string | date | ISO start date string selected on datepicker | | `to` | string | date | ISO end date string selected on datepicker | | `degraded_percentage` | number | float | A number representing the percentage of degraded documents in the data stream | | `integration` | string (optional) | keyword | An optional string representing the integration name associated with the dataset | | `privileges` | object | object | An object representing the privileges. It includes `can_monitor_data_stream`, `can_view_integrations`, and an optional `can_view_dashboards`. All are boolean. | | `filters` | object | object | An object containing filter details. It includes `is_degraded`, `query_length`, `integrations`, `namespaces`, and `qualities`. See below for more details | The `filters` property is an object with the following sub-properties: |
Sub-Property
|
Type
|
Schema Type
| Description | | --- | --- | --- | --- | | `is_degraded` | boolean | boolean | A boolean indicating whether navigation included `ignored` filter | | `query_length` | number | short | The length of the query used | | `integrations` | object | object | An object including `total`, `included`, and `excluded` properties representing applied filters. | | `namespaces` | object | object | An object including `total`, `included`, and `excluded` properties representing applied filters | | `qualities` | object | object | An object including `total`, `included`, and `excluded` properties representing applied filters | ### Details page - Flyout Event `"Dataset Quality Dataset Details Opened"` is reported when flyout is opened whereas `"Dataset Quality Dataset Details Navigated"` is reported when a link is clicked on the flyout which navigates the user away from Dataset Quality page. Important properties are tracked which help analyse the state user had before the navigation e.g. breakdown field, selected date range and whether user clicked the degraded docs or all docs link. _Note that, the flyout is expected to be converted into a routed page, hence "Dataset Details" is used for event names instead of the flyout._ #### Properties `"Dataset Quality Dataset Details Opened"` only differs from [`"Dataset Quality Navigated"`](#dqn) by the following properties: |
Property
|
Type
|
Schema Type
| Description | | --- | --- | --- | --- | | `tracking_id` | string | keyword | Id to group flyout opening and navigation for funnel analysis | | `duration` | number | long | Time it took in milliseconds from opening the flyout until the data stream details are available | | `breakdown_field` | string (optional) | keyword | Fields used to break the chart down by | `"Dataset Quality Dataset Details Navigated"` only differs from [`"Dataset Quality Navigated"`](#dqn) by the following properties: |
Property
|
Type
|
Schema Type
| Description | | --- | --- | --- | --- | | `tracking_id` | string | keyword | Id to group flyout opening and navigation for funnel analysis | | `filters` | object | object | `{ "is_degraded": }` which represent whether the user is navigating with `_ignored` filter applied| | `breakdown_field` | string (optional) | keyword | Fields used to break the chart down by | | `target` | enum value | keyword | Action that user took to navigate away from the dataset details page. Possible values are `Exit`, `LogsExplorer`, `Discover`, `Lens`, `Integration`, `IndexTemplate`, `Dashboard`, `Hosts` and `Services` | | `source` | enum value | keyword | Section of dataset details page the action is originated from. Possible values are `"Header"`, `"Footer"`, `"Summary"`, `"Chart"`, `"Table"` and `"ActionMenu"` | --- .../components/dataset_quality/context.ts | 2 + .../dataset_quality/dataset_quality.tsx | 7 +- .../dataset_quality/table/columns.tsx | 8 +- .../table/degraded_docs_percentage_link.tsx | 10 +- .../public/components/flyout/flyout.tsx | 10 +- .../flyout_summary_kpi_item.tsx | 15 +- .../flyout_summary/flyout_summary_kpis.tsx | 19 +- .../flyout_summary/get_summary_kpis.test.ts | 21 +- .../flyout/flyout_summary/get_summary_kpis.ts | 56 ++- .../public/components/flyout/header.tsx | 11 +- .../dataset_quality/public/hooks/index.ts | 1 + .../public/hooks/use_degraded_docs_chart.tsx | 14 +- .../hooks/use_flyout_integration_actions.tsx | 56 ++- .../public/hooks/use_redirect_link.ts | 87 +++-- .../public/hooks/use_telemetry.tsx | 352 ++++++++++++++++++ .../dataset_quality/public/plugin.tsx | 8 +- .../public/services/telemetry/index.ts | 10 + .../services/telemetry/telemetry_client.ts | 58 +++ .../services/telemetry/telemetry_events.ts | 261 +++++++++++++ .../telemetry/telemetry_service.test.ts | 177 +++++++++ .../services/telemetry/telemetry_service.ts | 36 ++ .../public/services/telemetry/types.ts | 124 ++++++ .../dataset_quality/tsconfig.json | 2 + .../dataset_quality/dataset_quality_flyout.ts | 20 +- .../dataset_quality/dataset_quality_flyout.ts | 20 +- 25 files changed, 1262 insertions(+), 123 deletions(-) create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts create mode 100644 x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts index 9d89c8522d7dc..460aad2f02476 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/context.ts @@ -6,9 +6,11 @@ */ import { createContext, useContext } from 'react'; import { DatasetQualityControllerStateService } from '../../state_machines/dataset_quality_controller'; +import { ITelemetryClient } from '../../services/telemetry'; export interface DatasetQualityContextValue { service: DatasetQualityControllerStateService; + telemetryClient: ITelemetryClient; } export const DatasetQualityContext = createContext({} as DatasetQualityContextValue); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx index 2a06fddf9f64d..8ae6f1f74bd2e 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/dataset_quality.tsx @@ -13,7 +13,7 @@ import { DatasetQualityContext, DatasetQualityContextValue } from './context'; import { useKibanaContextForPluginProvider } from '../../utils'; import { DatasetQualityStartDeps } from '../../types'; import { DatasetQualityController } from '../../controller'; -import { IDataStreamsStatsClient } from '../../services/data_streams_stats'; +import { ITelemetryClient } from '../../services/telemetry'; export interface DatasetQualityProps { controller: DatasetQualityController; @@ -22,13 +22,13 @@ export interface DatasetQualityProps { export interface CreateDatasetQualityArgs { core: CoreStart; plugins: DatasetQualityStartDeps; - dataStreamStatsClient: IDataStreamsStatsClient; + telemetryClient: ITelemetryClient; } export const createDatasetQuality = ({ core, plugins, - dataStreamStatsClient, + telemetryClient, }: CreateDatasetQualityArgs) => { return ({ controller }: DatasetQualityProps) => { const SummaryPanelProvider = dynamic(() => import('../../hooks/use_summary_panel')); @@ -37,6 +37,7 @@ export const createDatasetQuality = ({ const datasetQualityProviderValue: DatasetQualityContextValue = useMemo( () => ({ service: controller.service, + telemetryClient, }), [controller.service] ); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx index 60f959176866b..515307df1a200 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/columns.tsx @@ -31,6 +31,7 @@ import { BYTE_NUMBER_FORMAT, } from '../../../../common/constants'; import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat'; +import { NavigationSource } from '../../../services/telemetry'; import { DatasetQualityIndicator, QualityIndicator } from '../../quality_indicator'; import { PrivilegesWarningIconWrapper, IntegrationIcon } from '../../common'; import { useRedirectLink } from '../../../hooks'; @@ -352,10 +353,13 @@ const RedirectLink = ({ dataStreamStat: DataStreamStat; title: string; }) => { - const redirectLinkProps = useRedirectLink({ dataStreamStat }); + const redirectLinkProps = useRedirectLink({ + dataStreamStat, + telemetry: { page: 'main', navigationSource: NavigationSource.Table }, + }); return ( - + {title} ); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx index 66f61d6996bd3..9e8fb79168fd4 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/dataset_quality/table/degraded_docs_percentage_link.tsx @@ -8,6 +8,7 @@ import { EuiSkeletonRectangle, EuiFlexGroup, EuiLink } from '@elastic/eui'; import React from 'react'; import { _IGNORED } from '../../../../common/es_fields'; +import { NavigationSource } from '../../../services/telemetry'; import { useRedirectLink } from '../../../hooks'; import { QualityPercentageIndicator } from '../../quality_indicator'; import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat'; @@ -26,13 +27,20 @@ export const DegradedDocsPercentageLink = ({ const redirectLinkProps = useRedirectLink({ dataStreamStat, query: { language: 'kuery', query: `${_IGNORED}: *` }, + telemetry: { + page: 'main', + navigationSource: NavigationSource.Table, + }, }); return ( {percentage ? ( - + ) : ( diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx index 7543322a5dafc..1fb5134b0a92c 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect } from 'react'; import { css } from '@emotion/react'; import { EuiButtonEmpty, @@ -20,7 +20,7 @@ import { EuiSkeletonRectangle, } from '@elastic/eui'; import { flyoutCancelText } from '../../../common/translations'; -import { useDatasetQualityFlyout } from '../../hooks'; +import { useDatasetQualityFlyout, useDatasetDetailsTelemetry } from '../../hooks'; import { DatasetSummary, DatasetSummaryLoading } from './dataset_summary'; import { Header } from './header'; import { IntegrationSummary } from './integration_summary'; @@ -41,6 +41,12 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) { flyoutLoading, } = useDatasetQualityFlyout(); + const { startTracking } = useDatasetDetailsTelemetry(); + + useEffect(() => { + startTracking(); + }, [startTracking]); + return ( [number] & { isLoading: boolean }) { const { euiTheme } = useEuiTheme(); return ( @@ -71,8 +63,7 @@ export function FlyoutSummaryKpiItem({ alignItems: 'center', width: 'fit-content', }} - href={link.href} - target="_blank" + {...link.props} > - {getSummaryKpis({}).map(({ title }) => ( + {getSummaryKpis({ telemetry }).map(({ title }) => ( diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts index 42ac8bc5e7b28..5807c21a6a251 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.test.ts @@ -7,6 +7,7 @@ import { formatNumber } from '@elastic/eui'; import type { useKibanaContextForPlugin } from '../../../utils'; +import type { useDatasetDetailsTelemetry } from '../../../hooks'; import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller'; import { @@ -46,7 +47,11 @@ const timeRange: TimeRangeConfig = { to: 'now', }; -const degradedDocsHref = 'http://exploratory-view/degraded-docs'; +const degradedDocsLinkProps = { + linkProps: { href: 'http://exploratory-view/degraded-docs', onClick: () => {} }, + navigate: () => {}, + isLogsExplorerAvailable: true, +}; const hostsRedirectUrl = 'http://hosts/metric/'; const hostsLocator = { @@ -55,13 +60,18 @@ const hostsLocator = { typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator']; +const telemetry = { + trackDetailsNavigated: () => {}, +} as unknown as ReturnType; + describe('getSummaryKpis', () => { it('should return the correct KPIs', () => { const result = getSummaryKpis({ dataStreamDetails, timeRange, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }); expect(result).toEqual([ @@ -92,7 +102,7 @@ describe('getSummaryKpis', () => { value: '200', link: { label: flyoutShowAllText, - href: degradedDocsHref, + props: degradedDocsLinkProps.linkProps, }, userHasPrivilege: true, }, @@ -119,8 +129,9 @@ describe('getSummaryKpis', () => { const result = getSummaryKpis({ dataStreamDetails: detailsWithMaxPlusHosts, timeRange, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }); expect(result).toEqual([ @@ -151,7 +162,7 @@ describe('getSummaryKpis', () => { value: '200', link: { label: flyoutShowAllText, - href: degradedDocsHref, + props: degradedDocsLinkProps.linkProps, }, userHasPrivilege: true, }, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts index 6dcf61838b834..563c7d06cea48 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/flyout_summary/get_summary_kpis.ts @@ -6,6 +6,7 @@ */ import { formatNumber } from '@elastic/eui'; +import { getRouterLinkProps, RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; import { BYTE_NUMBER_FORMAT, DEFAULT_DATEPICKER_REFRESH, @@ -22,25 +23,29 @@ import { flyoutSizeText, } from '../../../../common/translations'; import { DataStreamDetails } from '../../../../common/api_types'; +import { NavigationTarget, NavigationSource } from '../../../services/telemetry'; import { useKibanaContextForPlugin } from '../../../utils'; +import type { useRedirectLink, useDatasetDetailsTelemetry } from '../../../hooks'; import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller'; export function getSummaryKpis({ dataStreamDetails, timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_DATEPICKER_REFRESH }, - degradedDocsHref, + degradedDocsLinkProps, hostsLocator, + telemetry, }: { dataStreamDetails?: DataStreamDetails; timeRange?: TimeRangeConfig; - degradedDocsHref?: string; + degradedDocsLinkProps?: ReturnType; hostsLocator?: ReturnType< typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator']; + telemetry: ReturnType; }): Array<{ title: string; value: string; - link?: { label: string; href: string }; + link?: { label: string; props: RouterLinkProps }; userHasPrivilege: boolean; }> { const services = dataStreamDetails?.services ?? {}; @@ -48,14 +53,17 @@ export function getSummaryKpis({ const countOfServices = serviceKeys .map((key: string) => services[key].length) .reduce((a, b) => a + b, 0); - const servicesLink = undefined; // TODO: Add link to APM services page when possible - const degradedDocsLink = degradedDocsHref - ? { - label: flyoutShowAllText, - href: degradedDocsHref, - } - : undefined; + // @ts-ignore // TODO: Add link to APM services page when possible - https://github.com/elastic/kibana/issues/179904 + const servicesLink = { + label: flyoutShowAllText, + props: getRouterLinkProps({ + href: undefined, + onClick: () => { + telemetry.trackDetailsNavigated(NavigationTarget.Services, NavigationSource.Summary); + }, + }), + }; return [ { @@ -76,14 +84,20 @@ export function getSummaryKpis({ { title: flyoutServicesText, value: formatMetricValueForMax(countOfServices, MAX_HOSTS_METRIC_VALUE, NUMBER_FORMAT), - link: servicesLink, + link: undefined, userHasPrivilege: true, }, - getHostsKpi(dataStreamDetails?.hosts, timeRange, hostsLocator), + getHostsKpi(dataStreamDetails?.hosts, timeRange, telemetry, hostsLocator), { title: flyoutDegradedDocsText, value: formatNumber(dataStreamDetails?.degradedDocsCount ?? 0, NUMBER_FORMAT), - link: degradedDocsLink, + link: + degradedDocsLinkProps && degradedDocsLinkProps.linkProps.href + ? { + label: flyoutShowAllText, + props: degradedDocsLinkProps.linkProps, + } + : undefined, userHasPrivilege: true, }, ]; @@ -92,6 +106,7 @@ export function getSummaryKpis({ function getHostsKpi( dataStreamHosts: DataStreamDetails['hosts'], timeRange: TimeRangeConfig, + telemetry: ReturnType, hostsLocator?: ReturnType< typeof useKibanaContextForPlugin >['services']['observabilityShared']['locators']['infra']['hostsLocator'] @@ -120,12 +135,15 @@ function getHostsKpi( }); // @ts-ignore // TODO: Add link to Infra Hosts page when possible - const hostsLink = hostsUrl - ? { - label: flyoutShowAllText, - href: hostsUrl, - } - : undefined; + const hostsLink = { + label: flyoutShowAllText, + props: getRouterLinkProps({ + href: hostsUrl, + onClick: () => { + telemetry.trackDetailsNavigated(NavigationTarget.Hosts, NavigationSource.Summary); + }, + }), + }; return { title: flyoutHostsText, diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx index 63fb761ce87f5..60d88992c979e 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/components/flyout/header.tsx @@ -20,6 +20,7 @@ import { flyoutOpenInDiscoverText, flyoutOpenInLogsExplorerText, } from '../../../common/translations'; +import { NavigationSource } from '../../services/telemetry'; import { useRedirectLink } from '../../hooks'; import { FlyoutDataset } from '../../state_machines/dataset_quality_controller'; import { IntegrationIcon } from '../common'; @@ -28,7 +29,13 @@ export function Header({ dataStreamStat }: { dataStreamStat: FlyoutDataset }) { const { integration, title } = dataStreamStat; const euiShadow = useEuiShadow('s'); const { euiTheme } = useEuiTheme(); - const redirectLinkProps = useRedirectLink({ dataStreamStat }); + const redirectLinkProps = useRedirectLink({ + dataStreamStat, + telemetry: { + page: 'details', + navigationSource: NavigationSource.Header, + }, + }); return ( @@ -61,7 +68,7 @@ export function Header({ dataStreamStat }: { dataStreamStat: FlyoutDataset }) { { services: { lens }, } = useKibanaContextForPlugin(); const { service } = useDatasetQualityContext(); + const { trackDetailsNavigated, navigationTargets, navigationSources } = + useDatasetDetailsTelemetry(); const { dataStreamStat, timeRange, breakdownField } = useDatasetQualityFlyout(); @@ -104,13 +107,14 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { const openInLensCallback = useCallback(() => { if (attributes) { + trackDetailsNavigated(navigationTargets.Lens, navigationSources.Chart); lens.navigateToPrefilledEditor({ id: '', timeRange, attributes, }); } - }, [lens, attributes, timeRange]); + }, [attributes, trackDetailsNavigated, navigationTargets, navigationSources, lens, timeRange]); const getOpenInLensAction = useMemo(() => { return { @@ -137,6 +141,10 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { query: { language: 'kuery', query: '_ignored:*' }, timeRangeConfig: timeRange, breakdownField: breakdownDataViewField?.name, + telemetry: { + page: 'details', + navigationSource: navigationSources.Chart, + }, }); const getOpenInLogsExplorerAction = useMemo(() => { @@ -149,10 +157,10 @@ export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => { : exploreDataInDiscoverText; }, getHref: async () => { - return redirectLinkProps.href; + return redirectLinkProps.linkProps.href; }, getIconType(): string | undefined { - return 'popout'; + return 'visTable'; }, async isCompatible(): Promise { return true; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx index 29faaec2788ea..518b8f84aedd8 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_flyout_integration_actions.tsx @@ -12,6 +12,7 @@ import { MANAGEMENT_APP_LOCATOR } from '@kbn/deeplinks-management/constants'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { DashboardType } from '../../common/data_streams_stats'; import { useKibanaContextForPlugin } from '../utils'; +import { useDatasetDetailsTelemetry } from './use_telemetry'; export const useFlyoutIntegrationActions = () => { const { @@ -21,6 +22,8 @@ export const useFlyoutIntegrationActions = () => { share, }, } = useKibanaContextForPlugin(); + const { wrapLinkPropsForTelemetry, navigationSources, navigationTargets } = + useDatasetDetailsTelemetry(); const [isOpen, toggleIsOpen] = useToggle(false); @@ -43,28 +46,51 @@ export const useFlyoutIntegrationActions = () => { const getIntegrationOverviewLinkProps = useCallback( (name: string, version: string) => { const href = basePath.prepend(`/app/integrations/detail/${name}-${version}/overview`); - return getRouterLinkProps({ - href, - onClick: () => navigateToUrl(href), - }); + return wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href, + onClick: () => { + return navigateToUrl(href); + }, + }), + navigationTargets.Integration, + navigationSources.ActionMenu + ); }, - [basePath, navigateToUrl] + [basePath, navigateToUrl, navigationSources, navigationTargets, wrapLinkPropsForTelemetry] ); const getIndexManagementLinkProps = useCallback( (params: { sectionId: string; appId: string }) => - getRouterLinkProps({ - href: indexManagementLocator?.getRedirectUrl(params), - onClick: () => indexManagementLocator?.navigate(params), - }), - [indexManagementLocator] + wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href: indexManagementLocator?.getRedirectUrl(params), + onClick: () => { + return indexManagementLocator?.navigate(params); + }, + }), + navigationTargets.IndexTemplate, + navigationSources.ActionMenu + ), + [ + indexManagementLocator, + navigationSources.ActionMenu, + navigationTargets.IndexTemplate, + wrapLinkPropsForTelemetry, + ] ); const getDashboardLinkProps = useCallback( (dashboard: DashboardType) => - getRouterLinkProps({ - href: dashboardLocator?.getRedirectUrl({ dashboardId: dashboard?.id } || ''), - onClick: () => dashboardLocator?.navigate({ dashboardId: dashboard?.id } || ''), - }), - [dashboardLocator] + wrapLinkPropsForTelemetry( + getRouterLinkProps({ + href: dashboardLocator?.getRedirectUrl({ dashboardId: dashboard?.id } || ''), + onClick: () => { + return dashboardLocator?.navigate({ dashboardId: dashboard?.id } || ''); + }, + }), + navigationTargets.Dashboard, + navigationSources.ActionMenu + ), + [dashboardLocator, navigationSources, navigationTargets, wrapLinkPropsForTelemetry] ); return { diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts index 751b7e14ebb79..5e2b39ba7a8b6 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_redirect_link.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { useMemo } from 'react'; import { SINGLE_DATASET_LOCATOR_ID, SingleDatasetLocatorParams, @@ -20,17 +21,20 @@ import { DataStreamStat } from '../../common/data_streams_stats/data_stream_stat import { useDatasetQualityContext } from '../components/dataset_quality/context'; import { FlyoutDataset, TimeRangeConfig } from '../state_machines/dataset_quality_controller'; import { useKibanaContextForPlugin } from '../utils'; +import { useRedirectLinkTelemetry } from './use_telemetry'; export const useRedirectLink = ({ dataStreamStat, query, timeRangeConfig, breakdownField, + telemetry, }: { dataStreamStat: DataStreamStat | FlyoutDataset; query?: Query | AggregateQuery; timeRangeConfig?: TimeRangeConfig; breakdownField?: string; + telemetry?: Parameters[0]['telemetry']; }) => { const { services: { share }, @@ -43,29 +47,66 @@ export const useRedirectLink = ({ const logsExplorerLocator = share.url.locators.get(SINGLE_DATASET_LOCATOR_ID); - const config = logsExplorerLocator - ? buildLogsExplorerConfig({ - locator: logsExplorerLocator, - dataStreamStat, - query, - from, - to, - breakdownField, - }) - : buildDiscoverConfig({ - locatorClient: share.url.locators, - dataStreamStat, - query, - from, - to, - breakdownField, - }); - - return { - ...config.routerLinkProps, - navigate: config.navigate, - isLogsExplorerAvailable: !!logsExplorerLocator, - }; + const { sendTelemetry } = useRedirectLinkTelemetry({ + rawName: dataStreamStat.rawName, + isLogsExplorer: !!logsExplorerLocator, + telemetry, + query, + }); + + return useMemo<{ + linkProps: RouterLinkProps; + navigate: () => void; + isLogsExplorerAvailable: boolean; + }>(() => { + const config = logsExplorerLocator + ? buildLogsExplorerConfig({ + locator: logsExplorerLocator, + dataStreamStat, + query, + from, + to, + breakdownField, + }) + : buildDiscoverConfig({ + locatorClient: share.url.locators, + dataStreamStat, + query, + from, + to, + breakdownField, + }); + + const onClickWithTelemetry = (event: Parameters[0]) => { + sendTelemetry(); + if (config.routerLinkProps.onClick) { + config.routerLinkProps.onClick(event); + } + }; + + const navigateWithTelemetry = () => { + sendTelemetry(); + config.navigate(); + }; + + return { + linkProps: { + ...config.routerLinkProps, + onClick: onClickWithTelemetry, + }, + navigate: navigateWithTelemetry, + isLogsExplorerAvailable: !!logsExplorerLocator, + }; + }, [ + breakdownField, + dataStreamStat, + from, + to, + logsExplorerLocator, + query, + sendTelemetry, + share.url.locators, + ]); }; const buildLogsExplorerConfig = ({ diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx new file mode 100644 index 0000000000000..e5fc1088e7466 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/hooks/use_telemetry.tsx @@ -0,0 +1,352 @@ +/* + * 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 { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useSelector } from '@xstate/react'; +import { getDateISORange } from '@kbn/timerange'; +import { AggregateQuery, Query } from '@kbn/es-query'; + +import { DataStreamStat } from '../../common/data_streams_stats'; +import { DataStreamDetails } from '../../common/api_types'; +import { mapPercentageToQuality } from '../../common/utils'; +import { + NavigationTarget, + NavigationSource, + DatasetDetailsEbtProps, + DatasetNavigatedEbtProps, + DatasetEbtProps, +} from '../services/telemetry'; +import { FlyoutDataset, TimeRangeConfig } from '../state_machines/dataset_quality_controller'; +import { useDatasetQualityContext } from '../components/dataset_quality/context'; +import { useDatasetQualityFilters } from './use_dataset_quality_filters'; + +export const useRedirectLinkTelemetry = ({ + rawName, + isLogsExplorer, + telemetry, + query, +}: { + rawName: string; + isLogsExplorer: boolean; + telemetry?: { + page: 'main' | 'details'; + navigationSource: NavigationSource; + }; + query?: Query | AggregateQuery; +}) => { + const { trackDatasetNavigated } = useDatasetTelemetry(); + const { trackDetailsNavigated, navigationTargets } = useDatasetDetailsTelemetry(); + + const sendTelemetry = useCallback(() => { + if (telemetry) { + const isIgnoredFilter = query ? JSON.stringify(query).includes('_ignored') : false; + if (telemetry.page === 'main') { + trackDatasetNavigated(rawName, isIgnoredFilter); + } else { + trackDetailsNavigated( + isLogsExplorer ? navigationTargets.LogsExplorer : navigationTargets.Discover, + telemetry.navigationSource, + isIgnoredFilter + ); + } + } + }, [ + isLogsExplorer, + trackDetailsNavigated, + navigationTargets, + query, + rawName, + telemetry, + trackDatasetNavigated, + ]); + + const wrapLinkPropsForTelemetry = useCallback( + (props: RouterLinkProps) => { + return { + ...props, + onClick: (event: Parameters[0]) => { + sendTelemetry(); + if (props.onClick) { + props.onClick(event); + } + }, + }; + }, + [sendTelemetry] + ); + + return { + wrapLinkPropsForTelemetry, + sendTelemetry, + }; +}; + +export const useDatasetTelemetry = () => { + const { service, telemetryClient } = useDatasetQualityContext(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const datasets = useSelector(service, (state) => state.context.datasets) ?? {}; + const nonAggregatableDatasets = useSelector( + service, + (state) => state.context.nonAggregatableDatasets + ); + const canUserViewIntegrations = useSelector( + service, + (state) => state.context.datasetUserPrivileges.canViewIntegrations + ); + const sort = useSelector(service, (state) => state.context.table.sort); + const appliedFilters = useDatasetQualityFilters(); + + const trackDatasetNavigated = useCallback<(rawName: string, isIgnoredFilter: boolean) => void>( + (rawName: string, isIgnoredFilter: boolean) => { + const foundDataset = datasets.find((dataset) => dataset.rawName === rawName); + if (foundDataset) { + const ebtProps = getDatasetEbtProps( + foundDataset, + sort, + appliedFilters, + nonAggregatableDatasets, + isIgnoredFilter, + canUserViewIntegrations + ); + telemetryClient.trackDatasetNavigated(ebtProps); + } else { + throw new Error( + `Cannot report dataset navigation telemetry for unknown dataset ${rawName}` + ); + } + }, + [ + sort, + appliedFilters, + canUserViewIntegrations, + datasets, + nonAggregatableDatasets, + telemetryClient, + ] + ); + + return { trackDatasetNavigated }; +}; + +export const useDatasetDetailsTelemetry = () => { + const { service, telemetryClient } = useDatasetQualityContext(); + + const { + dataset: dataStreamStat, + datasetDetails: dataStreamDetails, + insightsTimeRange, + breakdownField, + isNonAggregatable, + } = useSelector(service, (state) => state.context.flyout) ?? {}; + + const loadingState = useSelector(service, (state) => ({ + dataStreamDetailsLoading: state.matches('flyout.initializing.dataStreamDetails.fetching'), + })); + + const canUserAccessDashboards = useSelector( + service, + (state) => !state.matches('flyout.initializing.integrationDashboards.unauthorized') + ); + + const canUserViewIntegrations = useSelector( + service, + (state) => state.context.datasetUserPrivileges.canViewIntegrations + ); + + const ebtProps = useMemo(() => { + if ( + dataStreamDetails && + insightsTimeRange && + dataStreamStat && + !loadingState.dataStreamDetailsLoading + ) { + return getDatasetDetailsEbtProps( + insightsTimeRange, + dataStreamStat, + dataStreamDetails, + isNonAggregatable ?? false, + canUserViewIntegrations, + canUserAccessDashboards, + breakdownField + ); + } + + return undefined; + }, [ + insightsTimeRange, + dataStreamStat, + dataStreamDetails, + loadingState.dataStreamDetailsLoading, + isNonAggregatable, + canUserViewIntegrations, + canUserAccessDashboards, + breakdownField, + ]); + + const startTracking = useCallback(() => { + telemetryClient.startDatasetDetailsTracking(); + }, [telemetryClient]); + + // Report opening dataset details + useEffect(() => { + const datasetDetailsTrackingState = telemetryClient.getDatasetDetailsTrackingState(); + if (datasetDetailsTrackingState === 'started' && ebtProps) { + telemetryClient.trackDatasetDetailsOpened(ebtProps); + } + }, [ebtProps, telemetryClient]); + + const trackDetailsNavigated = useCallback( + (target: NavigationTarget, source: NavigationSource, isDegraded = false) => { + const datasetDetailsTrackingState = telemetryClient.getDatasetDetailsTrackingState(); + if ( + (datasetDetailsTrackingState === 'opened' || datasetDetailsTrackingState === 'navigated') && + ebtProps + ) { + telemetryClient.trackDatasetDetailsNavigated({ + ...ebtProps, + filters: { + is_degraded: isDegraded, + }, + target, + source, + }); + } else { + throw new Error( + 'Cannot report dataset details navigation telemetry without required data and state' + ); + } + }, + [ebtProps, telemetryClient] + ); + + const wrapLinkPropsForTelemetry = useCallback( + ( + props: RouterLinkProps, + target: NavigationTarget, + source: NavigationSource, + isDegraded = false + ) => { + return { + ...props, + onClick: (event: Parameters[0]) => { + trackDetailsNavigated(target, source, isDegraded); + if (props.onClick) { + props.onClick(event); + } + }, + }; + }, + [trackDetailsNavigated] + ); + + return { + startTracking, + trackDetailsNavigated, + wrapLinkPropsForTelemetry, + navigationTargets: NavigationTarget, + navigationSources: NavigationSource, + }; +}; + +function getDatasetEbtProps( + dataset: DataStreamStat, + sort: { field: string; direction: 'asc' | 'desc' }, + filters: ReturnType, + nonAggregatableDatasets: string[], + isIgnoredFilter: boolean, + canUserViewIntegrations: boolean +): DatasetNavigatedEbtProps { + const { startDate: from, endDate: to } = getDateISORange(filters.timeRange); + const datasetEbtProps: DatasetEbtProps = { + index_name: dataset.rawName, + data_stream: { + dataset: dataset.name, + namespace: dataset.namespace, + type: dataset.type, + }, + data_stream_health: dataset.degradedDocs.quality, + data_stream_aggregatable: nonAggregatableDatasets.some( + (indexName) => indexName === dataset.rawName + ), + from, + to, + degraded_percentage: dataset.degradedDocs.percentage, + integration: dataset.integration?.name, + privileges: { + can_monitor_data_stream: dataset.userPrivileges?.canMonitor ?? true, + can_view_integrations: canUserViewIntegrations, + }, + }; + + const ebtFilters: DatasetNavigatedEbtProps['filters'] = { + is_degraded: isIgnoredFilter, + query_length: filters.selectedQuery?.length ?? 0, + integrations: { + total: filters.integrations.filter((item) => item.name !== 'none').length, + included: filters.integrations.filter((item) => item?.checked === 'on').length, + excluded: filters.integrations.filter((item) => item?.checked === 'off').length, + }, + namespaces: { + total: filters.namespaces.length, + included: filters.namespaces.filter((item) => item?.checked === 'on').length, + excluded: filters.namespaces.filter((item) => item?.checked === 'off').length, + }, + qualities: { + total: filters.qualities.length, + included: filters.qualities.filter((item) => item?.checked === 'on').length, + excluded: filters.qualities.filter((item) => item?.checked === 'off').length, + }, + }; + + return { + ...datasetEbtProps, + sort, + filters: ebtFilters, + }; +} + +function getDatasetDetailsEbtProps( + insightsTimeRange: TimeRangeConfig, + flyoutDataset: FlyoutDataset, + details: DataStreamDetails, + isNonAggregatable: boolean, + canUserViewIntegrations: boolean, + canUserAccessDashboards: boolean, + breakdownField?: string +): DatasetDetailsEbtProps { + const indexName = flyoutDataset.rawName; + const dataStream = { + dataset: flyoutDataset.name, + namespace: flyoutDataset.namespace, + type: flyoutDataset.type, + }; + const degradedDocs = details?.degradedDocsCount ?? 0; + const totalDocs = details?.docsCount ?? 0; + const degradedPercentage = + totalDocs > 0 ? Number(((degradedDocs / totalDocs) * 100).toFixed(2)) : 0; + const health = mapPercentageToQuality(degradedPercentage); + const { startDate: from, endDate: to } = getDateISORange(insightsTimeRange); + + return { + index_name: indexName, + data_stream: dataStream, + privileges: { + can_monitor_data_stream: true, + can_view_integrations: canUserViewIntegrations, + can_view_dashboards: canUserAccessDashboards, + }, + data_stream_aggregatable: !isNonAggregatable, + data_stream_health: health, + from, + to, + degraded_percentage: degradedPercentage, + integration: flyoutDataset.integration?.name, + breakdown_field: breakdownField, + }; +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx index 6ea2655450607..3e90347875ba8 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/dataset_quality/public/plugin.tsx @@ -6,6 +6,7 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import { TelemetryService } from './services/telemetry'; import { createDatasetQuality } from './components/dataset_quality'; import { createDatasetQualityControllerLazyFactory } from './controller/lazy_create_controller'; import { DataStreamsStatsService } from './services/data_streams_stats'; @@ -20,13 +21,18 @@ import { export class DatasetQualityPlugin implements Plugin { + private telemetry = new TelemetryService(); constructor(context: PluginInitializerContext) {} public setup(core: CoreSetup, plugins: DatasetQualitySetupDeps) { + this.telemetry.setup({ analytics: core.analytics }); + return {}; } public start(core: CoreStart, plugins: DatasetQualityStartDeps): DatasetQualityPluginStart { + const telemetryClient = this.telemetry.start(); + const dataStreamStatsClient = new DataStreamsStatsService().start({ http: core.http, }).client; @@ -38,7 +44,7 @@ export class DatasetQualityPlugin const DatasetQuality = createDatasetQuality({ core, plugins, - dataStreamStatsClient, + telemetryClient, }); const createDatasetQualityController = createDatasetQualityControllerLazyFactory({ diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts new file mode 100644 index 0000000000000..c7cc9eb577e38 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/index.ts @@ -0,0 +1,10 @@ +/* + * 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 './telemetry_client'; +export * from './telemetry_service'; +export * from './types'; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts new file mode 100644 index 0000000000000..c0e93f13cd1b3 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_client.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { + ITelemetryClient, + DatasetDetailsEbtProps, + DatasetQualityTelemetryEventTypes, + DatasetDetailsNavigatedEbtProps, + DatasetDetailsTrackingState, + DatasetNavigatedEbtProps, +} from './types'; + +export class TelemetryClient implements ITelemetryClient { + private datasetDetailsTrackingId = ''; + private startTime = 0; + private datasetDetailsState: DatasetDetailsTrackingState = 'initial'; + + constructor(private analytics: AnalyticsServiceSetup) {} + + public trackDatasetNavigated = (eventProps: DatasetNavigatedEbtProps) => { + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.NAVIGATED, eventProps); + }; + + public startDatasetDetailsTracking() { + this.datasetDetailsTrackingId = uuidv4(); + this.startTime = Date.now(); + this.datasetDetailsState = 'started'; + } + + public getDatasetDetailsTrackingState() { + return this.datasetDetailsState; + } + + public trackDatasetDetailsOpened = (eventProps: DatasetDetailsEbtProps) => { + const datasetDetailsLoadDuration = Date.now() - this.startTime; + + this.datasetDetailsState = 'opened'; + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.DETAILS_OPENED, { + ...eventProps, + tracking_id: this.datasetDetailsTrackingId, + duration: datasetDetailsLoadDuration, + }); + }; + + public trackDatasetDetailsNavigated = (eventProps: DatasetDetailsNavigatedEbtProps) => { + this.datasetDetailsState = 'navigated'; + this.analytics.reportEvent(DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED, { + ...eventProps, + tracking_id: this.datasetDetailsTrackingId, + }); + }; +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts new file mode 100644 index 0000000000000..a8244a6830ae6 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_events.ts @@ -0,0 +1,261 @@ +/* + * 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 { omit } from 'lodash'; +import { SchemaObject, SchemaValue } from '@kbn/ebt'; +import { + DatasetEbtFilter, + DatasetEbtProps, + DatasetNavigatedEbtProps, + DatasetQualityTelemetryEvent, + DatasetQualityTelemetryEventTypes, +} from './types'; + +const dataStreamSchema: SchemaObject = { + properties: { + dataset: { + type: 'keyword', + _meta: { + description: 'Data stream dataset name', + }, + }, + namespace: { + type: 'keyword', + _meta: { + description: 'Data stream namespace', + }, + }, + type: { + type: 'keyword', + _meta: { + description: 'Data stream type e.g. "logs", "metrics"', + }, + }, + }, +}; + +const privilegesSchema: SchemaObject = { + properties: { + can_monitor_data_stream: { + type: 'boolean', + _meta: { + description: 'Whether user can monitor the data stream', + }, + }, + can_view_integrations: { + type: 'boolean', + _meta: { + description: 'Whether user can view integrations', + }, + }, + can_view_dashboards: { + type: 'boolean', + _meta: { + description: 'Whether user can view dashboards', + optional: true, + }, + }, + }, +}; + +const ebtFilterObjectSchema: SchemaObject = { + properties: { + total: { + type: 'short', + _meta: { + description: 'Total number of values available to filter', + optional: false, + }, + }, + included: { + type: 'short', + _meta: { + description: 'Number of values selected to filter for', + optional: false, + }, + }, + excluded: { + type: 'short', + _meta: { + description: 'Number of values selected to filter out', + optional: false, + }, + }, + }, + _meta: { + description: 'Represents the multi select filters', + optional: false, + }, +}; + +const sortSchema: SchemaObject = { + properties: { + field: { + type: 'keyword', + _meta: { + description: 'Field used for sorting on the main table', + optional: false, + }, + }, + direction: { + type: 'keyword', + _meta: { + description: 'Sort direction', + optional: false, + }, + }, + }, + _meta: { + description: 'Represents the state of applied sorting on the dataset quality home page', + optional: false, + }, +}; + +const filtersSchema: SchemaObject = { + properties: { + is_degraded: { + type: 'boolean', + _meta: { + description: 'Whether _ignored filter is applied', + optional: false, + }, + }, + query_length: { + type: 'short', + _meta: { + description: 'Length of the query string', + optional: false, + }, + }, + integrations: ebtFilterObjectSchema, + namespaces: ebtFilterObjectSchema, + qualities: ebtFilterObjectSchema, + }, + _meta: { + description: 'Represents the state of applied filters on the dataset quality home page', + optional: false, + }, +}; + +const datasetCommonSchema = { + index_name: { + type: 'keyword', + _meta: { + description: 'Index name', + }, + } as SchemaValue, + data_stream: dataStreamSchema, + privileges: privilegesSchema, + data_stream_health: { + type: 'keyword', + _meta: { + description: 'Quality of the data stream e.g. "good", "degraded", "poor"', + }, + } as SchemaValue, + data_stream_aggregatable: { + type: 'boolean', + _meta: { + description: 'Whether data stream is aggregatable against _ignored field', + }, + } as SchemaValue, + degraded_percentage: { + type: 'float', + _meta: { + description: 'Percentage of degraded documents in the data stream', + }, + } as SchemaValue, + from: { + type: 'date', + _meta: { + description: 'Start of the time range ISO8601 formatted string', + }, + } as SchemaValue, + to: { + type: 'date', + _meta: { + description: 'End of the time range ISO8601 formatted string', + }, + } as SchemaValue, + integration: { + type: 'keyword', + _meta: { + description: 'Integration name, if any', + optional: true, + }, + } as SchemaValue, +}; + +const datasetNavigatedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.NAVIGATED, + schema: { + ...datasetCommonSchema, + sort: sortSchema, + filters: filtersSchema, + }, +}; + +const datasetDetailsOpenedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_OPENED, + schema: { + ...datasetCommonSchema, + tracking_id: { + type: 'keyword', + _meta: { + description: `Locally generated session tracking ID for funnel analysis`, + }, + }, + duration: { + type: 'long', + _meta: { + description: 'Duration in milliseconds to load the dataset details page', + }, + }, + breakdown_field: { + type: 'keyword', + _meta: { + description: 'Field used for chart breakdown, if any', + optional: true, + }, + }, + }, +}; + +const datasetDetailsNavigatedEventType: DatasetQualityTelemetryEvent = { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED, + schema: { + ...omit(datasetDetailsOpenedEventType.schema, 'duration'), + filters: { + properties: { + is_degraded: { + type: 'boolean', + _meta: { + description: 'Whether _ignored filter is applied to the link', + optional: false, + }, + }, + }, + }, + target: { + type: 'keyword', + _meta: { + description: 'Action that user took to navigate away from the dataset details page', + }, + }, + source: { + type: 'keyword', + _meta: { + description: + 'Section of dataset details page the action is originated from e.g. header, summary, chart or table etc.', + }, + }, + }, +}; + +export const datasetQualityEbtEvents = { + datasetNavigatedEventType, + datasetDetailsOpenedEventType, + datasetDetailsNavigatedEventType, +}; diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts new file mode 100644 index 0000000000000..dfd1bd4fb2b51 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.test.ts @@ -0,0 +1,177 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { datasetQualityEbtEvents } from './telemetry_events'; +import { TelemetryService } from './telemetry_service'; +import { + NavigationTarget, + NavigationSource, + DatasetDetailsNavigatedEbtProps, + DatasetDetailsEbtProps, + WithTrackingId, + WithDuration, + DatasetEbtProps, + DatasetNavigatedEbtProps, +} from './types'; + +// Mock uuidv4 +jest.mock('uuid', () => { + return { + v4: jest.fn(() => `mock-uuid-${Math.random()}`), + }; +}); + +describe('TelemetryService', () => { + const service = new TelemetryService(); + + const mockCoreStart = coreMock.createSetup(); + service.setup({ analytics: mockCoreStart.analytics }); + + const defaultEbtProps: DatasetEbtProps = { + index_name: 'logs-example-dataset-default', + data_stream: { + dataset: 'example-dataset', + namespace: 'default', + type: 'logs', + }, + privileges: { + can_monitor_data_stream: true, + can_view_integrations: true, + can_view_dashboards: true, + }, + data_stream_health: 'poor', + data_stream_aggregatable: true, + degraded_percentage: 0.5, + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-02T00:00:00.000Z', + }; + + const defaultSort: DatasetNavigatedEbtProps['sort'] = { field: 'name', direction: 'asc' }; + + const defaultFilters: DatasetNavigatedEbtProps['filters'] = { + is_degraded: false, + query_length: 0, + integrations: { + total: 0, + included: 0, + excluded: 0, + }, + namespaces: { + total: 0, + included: 0, + excluded: 0, + }, + qualities: { + total: 0, + included: 0, + excluded: 0, + }, + }; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should register all events', () => { + expect(mockCoreStart.analytics.registerEventType).toHaveBeenCalledTimes( + Object.keys(datasetQualityEbtEvents).length + ); + }); + + it('should report dataset navigated event', async () => { + const telemetry = service.start(); + const exampleEventData: DatasetNavigatedEbtProps = { + ...defaultEbtProps, + sort: defaultSort, + filters: defaultFilters, + }; + + telemetry.trackDatasetNavigated(exampleEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetNavigatedEventType.eventType, + expect.objectContaining(exampleEventData) + ); + }); + + it('should report opening dataset details with a tracking_id', async () => { + const telemetry = service.start(); + const exampleEventData: DatasetDetailsEbtProps = { + ...defaultEbtProps, + }; + + telemetry.startDatasetDetailsTracking(); + + // Increment jest's internal timer to simulate user interaction delay + jest.advanceTimersByTime(500); + + telemetry.trackDatasetDetailsOpened(exampleEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetDetailsOpenedEventType.eventType, + expect.objectContaining({ + ...exampleEventData, + tracking_id: expect.stringMatching(/\S/), + duration: expect.any(Number), + }) + ); + + // Expect the duration to be greater than the time mark + const args = mockCoreStart.analytics.reportEvent.mock.calls[0][1] as WithTrackingId & + WithDuration; + expect(args.duration).toBeGreaterThanOrEqual(500); + }); + + it('should report closing dataset details with the same tracking_id', async () => { + const telemetry = service.start(); + const exampleOpenEventData: DatasetDetailsEbtProps = { + ...defaultEbtProps, + }; + + const exampleNavigatedEventData: DatasetDetailsNavigatedEbtProps = { + ...exampleOpenEventData, + breakdown_field: 'example_field', + filters: { + is_degraded: false, + }, + target: NavigationTarget.Exit, + source: NavigationSource.Chart, + }; + + telemetry.startDatasetDetailsTracking(); + telemetry.trackDatasetDetailsOpened(exampleOpenEventData); + telemetry.trackDatasetDetailsNavigated(exampleNavigatedEventData); + + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledTimes(2); + expect(mockCoreStart.analytics.reportEvent).toHaveBeenCalledWith( + datasetQualityEbtEvents.datasetDetailsNavigatedEventType.eventType, + expect.objectContaining({ + ...exampleNavigatedEventData, + tracking_id: expect.stringMatching(/\S/), + }) + ); + + // Make sure the tracking_id is the same for both events + const [firstCall, secondCall] = mockCoreStart.analytics.reportEvent.mock.calls; + expect((firstCall[1] as WithTrackingId).tracking_id).toEqual( + (secondCall[1] as WithTrackingId).tracking_id + ); + expect((secondCall[1] as DatasetDetailsNavigatedEbtProps).breakdown_field).toEqual( + 'example_field' + ); + }); +}); diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts new file mode 100644 index 0000000000000..f8f8c5322f556 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/telemetry_service.ts @@ -0,0 +1,36 @@ +/* + * 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 { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { TelemetryServiceSetupParams, ITelemetryClient } from './types'; +import { datasetQualityEbtEvents } from './telemetry_events'; +import { TelemetryClient } from './telemetry_client'; + +/** + * Service that interacts with the Core's analytics module + */ +export class TelemetryService { + constructor(private analytics?: AnalyticsServiceSetup) {} + + public setup({ analytics }: TelemetryServiceSetupParams) { + this.analytics = analytics; + + analytics.registerEventType(datasetQualityEbtEvents.datasetNavigatedEventType); + analytics.registerEventType(datasetQualityEbtEvents.datasetDetailsOpenedEventType); + analytics.registerEventType(datasetQualityEbtEvents.datasetDetailsNavigatedEventType); + } + + public start(): ITelemetryClient { + if (!this.analytics) { + throw new Error( + 'The TelemetryService.setup() method has not been invoked, be sure to call it during the plugin setup.' + ); + } + + return new TelemetryClient(this.analytics); + } +} diff --git a/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts new file mode 100644 index 0000000000000..2784f02187db1 --- /dev/null +++ b/x-pack/plugins/observability_solution/dataset_quality/public/services/telemetry/types.ts @@ -0,0 +1,124 @@ +/* + * 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 { AnalyticsServiceSetup, RootSchema } from '@kbn/core/public'; +import { QualityIndicators } from '../../../common/types'; + +export interface TelemetryServiceSetupParams { + analytics: AnalyticsServiceSetup; +} + +export type DatasetDetailsTrackingState = 'initial' | 'started' | 'opened' | 'navigated'; + +export enum NavigationTarget { + Exit = 'exit', + LogsExplorer = 'logs_explorer', + Discover = 'discover', + Lens = 'lens', + Integration = 'integration', + IndexTemplate = 'index_template', + Dashboard = 'dashboard', + Hosts = 'hosts', + Services = 'services', +} + +/** + * Source UI component that triggered the navigation + */ +export enum NavigationSource { + Header = 'header', + Footer = 'footer', + Summary = 'summary', + Chart = 'chart', + Table = 'table', + ActionMenu = 'action_menu', +} + +export interface WithTrackingId { + tracking_id: string; // For funnel analysis and session tracking +} + +export interface WithDuration { + duration: number; // The time (in milliseconds) it took to reach the meaningful state +} + +export interface DatasetEbtProps { + index_name: string; + data_stream: { + dataset: string; + namespace: string; + type: string; + }; + data_stream_health: QualityIndicators; + data_stream_aggregatable: boolean; + from: string; + to: string; + degraded_percentage: number; + integration?: string; + privileges: { + can_monitor_data_stream: boolean; + can_view_integrations: boolean; + can_view_dashboards?: boolean; + }; +} + +export interface DatasetEbtFilter { + total: number; + included: number; + excluded: number; +} + +export interface DatasetNavigatedEbtProps extends DatasetEbtProps { + sort: { field: string; direction: 'asc' | 'desc' }; + filters: { + is_degraded: boolean; + query_length: number; + integrations: DatasetEbtFilter; + namespaces: DatasetEbtFilter; + qualities: DatasetEbtFilter; + }; +} + +export interface DatasetDetailsEbtProps extends DatasetEbtProps { + breakdown_field?: string; +} + +export interface DatasetDetailsNavigatedEbtProps extends DatasetDetailsEbtProps { + filters: { + is_degraded: boolean; + }; + target: NavigationTarget; + source: NavigationSource; +} + +export interface ITelemetryClient { + trackDatasetNavigated: (eventProps: DatasetNavigatedEbtProps) => void; + startDatasetDetailsTracking: () => void; + getDatasetDetailsTrackingState: () => DatasetDetailsTrackingState; + trackDatasetDetailsOpened: (eventProps: DatasetDetailsEbtProps) => void; + trackDatasetDetailsNavigated: (eventProps: DatasetDetailsNavigatedEbtProps) => void; +} + +export enum DatasetQualityTelemetryEventTypes { + NAVIGATED = 'Dataset Quality Navigated', + DETAILS_OPENED = 'Dataset Quality Dataset Details Opened', + DETAILS_NAVIGATED = 'Dataset Quality Dataset Details Navigated', +} + +export type DatasetQualityTelemetryEvent = + | { + eventType: DatasetQualityTelemetryEventTypes.NAVIGATED; + schema: RootSchema; + } + | { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_OPENED; + schema: RootSchema; + } + | { + eventType: DatasetQualityTelemetryEventTypes.DETAILS_NAVIGATED; + schema: RootSchema; + }; diff --git a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json index 8fcb39724d19e..13e698502a7a2 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json +++ b/x-pack/plugins/observability_solution/dataset_quality/tsconfig.json @@ -50,6 +50,8 @@ "@kbn/calculate-auto", "@kbn/discover-plugin", "@kbn/shared-ux-prompt-no-data-views-types", + "@kbn/core-analytics-server", + "@kbn/ebt", "@kbn/ebt-tools" ], "exclude": [ diff --git a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts index 59e11b225772a..6aa16cf806fe5 100644 --- a/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts +++ b/x-pack/test/functional/apps/dataset_quality/dataset_quality_flyout.ts @@ -271,6 +271,11 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid }); describe('navigation', () => { + afterEach(async () => { + // Navigate back to dataset quality page after each test + await PageObjects.datasetQuality.navigateTo(); + }); + it('should go to log explorer page when the open in log explorer button is clicked', async () => { const testDatasetName = datasetNames[2]; await PageObjects.datasetQuality.openDatasetFlyout(testDatasetName); @@ -283,9 +288,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.eql(testDatasetName); - - // Should bring back the test to the dataset quality page - await PageObjects.datasetQuality.navigateTo(); }); it('should go log explorer for degraded docs when the show all button is clicked', async () => { @@ -293,17 +295,11 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`; await testSubjects.click(degradedDocsShowAllSelector); - await browser.switchTab(1); // Confirm dataset selector text in observability logs explorer const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.contain(apacheAccessDatasetName); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); // Blocked by https://github.com/elastic/kibana/issues/181705 @@ -313,7 +309,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`; await testSubjects.click(hostsShowAllSelector); - await browser.switchTab(1); // Confirm url contains metrics/hosts await retry.tryForTime(5000, async () => { @@ -321,11 +316,6 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid const parsedUrl = new URL(currentUrl); expect(parsedUrl.pathname).to.contain('/app/metrics/hosts'); }); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts index 24f6011c78855..9d20aefcebd28 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/dataset_quality/dataset_quality_flyout.ts @@ -276,6 +276,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('navigation', () => { + afterEach(async () => { + // Navigate back to dataset quality page after each test + await PageObjects.datasetQuality.navigateTo(); + }); + it('should go to log explorer page when the open in log explorer button is clicked', async () => { const testDatasetName = datasetNames[2]; await PageObjects.datasetQuality.openDatasetFlyout(testDatasetName); @@ -288,9 +293,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.eql(testDatasetName); - - // Should bring back the test to the dataset quality page - await PageObjects.datasetQuality.navigateTo(); }); it('should go log explorer for degraded docs when the show all button is clicked', async () => { @@ -298,17 +300,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`; await testSubjects.click(degradedDocsShowAllSelector); - await browser.switchTab(1); // Confirm dataset selector text in observability logs explorer const datasetSelectorText = await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText(); expect(datasetSelectorText).to.contain(apacheAccessDatasetName); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); // Blocked by https://github.com/elastic/kibana/issues/181705 @@ -318,7 +314,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`; await testSubjects.click(hostsShowAllSelector); - await browser.switchTab(1); // Confirm url contains metrics/hosts await retry.tryForTime(5000, async () => { @@ -326,11 +321,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const parsedUrl = new URL(currentUrl); expect(parsedUrl.pathname).to.contain('/app/metrics/hosts'); }); - - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await PageObjects.datasetQuality.closeFlyout(); }); }); From dcc3846e7850c8d38b763f91668d04bc2bbb0448 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Tue, 2 Jul 2024 11:32:29 +0200 Subject: [PATCH 07/13] [EDR Workflows] Fix saved_queries flaky tests (#187302) --- x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts | 4 +--- x-pack/plugins/osquery/cypress/tasks/live_query.ts | 1 + x-pack/plugins/osquery/cypress/tasks/saved_queries.ts | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts index 27fe443eed062..8f04a30c57048 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/saved_queries.cy.ts @@ -58,7 +58,6 @@ describe('ALL - Saved queries', { tags: ['@ess', '@serverless'] }, () => { it.skip('checks that user cant add a saved query with an ID that already exists', () => { cy.contains('Saved queries').click(); cy.contains('Add saved query').click(); - cy.get('input[name="id"]').type(`users_elastic{downArrow}{enter}`); cy.contains('ID must be unique').should('not.exist'); @@ -76,8 +75,7 @@ describe('ALL - Saved queries', { tags: ['@ess', '@serverless'] }, () => { }); }); - // FAILING ES SERVERLESS PROMOTION: https://github.com/elastic/kibana/issues/169787 - describe.skip('prebuilt', () => { + describe('prebuilt', () => { let packName: string; let packId: string; let savedQueryId: string; diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index cf6bf6020f903..4c05e2bbc98fe 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -14,6 +14,7 @@ export const DEFAULT_QUERY = 'select * from processes;'; export const BIG_QUERY = 'select * from processes, users limit 110;'; export const selectAllAgents = () => { + cy.getBySel('globalLoadingIndicator').should('not.exist'); cy.getBySel('agentSelection').find('input').should('not.be.disabled'); cy.getBySel('agentSelection').within(() => { cy.getBySel('comboBoxInput').click(); diff --git a/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts index bc006fae23ddb..ee752ace3c1de 100644 --- a/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts +++ b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts @@ -21,8 +21,7 @@ import { import { navigateTo } from './navigation'; export const getSavedQueriesComplexTest = () => - // FLAKY: https://github.com/elastic/kibana/issues/169786 - describe.skip('Saved queries Complex Test', () => { + describe('Saved queries Complex Test', () => { const timeout = '601'; const suffix = generateRandomStringName(1)[0]; const savedQueryId = `Saved-Query-Id-${suffix}`; @@ -65,7 +64,9 @@ export const getSavedQueriesComplexTest = () => cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); // change pagination - cy.getBySel('pagination-button-next').click().wait(500).click(); + cy.getBySel('pagination-button-next').click(); + cy.getBySel('globalLoadingIndicator').should('not.exist'); + cy.getBySel('pagination-button-next').click(); cy.getBySel(RESULTS_TABLE_COLUMNS_BUTTON).should('have.text', 'Columns32/35'); // enter fullscreen From d9b341ff1c7e9754e44a3517c60403a514e3e3f2 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 2 Jul 2024 11:33:08 +0200 Subject: [PATCH 08/13] [Discover][ES|QL] Correctly adds the limit to the field statistics queries (#186967) ## Summary Closes https://github.com/elastic/kibana/issues/186945 image ### Checklist - [ ] [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 --- .../utils/get_esql_with_safe_limit.test.ts | 16 ++++++-- .../src/utils/get_esql_with_safe_limit.ts | 39 ++++++++++--------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts index e452127506b26..a18309d38dddd 100644 --- a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts +++ b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.test.ts @@ -17,15 +17,23 @@ describe('getESQLWithSafeLimit()', () => { }); it('should add the limit', () => { - expect(getESQLWithSafeLimit(' from logs', LIMIT)).toBe('from logs \n| LIMIT 10000'); + expect(getESQLWithSafeLimit('from logs', LIMIT)).toBe('from logs \n| LIMIT 10000'); expect(getESQLWithSafeLimit('FROM logs* | LIMIT 5', LIMIT)).toBe( - 'FROM logs* \n| LIMIT 10000| LIMIT 5' + 'FROM logs* \n| LIMIT 10000 | LIMIT 5' ); expect(getESQLWithSafeLimit('FROM logs* | SORT @timestamp | LIMIT 5', LIMIT)).toBe( - 'FROM logs* |SORT @timestamp \n| LIMIT 10000| LIMIT 5' + 'FROM logs* | SORT @timestamp \n| LIMIT 10000 | LIMIT 5' ); expect(getESQLWithSafeLimit('from logs* | STATS MIN(a) BY b', LIMIT)).toBe( - 'from logs* \n| LIMIT 10000| STATS MIN(a) BY b' + 'from logs* \n| LIMIT 10000 | STATS MIN(a) BY b' + ); + + expect(getESQLWithSafeLimit('from logs* | STATS MIN(a) BY b | SORT b', LIMIT)).toBe( + 'from logs* \n| LIMIT 10000 | STATS MIN(a) BY b | SORT b' + ); + + expect(getESQLWithSafeLimit('from logs* // | STATS MIN(a) BY b', LIMIT)).toBe( + 'from logs* \n| LIMIT 10000 // | STATS MIN(a) BY b' ); }); }); diff --git a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts index 793292909c68f..21eef75b78800 100644 --- a/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts +++ b/packages/kbn-esql-utils/src/utils/get_esql_with_safe_limit.ts @@ -5,30 +5,33 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; export function getESQLWithSafeLimit(esql: string, limit: number): string { - if (!esql.trim().toLowerCase().startsWith('from')) { + const { ast } = getAstAndSyntaxErrors(esql); + const sourceCommand = ast.find(({ name }) => ['from', 'metrics'].includes(name)); + if (!sourceCommand) { return esql; } - const parts = esql.split('|'); - if (!parts.length) { - return esql; + let sortCommandIndex = -1; + const sortCommand = ast.find(({ name }, index) => { + sortCommandIndex = index; + return name === 'sort'; + }); + + if (!sortCommand || (sortCommand && sortCommandIndex !== 1)) { + const sourcePipeText = esql.substring( + sourceCommand.location.min, + sourceCommand.location.max + 1 + ); + return esql.replace(sourcePipeText, `${sourcePipeText} \n| LIMIT ${limit}`); } - const fromCommandIndex = 0; - const sortCommandIndex = 1; - const index = - parts.length > 1 && parts[1].trim().toLowerCase().startsWith('sort') - ? sortCommandIndex - : fromCommandIndex; + const sourceSortPipeText = esql.substring( + sourceCommand.location.min, + sortCommand.location.max + 1 + ); - return parts - .map((part, i) => { - if (i === index) { - return `${part.trim()} \n| LIMIT ${limit}`; - } - return part; - }) - .join('|'); + return esql.replace(sourceSortPipeText, `${sourceSortPipeText} \n| LIMIT ${limit}`); } From 320495b1ccbeb35d218e77cc23ca45f8af61d61f Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 2 Jul 2024 12:38:00 +0300 Subject: [PATCH 09/13] fix: Obs Applications > Transaction Detail][SCREEN READER]: Table rows need TH[scope="row"] for SR usability: 0011 (#186980) Closes: https://github.com/elastic/observability-dev/issues/3548 Closes: https://github.com/elastic/observability-dev/issues/3597 ## Summary 1. This PR incorporates the `rowHeader` attribute into the` Latency correlations` and `Failed transaction correlations ` tables 2. It also enhances accessibility by replacing `EuiTooltips` to `EuiIconTip`. --- .../app/correlations/correlations_table.tsx | 3 + .../failed_transactions_correlations.tsx | 133 +++++++++--------- .../app/correlations/latency_correlations.tsx | 33 +++-- 3 files changed, 85 insertions(+), 84 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx index d781e9cbf4e5d..670521bb6086c 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/correlations_table.tsx @@ -27,6 +27,7 @@ interface CorrelationsTableProps { selectedTerm?: FieldValuePair; onFilter?: () => void; columns: Array>; + rowHeader?: string; onTableChange: (c: Criteria) => void; sorting?: EuiTableSortingType; } @@ -40,6 +41,7 @@ export function CorrelationsTable({ selectedTerm, onTableChange, sorting, + rowHeader, }: CorrelationsTableProps) { const euiTheme = useTheme(); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -85,6 +87,7 @@ export function CorrelationsTable({ loading={status === FETCH_STATUS.LOADING} error={status === FETCH_STATUS.FAILURE ? errorMessage : ''} columns={columns} + rowHeader={rowHeader} rowProps={(term) => { return { onClick: () => { diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx index d91f9e1935f76..9149be49e2986 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -14,10 +14,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiIcon, EuiTitle, EuiBadge, - EuiToolTip, EuiSwitch, EuiIconTip, } from '@elastic/eui'; @@ -108,30 +106,28 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '100px', field: 'pValue', name: ( - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel', { - defaultMessage: - 'The chance of getting at least this amount of field name and value for failed transactions given its prevalence in successful transactions.', + defaultMessage: 'p-value', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel', +   + - - + /> + ), render: (pValue: number) => pValue.toPrecision(3), @@ -141,29 +137,27 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '100px', field: 'failurePercentage', name: ( - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageLabel', { - defaultMessage: 'Percentage of time the term appear in failed transactions.', + defaultMessage: 'Failure %', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.failurePercentageLabel', +   + - - + /> + ), render: (_, { failurePercentage }) => asPercent(failurePercentage, 1), sortable: true, @@ -172,32 +166,29 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v field: 'successPercentage', width: '100px', name: ( - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageLabel', { - defaultMessage: - 'Percentage of time the term appear in successful transactions.', + defaultMessage: 'Success %', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.successPercentageLabel', +   + - - + /> + ), - render: (_, { successPercentage }) => asPercent(successPercentage, 1), sortable: true, }, @@ -208,25 +199,28 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v width: '116px', field: 'normalizedScore', name: ( - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', { - defaultMessage: - 'The score [0-1] of an attribute; the greater the score, the more an attribute contributes to failed transactions.', + defaultMessage: 'Score', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', +   + - - + /> + ), render: (_, { normalizedScore }) => { return
{asPreciseDecimal(normalizedScore, 2)}
; @@ -515,6 +509,7 @@ export function FailedTransactionsCorrelations({ onFilter }: { onFilter: () => v {showCorrelationsTable && ( columns={failedTransactionsCorrelationsColumns} + rowHeader="normalizedScore" significantTerms={correlationTerms} status={progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS} setPinnedSignificantTerm={setPinnedSignificantTerm} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx index 33310928a398b..1183338b0f4bd 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/correlations/latency_correlations.tsx @@ -10,14 +10,13 @@ import { useHistory } from 'react-router-dom'; import { orderBy } from 'lodash'; import { - EuiIcon, EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, - EuiToolTip, EuiBadge, + EuiIconTip, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; @@ -123,25 +122,28 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { width: '116px', field: 'correlation', name: ( - + {i18n.translate( + 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', { - defaultMessage: - 'The correlation score [0-1] of an attribute; the greater the score, the more an attribute increases latency.', + defaultMessage: 'Correlation', } )} - > - <> - {i18n.translate( - 'xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationLabel', +   + - - + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + /> + ), render: (_, { correlation }) => { return
{asPreciseDecimal(correlation, 2)}
; @@ -364,6 +366,7 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { {showCorrelationsTable && ( columns={mlCorrelationColumns} + rowHeader="correlation" significantTerms={histogramTerms} status={progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS} setPinnedSignificantTerm={setPinnedSignificantTerm} From 3699c3a44986d648a49a2beb7a0a19383928f898 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Tue, 2 Jul 2024 11:45:24 +0200 Subject: [PATCH 10/13] [APM] Environment N/A badge for the classic view should not appear (#187329) Closed #187245 ## Summary This PR removed the Environment N/A badge from the classic view. The Environment N/A badge should still be visible for services coming from logs and if there is an environment value it should still be visible. image ## Testing 1. Enable `observability:apmEnableMultiSignal` in advanced settings
2. Run the entities definition in the dev tools ``` POST kbn:/internal/api/entities/definition { "id": "apm-services-with-metadata", "name": "Services from logs and metrics", "displayNameTemplate": "test", "history": { "timestampField": "@timestamp", "interval": "5m" }, "type": "service", "indexPatterns": [ "logs-*", "metrics-*" ], "timestampField": "@timestamp", "lookback": "5m", "identityFields": [ { "field": "service.name", "optional": false }, { "field": "service.environment", "optional": true } ], "identityTemplate": "{{service.name}}:{{service.environment}}", "metadata": [ "tags", "host.name", "data_stream.type", "service.name", "service.instance.id", "service.namespace", "service.environment", "service.version", "service.runtime.name", "service.runtime.version", "service.node.name", "service.language.name", "agent.name", "cloud.provider", "cloud.instance.id", "cloud.availability_zone", "cloud.instance.name", "cloud.machine.type", "container.id" ], "metrics": [ { "name": "latency", "equation": "A", "metrics": [ { "name": "A", "aggregation": "avg", "field": "transaction.duration.histogram" } ] }, { "name": "throughput", "equation": "A / 5", "metrics": [ { "name": "A", "aggregation": "doc_count", "filter": "transaction.duration.histogram:*" } ] }, { "name": "failedTransactionRate", "equation": "A / B", "metrics": [ { "name": "A", "aggregation": "doc_count", "filter": "event.outcome: \"failure\"" }, { "name": "B", "aggregation": "doc_count", "filter": "event.outcome: *" } ] }, { "name": "logErrorRate", "equation": "A / B", "metrics": [ { "name": "A", "aggregation": "doc_count", "filter": "log.level: \"error\"" }, { "name": "B", "aggregation": "doc_count", "filter": "log.level: *" } ] }, { "name": "logRatePerMinute", "equation": "A / 5", "metrics": [ { "name": "A", "aggregation": "doc_count", "filter": "log.level: \"error\"" } ] } ] } ```
3. Generate data with synthrace 1. logs only: `node scripts/synthtrace simple_logs.ts` 2. APM only: `node scripts/synthtrace simple_trace.ts` 3. Modify simple_trace: Change line 29 to ``` .service({ name: `synth-no-env-${index}`, agentName: 'nodejs' }) ``` and run it again 4. Oblt Cluster ( No N/A label ) image --- .../multi_signal_inventory/table/get_service_columns.tsx | 7 ++++++- .../public/components/shared/environment_badge/index.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx index 349fc043c6f16..48667b7834656 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_inventory/multi_signal_inventory/table/get_service_columns.tsx @@ -68,7 +68,12 @@ export function getServiceColumns({ sortable: true, width: `${unit * 9}px`, dataType: 'number', - render: (_, { environments }) => , + render: (_, { environments, signalTypes }) => ( + + ), align: RIGHT_ALIGNMENT, }, { diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx index 6863d76908a0f..135848bae13d4 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/environment_badge/index.tsx @@ -12,12 +12,13 @@ import { NotAvailableEnvironment } from '../not_available_environment'; interface Props { environments: string[]; + isMetricsSignalType?: boolean; } -export function EnvironmentBadge({ environments = [] }: Props) { - return environments && environments.length > 0 ? ( +export function EnvironmentBadge({ environments = [], isMetricsSignalType = true }: Props) { + return isMetricsSignalType || (environments && environments.length > 0) ? ( Date: Tue, 2 Jul 2024 11:45:46 +0200 Subject: [PATCH 11/13] [CI] Display command on failure page (#186999) ## Summary This PR adds the executed command line to the failures page. We tweak the reporters to export the executed command to the junit xmls, then we read those attributes after parsing the results. The tests needed some adjustment, because they're very brittle, and don't seem to be very accurate anymore. Closes: https://github.com/elastic/kibana-operations/issues/127 Check out the `[logs]` for the failed tests here (ftr/jest/jest_integration): https://buildkite.com/elastic/kibana-pull-request/builds/218457 --- .../__fixtures__/ftr_report.xml | 4 ++-- .../__fixtures__/jest_report.xml | 4 ++-- .../__fixtures__/mocha_report.xml | 4 ++-- .../add_messages_to_report.test.ts | 10 ++++----- .../get_failures.test.ts | 6 +++++ .../failed_tests_reporter/get_failures.ts | 21 ++++++++++++++++-- .../report_failures_to_file.ts | 21 +++++++++++++----- .../failed_tests_reporter/test_report.ts | 4 ++++ .../src/jest/junit_reporter/junit_reporter.ts | 3 +++ .../src/mocha/junit_report_generation.js | 19 +++++++++++++--- .../src/mocha/junit_report_generation.test.js | 6 +++-- .../kbn-test/src/prettify_command_line.ts | 22 +++++++++++++++++++ 12 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 packages/kbn-test/src/prettify_command_line.ts diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml index 3bfc686f9e845..07f1e79b0f5df 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/ftr_report.xml @@ -1,6 +1,6 @@ - - + + - - + + diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml index 64cb7ce551ee5..699ad47c58240 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/__fixtures__/mocha_report.xml @@ -1,6 +1,6 @@ - - + + diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts index 02197cf54fab0..42f3dcd120e9c 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts @@ -60,8 +60,8 @@ it('rewrites ftr reports with minimal changes', async () => { +++ ftr.xml @@ -1,53 +1,56 @@ ‹?xml version="1.0" encoding="utf-8"?› - ‹testsuites› - ‹testsuite timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71"› + ‹testsuites name="ftr" timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71" command-line="node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts"› + ‹testsuite timestamp="2019-06-05T23:37:10" time="903.670" tests="129" failures="5" skipped="71" command-line="node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts"› ‹testcase name="maps app maps loaded from sample data ecommerce "before all" hook" classname="Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps/sample_data·js" time="154.378"› - ‹system-out› - ‹![CDATA[[00:00:00] │ @@ -155,7 +155,7 @@ it('rewrites jest reports with minimal changes', async () => { --- jest.xml +++ jest.xml @@ -3,13 +3,17 @@ - ‹testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts"› + ‹testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" command-line="node scripts/jest --config some/jest/config.ts"› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can start and end a process" time="1.316"/› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can force kill the process if langServer can not exit" time="3.182"/› ‹testcase classname="X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp" name="launcher can reconnect if process died" time="7.060"› @@ -203,8 +203,8 @@ it('rewrites mocha reports with minimal changes', async () => { +++ mocha.xml @@ -1,13 +1,16 @@ ‹?xml version="1.0" encoding="utf-8"?› - ‹testsuites› - ‹testsuite timestamp="2019-06-13T23:29:36" time="30.739" tests="1444" failures="2" skipped="3"› + ‹testsuites command-line="node scripts/functional_tests --config super-mocha-test.config.js"› + ‹testsuite timestamp="2019-06-13T23:29:36" time="30.739" tests="1444" failures="2" skipped="3" command-line="node scripts/functional_tests --config super-mocha-test.config.js"› ‹testcase name="code in multiple nodes "before all" hook" classname="X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts" time="0.121"› - ‹system-out› - ‹![CDATA[]]› diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts index 77d7cd93ce4b0..d76662c600724 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.test.ts @@ -16,6 +16,7 @@ it('discovers failures in ftr report', async () => { Array [ Object { "classname": "Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps/sample_data·js", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": " Error: retry.try timeout: TimeoutError: Waiting for element to be located By(css selector, [data-test-subj~=\\"layerTocActionsPanelToggleButtonRoad_Map_-_Bright\\"]) Wait timed out after 10055ms @@ -37,6 +38,7 @@ it('discovers failures in ftr report', async () => { }, Object { "classname": "Chrome X-Pack UI Functional Tests.x-pack/test/functional/apps/maps", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": " { NoSuchSessionError: This driver instance does not have a valid session ID (did you call WebDriver.quit()?) and may no longer be used. at promise.finally (/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-ciGroup7/node/immutable/kibana/node_modules/selenium-webdriver/lib/webdriver.js:726:38) @@ -56,6 +58,7 @@ it('discovers failures in ftr report', async () => { }, Object { "classname": "Firefox XPack UI Functional Tests.x-pack/test/functional/apps/machine_learning/anomaly_detection/saved_search_job·ts", + "commandLine": "node scripts/functional_tests --config=x-pack/test/api_integration/apis/status/config.ts", "failure": "{ NoSuchSessionError: Tried to run command without establishing a connection at Object.throwDecodedError (/dev/shm/workspace/kibana/node_modules/selenium-webdriver/lib/error.js:550:15) at parseHttpResponse (/dev/shm/workspace/kibana/node_modules/selenium-webdriver/lib/http.js:563:13) @@ -76,6 +79,7 @@ it('discovers failures in jest report', async () => { Array [ Object { "classname": "X-Pack Jest Tests.x-pack/legacy/plugins/code/server/lsp", + "commandLine": "node scripts/jest --config some/jest/config.ts", "failure": " TypeError: Cannot read property '0' of undefined at Object..test (/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts:166:10) @@ -95,6 +99,7 @@ it('discovers failures in mocha report', async () => { Array [ Object { "classname": "X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts", + "commandLine": "node scripts/functional_tests --config super-mocha-test.config.js", "failure": " Error: Unable to read artifact info from https://artifacts-api.elastic.co/v1/versions/8.0.0-SNAPSHOT/builds/latest/projects/elasticsearch: Service Temporarily Unavailable @@ -117,6 +122,7 @@ it('discovers failures in mocha report', async () => { }, Object { "classname": "X-Pack Mocha Tests.x-pack/legacy/plugins/code/server/__tests__/multi_node·ts", + "commandLine": "node scripts/functional_tests --config super-mocha-test.config.js", "failure": " TypeError: Cannot read property 'shutdown' of undefined at Context.shutdown (plugins/code/server/__tests__/multi_node.ts:125:23) diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts index e3230e3cdddce..f0955b17f9953 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/get_failures.ts @@ -16,6 +16,7 @@ export type TestFailure = FailedTestCase['$'] & { 'system-out'?: string; githubIssue?: string; failureCount?: number; + commandLine?: string; }; const getText = (node?: Array) => { @@ -71,19 +72,35 @@ const isLikelyIrrelevant = (name: string, failure: string) => { export function getFailures(report: TestReport) { const failures: TestFailure[] = []; + const commandLine = getCommandLineFromReport(report); + for (const testCase of makeFailedTestCaseIter(report)) { const failure = getText(testCase.failure); const likelyIrrelevant = isLikelyIrrelevant(testCase.$.name, failure); - failures.push({ + const failureObj = { // unwrap xml weirdness ...testCase.$, // Strip ANSI color characters failure, likelyIrrelevant, 'system-out': getText(testCase['system-out']), - }); + commandLine, + }; + + // cleaning up duplicates + delete failureObj['command-line']; + + failures.push(failureObj); } return failures; } + +function getCommandLineFromReport(report: TestReport) { + if ('testsuites' in report) { + return report.testsuites?.testsuite?.[0]?.$['command-line'] || ''; + } else { + return report.testsuite?.$['command-line'] || ''; + } +} diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts index ab54d7f60dfe5..d2c0fb705d1aa 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/report_failures_to_file.ts @@ -170,14 +170,23 @@ export async function reportFailuresToFile(

${escape(failure.name)}

- Failures in tracked branches: ${ - failure.failureCount || 0 - } + ${ + failure.commandLine + ? `

+ Command Line: +
${escape(failure.commandLine)}
+
` + : '' + } +
+ Failures in tracked branches: + ${failure.failureCount || 0} +
${ failure.githubIssue - ? `
${escape( - failure.githubIssue - )}` + ? `` : '' }
diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts index e70aa44a2a088..ce817f90079e0 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/test_report.ts @@ -37,6 +37,8 @@ export interface TestSuite { skipped: string; /* optional JSON encoded metadata */ 'metadata-json'?: string; + /* the command that ran this suite */ + 'command-line'?: string; }; testcase?: TestCase[]; } @@ -51,6 +53,8 @@ export interface TestCase { time: string; /* optional JSON encoded metadata */ 'metadata-json'?: string; + /* the command that ran this suite */ + 'command-line'?: string; }; /* contents of system-out elements */ 'system-out'?: Array; diff --git a/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts b/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts index edb109eaa7000..ef6986183dd6c 100644 --- a/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts +++ b/packages/kbn-test/src/jest/junit_reporter/junit_reporter.ts @@ -17,6 +17,7 @@ import { AggregatedResult, Test, BaseReporter } from '@jest/reporters'; import { escapeCdata } from '../../mocha/xml'; import { getUniqueJunitReportPath } from '../../report_path'; +import { prettifyCommandLine } from '../../prettify_command_line'; interface ReporterOptions { reportName?: string; @@ -71,6 +72,7 @@ export default class JestJUnitReporter extends BaseReporter { tests: results.numTotalTests, failures: results.numFailedTests, skipped: results.numPendingTests, + 'command-line': prettifyCommandLine(process.argv), }); // top level test results are the files/suites @@ -83,6 +85,7 @@ export default class JestJUnitReporter extends BaseReporter { failures: suite.numFailingTests, skipped: suite.numPendingTests, file: suite.testFilePath, + 'command-line': prettifyCommandLine(process.argv), }); // nested in there are the tests in that file diff --git a/packages/kbn-test/src/mocha/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.js index 4b35fba4fb1e6..fcf31672d7996 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.js @@ -17,6 +17,7 @@ import { getUniqueJunitReportPath } from '../report_path'; import { getSnapshotOfRunnableLogs } from './log_cache'; import { escapeCdata } from '../..'; +import { prettifyCommandLine } from '../prettify_command_line'; const dateNow = Date.now.bind(Date); @@ -95,14 +96,25 @@ export function setupJUnitReportGeneration(runner, options = {}) { // cache codeowners for quicker lookup const reversedCodeowners = getPathsWithOwnersReversed(); - const builder = xmlBuilder.create( + const commandLine = prettifyCommandLine(process.argv); + + const root = xmlBuilder.create( 'testsuites', { encoding: 'utf-8' }, {}, { skipNullAttributes: true } ); - const testsuitesEl = builder.ele('testsuite', { + root.att({ + name: 'ftr', + time: getDuration(stats), + tests: allTests.length + failedHooks.length, + failures: failures.length, + skipped: skippedResults.length, + 'command-line': commandLine, + }); + + const testsuitesEl = root.ele('testsuite', { name: reportName, timestamp: new Date(stats.startTime).toISOString().slice(0, -5), time: getDuration(stats), @@ -110,6 +122,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { failures: failures.length, skipped: skippedResults.length, 'metadata-json': JSON.stringify(metadata ?? {}), + 'command-line': commandLine, }); function addTestcaseEl(node, failed) { @@ -147,7 +160,7 @@ export function setupJUnitReportGeneration(runner, options = {}) { }); const reportPath = getUniqueJunitReportPath(rootDirectory, reportName); - const reportXML = builder.end(); + const reportXML = root.end(); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, reportXML, 'utf8'); }); diff --git a/packages/kbn-test/src/mocha/junit_report_generation.test.js b/packages/kbn-test/src/mocha/junit_report_generation.test.js index b6bc2e951d1df..aad96a93fd860 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.test.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.test.js @@ -45,9 +45,9 @@ describe('dev/mocha/junit report generation', () => { // test case results are wrapped in expect(report).toEqual({ - testsuites: { + testsuites: expect.objectContaining({ testsuite: [report.testsuites.testsuite[0]], - }, + }), }); // the single element at the root contains summary data for all tests results @@ -55,6 +55,8 @@ describe('dev/mocha/junit report generation', () => { expect(testsuite.$.time).toMatch(DURATION_REGEX); expect(testsuite.$.timestamp).toMatch(ISO_DATE_SEC_REGEX); expect(testsuite.$).toEqual({ + 'command-line': + 'node scripts/jest --config=packages/kbn-test/jest.config.js --runInBand --coverage=false --passWithNoTests', failures: '2', name: 'test', skipped: '1', diff --git a/packages/kbn-test/src/prettify_command_line.ts b/packages/kbn-test/src/prettify_command_line.ts new file mode 100644 index 0000000000000..0f8f1eb75570b --- /dev/null +++ b/packages/kbn-test/src/prettify_command_line.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { execSync } from 'child_process'; +import * as path from 'path'; + +const kibanaRoot = execSync('git rev-parse --show-toplevel').toString().trim() || process.cwd(); + +export function prettifyCommandLine(args: string[]) { + let [executable, ...rest] = args; + if (executable.endsWith('node')) { + executable = 'node'; + } + rest = rest.map((arg) => path.relative(kibanaRoot, arg)); + + return [executable, ...rest].join(' '); +} From d183092603cf4a40632733689d068d84f422b88f Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Tue, 2 Jul 2024 12:00:38 +0200 Subject: [PATCH 12/13] [EDR Workflows] Unskip add_integration.cy.ts (#187304) --- x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts index 0c423dc9cfe8c..6b847fb396967 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/add_integration.cy.ts @@ -103,8 +103,7 @@ describe('ALL - Add Integration', { tags: ['@ess', '@serverless'] }, () => { cy.contains(`version: ${oldVersion}`).should('not.exist'); }); }); - // FLAKY: https://github.com/elastic/kibana/issues/170593 - describe.skip('Add integration to policy', () => { + describe('Add integration to policy', () => { const [integrationName, policyName] = generateRandomStringName(2); let policyId: string; beforeEach(() => { From e73eb1d33e5b8e473e870b410604762d3da1e889 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 2 Jul 2024 12:32:53 +0200 Subject: [PATCH 13/13] Improves the performance of the table ES|QL visualization (#187142) ## Summary Closes https://github.com/elastic/kibana/issues/187089 Fixes the performance of the ES|QL charts: - the panel - the table visualization The ES|QL charts become imperformant for a big amount of fields. I am changing the implementation a bit in order to render (and hold to cache) only the fields that take part in the visualization and not all colums ### Checklist - [ ] [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 --- .../map_to_columns/map_to_columns.test.ts | 52 +++++++++++++ .../map_to_columns/map_to_columns.ts | 12 ++- .../map_to_columns_fn_textbased.ts | 38 ++++++++++ .../expressions/map_to_columns/types.ts | 1 + .../shared/edit_on_the_fly/helpers.ts | 2 +- .../lens_configuration_flyout.tsx | 3 +- .../components/dimension_editor.tsx | 73 +++++++++++++------ .../components/dimension_trigger.tsx | 35 +-------- .../text_based/text_based_languages.test.ts | 3 + .../text_based/text_based_languages.tsx | 17 ++++- .../datasources/text_based/to_expression.ts | 1 + .../open_lens_config/create_action_helpers.ts | 2 +- 12 files changed, 174 insertions(+), 65 deletions(-) create mode 100644 x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts index e5d678b88e5a5..5a70c4385784c 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.test.ts @@ -279,4 +279,56 @@ describe('map_to_columns', () => { } `); }); + + describe('map_to_columns_text_based', () => { + it('should keep columns that exist in idMap only', async () => { + const input: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'number' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + { id: 'c', name: 'C', meta: { type: 'string' } }, + ], + rows: [ + { a: 1, b: 2, c: '3' }, + { a: 3, b: 4, c: '5' }, + { a: 5, b: 6, c: '7' }, + { a: 7, b: 8, c: '9' }, + ], + }; + + const idMap = { + a: [ + { + id: 'a', + label: 'A', + }, + ], + b: [ + { + id: 'b', + label: 'B', + }, + ], + }; + + const result = await mapToColumns.fn( + input, + { idMap: JSON.stringify(idMap), isTextBased: true }, + createMockExecutionContext() + ); + + expect(result.columns).toStrictEqual([ + { id: 'a', name: 'A', meta: { type: 'number' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ]); + + expect(result.rows).toStrictEqual([ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts index 3315cd4170dd9..0faa4de4fac41 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns.ts @@ -22,11 +22,21 @@ export const mapToColumns: MapToColumnsExpressionFunction = { 'A JSON encoded object in which keys are the datatable column ids and values are the Lens column definitions. Any datatable columns not mentioned within the ID map will be kept unmapped.', }), }, + isTextBased: { + types: ['boolean'], + help: i18n.translate('xpack.lens.functions.mapToColumns.isESQL.help', { + defaultMessage: 'An optional flag to indicate if this is about the text based datasource.', + }), + }, }, inputTypes: ['datatable'], async fn(...args) { /** Build optimization: prevent adding extra code into initial bundle **/ const { mapToOriginalColumns } = await import('./map_to_columns_fn'); - return mapToOriginalColumns(...args); + const { mapToOriginalColumnsTextBased } = await import('./map_to_columns_fn_textbased'); + + return args?.[1]?.isTextBased + ? mapToOriginalColumnsTextBased(...args) + : mapToOriginalColumns(...args); }, }; diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts new file mode 100644 index 0000000000000..cbaa8b8888dfe --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/map_to_columns_fn_textbased.ts @@ -0,0 +1,38 @@ +/* + * 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 { OriginalColumn, MapToColumnsExpressionFunction } from './types'; + +export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn'] = ( + data, + { idMap: encodedIdMap } +) => { + const idMap = JSON.parse(encodedIdMap) as Record; + + return { + ...data, + rows: data.rows.map((row) => { + const mappedRow: Record = {}; + + for (const id in row) { + if (id in idMap) { + for (const cachedEntry of idMap[id]) { + mappedRow[cachedEntry.id] = row[id]; // <= I wrote idMap rather than mappedRow + } + } + } + + return mappedRow; + }), + columns: data.columns.flatMap((column) => { + if (!(column.id in idMap)) { + return []; + } + return idMap[column.id].map((originalColumn) => ({ ...column, id: originalColumn.id })); + }), + }; +}; diff --git a/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts b/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts index 4623f435e5a67..e6617c38863bf 100644 --- a/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts +++ b/x-pack/plugins/lens/common/expressions/map_to_columns/types.ts @@ -17,6 +17,7 @@ export type MapToColumnsExpressionFunction = ExpressionFunctionDefinition< Datatable, { idMap: string; + isTextBased?: boolean; }, Datatable | Promise >; diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts index 8da4c87607987..e0f6654287a3f 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -42,7 +42,7 @@ export const getSuggestions = async ( signal: abortController?.signal, }); const context = { - dataViewSpec: dataView?.toSpec(), + dataViewSpec: dataView?.toSpec(false), fieldName: '', textBasedColumns: columns, query, diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index d55cfddf5a488..5c163df2c0715 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -117,8 +117,7 @@ export function LensEditConfigurationFlyout({ // there are cases where a query can return a big amount of columns // at this case we don't suggest all columns in a table but the first // MAX_NUM_OF_COLUMNS - const columns = Object.keys(table.rows?.[0]) ?? []; - setSuggestsLimitedColumns(columns.length >= MAX_NUM_OF_COLUMNS); + setSuggestsLimitedColumns(table.columns.length >= MAX_NUM_OF_COLUMNS); layers.forEach((layer) => { activeData[layer] = table; }); diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx index 0f82a65fc1ff7..d81ad37a22030 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { ExpressionsStart, DatatableColumn } from '@kbn/expressions-plugin/public'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import type { DatasourceDimensionEditorProps, DataType } from '../../../types'; import { FieldSelect } from './field_select'; import type { TextBasedPrivateState } from '../types'; -import { retrieveLayerColumnsFromCache, getColumnsFromCache } from '../fieldlist_cache'; import { isNotNumeric, isNumeric } from '../utils'; export type TextBasedDimensionEditorProps = @@ -22,30 +22,55 @@ export type TextBasedDimensionEditorProps = }; export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) { + const [allColumns, setAllColumns] = useState([]); const query = props.state.layers[props.layerId]?.query; - const allColumns = retrieveLayerColumnsFromCache( - props.state.layers[props.layerId]?.columns ?? [], - query - ); - const allFields = query ? getColumnsFromCache(query) : []; + useEffect(() => { + // in case the columns are not in the cache, I refetch them + async function fetchColumns() { + if (query) { + const table = await fetchFieldsFromESQL( + { esql: `${query.esql} | limit 0` }, + props.expressions + ); + if (table) { + setAllColumns(table.columns); + } + } + } + fetchColumns(); + }, [props.expressions, query]); + const hasNumberTypeColumns = allColumns?.some(isNumeric); - const fields = allFields.map((col) => { - return { - id: col.id, - name: col.name, - meta: col?.meta ?? { type: 'number' }, - compatible: - props.isMetricDimension && hasNumberTypeColumns - ? props.filterOperations({ - dataType: col?.meta?.type as DataType, - isBucketed: Boolean(isNotNumeric(col)), - scale: 'ordinal', - }) - : true, - }; - }); - const selectedField = allColumns?.find((column) => column.columnId === props.columnId); + const fields = useMemo(() => { + return allColumns.map((col) => { + return { + id: col.id, + name: col.name, + meta: col?.meta ?? { type: 'number' }, + compatible: + props.isMetricDimension && hasNumberTypeColumns + ? props.filterOperations({ + dataType: col?.meta?.type as DataType, + isBucketed: Boolean(isNotNumeric(col)), + scale: 'ordinal', + }) + : true, + }; + }); + }, [allColumns, hasNumberTypeColumns, props]); + + const selectedField = useMemo(() => { + const field = fields?.find((column) => column.id === props.columnId); + if (field) { + return { + fieldName: field.name, + meta: field.meta, + columnId: field.id, + }; + } + return undefined; + }, [fields, props.columnId]); return ( <> diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx index f6062068cee77..922c0b2ba9fab 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx @@ -5,18 +5,12 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import { DimensionTrigger } from '@kbn/visualization-ui-components'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { DatasourceDimensionTriggerProps } from '../../../types'; import type { TextBasedPrivateState } from '../types'; -import { - getColumnsFromCache, - addColumnsToCache, - retrieveLayerColumnsFromCache, -} from '../fieldlist_cache'; export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps & { columnLabelMap: Record; @@ -24,35 +18,12 @@ export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps { - // in case the columns are not in the cache, I refetch them - async function fetchColumns() { - const fieldList = query ? getColumnsFromCache(query) : []; + const customLabel: string | undefined = props.columnLabelMap[props.columnId]; - if (fieldList.length === 0 && query) { - const table = await fetchFieldsFromESQL(query, props.expressions); - if (table) { - addColumnsToCache(query, table.columns); - } - } - setDataHasLoaded(true); - } - fetchColumns(); - }, [props.expressions, query]); - const allColumns = dataHasLoaded - ? retrieveLayerColumnsFromCache(props.state.layers[props.layerId]?.columns ?? [], query) - : []; - const selectedField = allColumns?.find((column) => column.columnId === props.columnId); - let customLabel: string | undefined = props.columnLabelMap[props.columnId]; - if (!customLabel) { - customLabel = selectedField?.fieldName; - } return ( { "idMap": Array [ "{\\"Test 1\\":[{\\"id\\":\\"a\\",\\"label\\":\\"Test 1\\"}],\\"Test 2\\":[{\\"id\\":\\"b\\",\\"label\\":\\"Test 2\\"}]}", ], + "isTextBased": Array [ + true, + ], }, "function": "lens_map_to_columns", "type": "function", diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index 8cf8ce7ebd360..411583d88ef13 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -11,7 +11,7 @@ import { CoreStart } from '@kbn/core/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { AggregateQuery, isOfAggregateQueryType, getAggregateQueryMode } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core/public'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { ExpressionsStart, DatatableColumn } from '@kbn/expressions-plugin/public'; import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import memoizeOne from 'memoize-one'; @@ -180,6 +180,15 @@ export function getTextBasedDatasource({ return Object.entries(state.layers)?.flatMap(([id, layer]) => { const allColumns = retrieveLayerColumnsFromCache(layer.columns, layer.query); + if (!allColumns.length && layer.query) { + const layerColumns = layer.columns.map((c) => ({ + id: c.columnId, + name: c.fieldName, + meta: c.meta, + })) as DatatableColumn[]; + addColumnsToCache(layer.query, layerColumns); + } + const unchangedSuggestionTable = getUnchangedSuggestionTable(state, allColumns, id); // we are trying here to cover the most common cases for the charts we offer @@ -214,7 +223,7 @@ export function getTextBasedDatasource({ if (fieldName) return []; if (context && 'dataViewSpec' in context && context.dataViewSpec.title && context.query) { const newLayerId = generateId(); - const textBasedQueryColumns = context.textBasedColumns ?? []; + const textBasedQueryColumns = context.textBasedColumns?.slice(0, MAX_NUM_OF_COLUMNS) ?? []; // Number fields are assigned automatically as metrics (!isBucketed). There are cases where the query // will not return number fields. In these cases we want to suggest a datatable // Datatable works differently in this case. On the metrics dimension can be all type of fields @@ -258,7 +267,7 @@ export function getTextBasedDatasource({ [newLayerId]: { index, query, - columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [], + columns: newColumns ?? [], timeField: context.dataViewSpec.timeFieldName, }, }, @@ -275,7 +284,7 @@ export function getTextBasedDatasource({ notAssignedMetrics: !hasNumberTypeColumns, layerId: newLayerId, columns: - newColumns?.slice(0, MAX_NUM_OF_COLUMNS)?.map((f) => { + newColumns?.map((f) => { return { columnId: f.columnId, operation: { diff --git a/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts b/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts index 148a16232c980..a175f191d5916 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/to_expression.ts @@ -59,6 +59,7 @@ function getExpressionForLayer( function: 'lens_map_to_columns', arguments: { idMap: [JSON.stringify(idMapper)], + isTextBased: [true], }, }); return textBasedQueryToAst; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts index 3a7f35a254b26..387349039fed0 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/create_action_helpers.ts @@ -78,7 +78,7 @@ export async function executeCreateAction({ }); const context = { - dataViewSpec: dataView.toSpec(), + dataViewSpec: dataView.toSpec(false), fieldName: '', textBasedColumns: columns, query: defaultEsqlQuery,