From 1e77d8d10d35104318285152a39cd14c5fd3c3f6 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 14 Nov 2022 08:27:10 -0500 Subject: [PATCH 01/20] [Cases] Assignees enhancements (#144836) WIP This PR implements some enhancements for the assignees feature that wasn't completed in 8.5. Issue: https://github.com/elastic/kibana/issues/141057 Fixes: https://github.com/elastic/kibana/issues/140889 ### List sorting The current user is not brought to the front of lists (only in the popovers). Unknown users are still placed at the end of the list.
Current user is sorted like other users #### Case View Page ![image](https://user-images.githubusercontent.com/56361221/200646181-9744622f-fe11-41c5-97ac-ce7b777d47a1.png) #### Case List Page Avatars ![image](https://user-images.githubusercontent.com/56361221/200646269-b637743f-35f1-48d0-91bd-faee32784613.png)
### Limit assignee selection Leverage the `limit` prop exposed by the `UserProfilesSelectable` here: https://github.com/elastic/kibana/pull/144618
Adding limit message ![image](https://user-images.githubusercontent.com/56361221/200653672-9c195031-3117-4ac9-b6e9-98ac11ee170e.png)
### Show the selected count Show the selected count even when it is zero so the component doesn't jump around.
Selected count #### View case page ![image](https://user-images.githubusercontent.com/56361221/200659972-a6eca466-0d4c-4736-9a2e-62b422f99944.png) #### All cases filter ![image](https://user-images.githubusercontent.com/56361221/200660181-da13092b-6f6a-4b2d-98cd-325ebf8d75b1.png)
### Expandable assignees column Added a button to expand/collapse the assignee avatars column on the all cases list page
Cases list page assignees column ![image](https://user-images.githubusercontent.com/56361221/200891826-08f15531-3a47-40c1-9cc6-12558b645083.png) ![image](https://user-images.githubusercontent.com/56361221/200892014-92cd3142-15d0-4250-b83e-b32b1c9dd03f.png)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/all_cases/all_cases_list.tsx | 1 - .../all_cases/assignees_column.test.tsx | 154 ++++++++++++++++++ .../components/all_cases/assignees_column.tsx | 97 +++++++++++ .../components/all_cases/assignees_filter.tsx | 8 +- .../components/all_cases/translations.ts | 10 ++ .../all_cases/use_cases_columns.test.tsx | 86 +++++++++- .../all_cases/use_cases_columns.tsx | 52 +----- .../case_view/components/assign_users.tsx | 1 - .../components/suggest_users_popover.test.tsx | 4 +- .../components/suggest_users_popover.tsx | 16 +- .../selected_status_message.test.tsx | 24 --- .../user_profiles/selected_status_message.tsx | 22 --- .../components/user_profiles/translations.ts | 7 + .../user_profiles/use_assignees.test.ts | 28 +--- .../containers/user_profiles/use_assignees.ts | 13 +- 15 files changed, 373 insertions(+), 150 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/assignees_column.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/assignees_column.tsx delete mode 100644 x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index f4449c39490033..42ef26d6ba1ace 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -199,7 +199,6 @@ export const AllCasesList = React.memo( const { columns } = useCasesColumns({ filterStatus: filterOptions.status ?? StatusAll, userProfiles: userProfiles ?? new Map(), - currentUserProfile, isSelectorView, connectors, onRowClick, diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_column.test.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_column.test.tsx new file mode 100644 index 00000000000000..c0f33130a11370 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_column.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; +import type { AssigneesColumnProps } from './assignees_column'; +import { AssigneesColumn } from './assignees_column'; + +describe('AssigneesColumn', () => { + const defaultProps: AssigneesColumnProps = { + assignees: userProfiles, + userProfiles: userProfilesMap, + compressedDisplayLimit: 2, + }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + + appMockRender = createAppMockRenderer(); + }); + + it('renders a long dash if the assignees is an empty array', async () => { + const props = { + ...defaultProps, + assignees: [], + }; + + appMockRender.render(); + + expect( + screen.queryByTestId('case-table-column-assignee-damaged_raccoon') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-table-column-expand-button')).not.toBeInTheDocument(); + // u2014 is the unicode for a long dash + expect(screen.getByText('\u2014')).toBeInTheDocument(); + }); + + it('only renders 2 avatars when the limit is 2', async () => { + const props = { + ...defaultProps, + }; + + appMockRender.render(); + + expect(screen.getByTestId('case-table-column-assignee-damaged_raccoon')).toBeInTheDocument(); + expect(screen.getByTestId('case-table-column-assignee-physical_dinosaur')).toBeInTheDocument(); + }); + + it('renders all 3 avatars when the limit is 5', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: 5, + }; + + appMockRender.render(); + + expect(screen.getByTestId('case-table-column-assignee-damaged_raccoon')).toBeInTheDocument(); + expect(screen.getByTestId('case-table-column-assignee-physical_dinosaur')).toBeInTheDocument(); + expect(screen.getByTestId('case-table-column-assignee-wet_dingo')).toBeInTheDocument(); + }); + + it('shows the show more avatars button when the limit is 2', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: 2, + }; + + appMockRender.render(); + + expect(screen.getByTestId('case-table-column-expand-button')).toBeInTheDocument(); + expect(screen.getByText('+1 more')).toBeInTheDocument(); + }); + + it('does not show the show more button when the limit is 5', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: 5, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('case-table-column-expand-button')).not.toBeInTheDocument(); + }); + + it('does not show the show more button when the limit is the same number of the assignees', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: userProfiles.length, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('case-table-column-expand-button')).not.toBeInTheDocument(); + }); + + it('displays the show less avatars button when the show more is clicked', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: 2, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('case-table-column-assignee-wet_dingo')).not.toBeInTheDocument(); + + expect(screen.getByTestId('case-table-column-expand-button')).toBeInTheDocument(); + expect(screen.getByText('+1 more')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-table-column-expand-button')); + + await waitFor(() => { + expect(screen.getByText('show less')).toBeInTheDocument(); + expect(screen.getByTestId('case-table-column-assignee-wet_dingo')).toBeInTheDocument(); + }); + }); + + it('shows more avatars and then hides them when the expand row button is clicked multiple times', async () => { + const props = { + ...defaultProps, + compressedDisplayLimit: 2, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('case-table-column-assignee-wet_dingo')).not.toBeInTheDocument(); + + expect(screen.getByTestId('case-table-column-expand-button')).toBeInTheDocument(); + expect(screen.getByText('+1 more')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-table-column-expand-button')); + + await waitFor(() => { + expect(screen.getByText('show less')).toBeInTheDocument(); + expect(screen.getByTestId('case-table-column-assignee-wet_dingo')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('case-table-column-expand-button')); + + await waitFor(() => { + expect(screen.getByText('+1 more')).toBeInTheDocument(); + expect(screen.queryByTestId('case-table-column-assignee-wet_dingo')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_column.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_column.tsx new file mode 100644 index 00000000000000..8b5444f9a9d911 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_column.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import type { Case } from '../../../common/ui/types'; +import { getEmptyTagValue } from '../empty_value'; +import { UserToolTip } from '../user_profiles/user_tooltip'; +import { useAssignees } from '../../containers/user_profiles/use_assignees'; +import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; +import { SmallUserAvatar } from '../user_profiles/small_user_avatar'; +import * as i18n from './translations'; + +const COMPRESSED_AVATAR_LIMIT = 3; + +export interface AssigneesColumnProps { + assignees: Case['assignees']; + userProfiles: Map; + compressedDisplayLimit?: number; +} + +const AssigneesColumnComponent: React.FC = ({ + assignees, + userProfiles, + compressedDisplayLimit = COMPRESSED_AVATAR_LIMIT, +}) => { + const [isAvatarListExpanded, setIsAvatarListExpanded] = useState(false); + + const { allAssignees } = useAssignees({ + caseAssignees: assignees, + userProfiles, + }); + + const toggleExpandedAvatars = useCallback( + () => setIsAvatarListExpanded((prevState) => !prevState), + [] + ); + + const numHiddenAvatars = allAssignees.length - compressedDisplayLimit; + const shouldShowExpandListButton = numHiddenAvatars > 0; + + const limitedAvatars = useMemo( + () => allAssignees.slice(0, compressedDisplayLimit), + [allAssignees, compressedDisplayLimit] + ); + + const avatarsToDisplay = useMemo(() => { + if (isAvatarListExpanded || !shouldShowExpandListButton) { + return allAssignees; + } + + return limitedAvatars; + }, [allAssignees, isAvatarListExpanded, limitedAvatars, shouldShowExpandListButton]); + + if (allAssignees.length <= 0) { + return getEmptyTagValue(); + } + + return ( + + {avatarsToDisplay.map((assignee) => { + const dataTestSubjName = getUsernameDataTestSubj(assignee); + return ( + + + + + + ); + })} + + {shouldShowExpandListButton ? ( + + {isAvatarListExpanded ? i18n.SHOW_LESS : i18n.SHOW_MORE(numHiddenAvatars)} + + ) : null} + + ); +}; + +AssigneesColumnComponent.displayName = 'AssigneesColumn'; + +export const AssigneesColumn = React.memo(AssigneesColumnComponent); diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx index 67086f44e1fefa..1f9118010ba169 100644 --- a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx @@ -15,7 +15,6 @@ import { useCasesContext } from '../cases_context/use_cases_context'; import type { CurrentUserProfile } from '../types'; import { EmptyMessage } from '../user_profiles/empty_message'; import { NoMatches } from '../user_profiles/no_matches'; -import { SelectedStatusMessage } from '../user_profiles/selected_status_message'; import { bringCurrentUserToFrontAndSort, orderAssigneesIncludingNone } from '../user_profiles/sort'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; import * as i18n from './translations'; @@ -53,12 +52,7 @@ const AssigneesFilterPopoverComponent: React.FC = ( ); const selectedStatusMessage = useCallback( - (selectedCount: number) => ( - - ), + (selectedCount: number) => i18n.TOTAL_ASSIGNEES_FILTERED(selectedCount), [] ); diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index aedfefd35c3605..1a215cd1ef8891 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -137,3 +137,13 @@ export const NO_ASSIGNEES = i18n.translate( defaultMessage: 'No assignees', } ); + +export const SHOW_LESS = i18n.translate('xpack.cases.allCasesView.showLessAvatars', { + defaultMessage: 'show less', +}); + +export const SHOW_MORE = (count: number) => + i18n.translate('xpack.cases.allCasesView.showMoreAvatars', { + defaultMessage: '+{count} more', + values: { count }, + }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx index 5ae7556e6bd293..0647bdd25e3827 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -18,14 +18,13 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, readCasesPermissions, TestProviders } from '../../common/mock'; import { renderHook } from '@testing-library/react-hooks'; import { CaseStatuses } from '../../../common'; -import { userProfilesMap, userProfiles } from '../../containers/user_profiles/api.mock'; +import { userProfilesMap } from '../../containers/user_profiles/api.mock'; describe('useCasesColumns ', () => { let appMockRender: AppMockRenderer; const useCasesColumnsProps: GetCasesColumn = { filterStatus: CaseStatuses.open, userProfiles: userProfilesMap, - currentUserProfile: userProfiles[0], isSelectorView: false, showSolutionColumn: true, }; @@ -58,6 +57,7 @@ describe('useCasesColumns ', () => { "field": "assignees", "name": "Assignees", "render": [Function], + "width": "180px", }, Object { "field": "tags", @@ -112,6 +112,86 @@ describe('useCasesColumns ', () => { `); }); + it('returns the assignees column without the width specified when in the modal view', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + + const { result } = renderHook( + () => useCasesColumns({ ...useCasesColumnsProps, isSelectorView: true }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + "width": "20%", + }, + Object { + "field": "assignees", + "name": "Assignees", + "render": [Function], + "width": undefined, + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "width": "15%", + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + "width": "80px", + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "render": [Function], + }, + ], + } + `); + }); + it('does not render the solution columns', async () => { const license = licensingMock.createLicense({ license: { type: 'platinum' }, @@ -138,6 +218,7 @@ describe('useCasesColumns ', () => { "field": "assignees", "name": "Assignees", "render": [Function], + "width": "180px", }, Object { "field": "tags", @@ -209,6 +290,7 @@ describe('useCasesColumns ', () => { "field": "assignees", "name": "Assignees", "render": [Function], + "width": "180px", }, Object { "field": "tags", diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index a1c845ad94f472..cb08bf4b6e526b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -43,12 +43,8 @@ import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { severities } from '../severity/config'; -import { UserToolTip } from '../user_profiles/user_tooltip'; -import { useAssignees } from '../../containers/user_profiles/use_assignees'; -import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; -import type { CurrentUserProfile } from '../types'; -import { SmallUserAvatar } from '../user_profiles/small_user_avatar'; import { useCasesFeatures } from '../../common/use_cases_features'; +import { AssigneesColumn } from './assignees_column'; type CasesColumns = | EuiTableActionsColumnType @@ -76,47 +72,9 @@ const StyledEuiBadge = euiStyled(EuiBadge)` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); -const AssigneesColumn: React.FC<{ - assignees: Case['assignees']; - userProfiles: Map; - currentUserProfile: CurrentUserProfile; -}> = ({ assignees, userProfiles, currentUserProfile }) => { - const { allAssignees } = useAssignees({ - caseAssignees: assignees, - userProfiles, - currentUserProfile, - }); - - if (allAssignees.length <= 0) { - return getEmptyTagValue(); - } - - return ( - - {allAssignees.map((assignee) => { - const dataTestSubjName = getUsernameDataTestSubj(assignee); - return ( - - - - - - ); - })} - - ); -}; - -AssigneesColumn.displayName = 'AssigneesColumn'; - export interface GetCasesColumn { filterStatus: string; userProfiles: Map; - currentUserProfile: CurrentUserProfile; isSelectorView: boolean; connectors?: ActionConnector[]; onRowClick?: (theCase: Case) => void; @@ -131,7 +89,6 @@ export interface UseCasesColumnsReturnValue { export const useCasesColumns = ({ filterStatus, userProfiles, - currentUserProfile, isSelectorView, connectors = [], onRowClick, @@ -184,12 +141,9 @@ export const useCasesColumns = ({ field: 'assignees', name: i18n.ASSIGNEES, render: (assignees: Case['assignees']) => ( - + ), + width: !isSelectorView ? '180px' : undefined, }); } diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx index 90d227901259ac..bd09c49597d9ed 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx @@ -104,7 +104,6 @@ const AssignUsersComponent: React.FC = ({ const { assigneesWithProfiles, assigneesWithoutProfiles, allAssignees } = useAssignees({ caseAssignees, userProfiles, - currentUserProfile, }); const [selectedAssignees, setSelectedAssignees] = useState(); diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx index bf22c764290aa8..479b8e39d232da 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx @@ -36,7 +36,7 @@ describe('SuggestUsersPopover', () => { }; }); - it.skip('calls onUsersChange when 1 user is selected', async () => { + it('calls onUsersChange when 1 user is selected', async () => { const onUsersChange = jest.fn(); const props = { ...defaultProps, onUsersChange }; appMockRender.render(); @@ -182,7 +182,7 @@ describe('SuggestUsersPopover', () => { expect(screen.getByText('1 assigned')).toBeInTheDocument(); }); - it.skip('shows the 1 assigned total after clicking on a user', async () => { + it('shows the 1 assigned total after clicking on a user', async () => { appMockRender.render(); await waitForEuiPopoverOpen(); diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx index fd73d3a2ae7ce3..af3883257fdd46 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx @@ -11,12 +11,12 @@ import { UserProfilesPopover } from '@kbn/user-profile-components'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { MAX_ASSIGNEES_PER_CASE } from '../../../../common/constants'; import { useSuggestUserProfiles } from '../../../containers/user_profiles/use_suggest_user_profiles'; import { useCasesContext } from '../../cases_context/use_cases_context'; import type { AssigneeWithProfile } from '../../user_profiles/types'; import * as i18n from '../translations'; import { bringCurrentUserToFrontAndSort } from '../../user_profiles/sort'; -import { SelectedStatusMessage } from '../../user_profiles/selected_status_message'; import { EmptyMessage } from '../../user_profiles/empty_message'; import { NoMatches } from '../../user_profiles/no_matches'; import type { CurrentUserProfile } from '../../types'; @@ -79,12 +79,12 @@ const SuggestUsersPopoverComponent: React.FC = ({ ); const selectedStatusMessage = useCallback( - (selectedCount: number) => ( - - ), + (selectedCount: number) => i18n.TOTAL_USERS_ASSIGNED(selectedCount), + [] + ); + + const limitReachedMessage = useCallback( + (limit: number) => i18n.MAX_SELECTED_ASSIGNEES(limit), [] ); @@ -131,6 +131,8 @@ const SuggestUsersPopoverComponent: React.FC = ({ selectedOptions: selectedUsers ?? selectedProfiles, isLoading: isLoadingData, height: 'full', + limit: MAX_ASSIGNEES_PER_CASE, + limitReachedMessage, searchPlaceholder: i18n.SEARCH_USERS, clearButtonLabel: i18n.REMOVE_ASSIGNEES, emptyMessage: , diff --git a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx deleted file mode 100644 index b9611bb683d447..00000000000000 --- a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { SelectedStatusMessage } from './selected_status_message'; - -describe('SelectedStatusMessage', () => { - it('does not render if the count is 0', () => { - const { container } = render(); - - expect(container.firstChild).toBeNull(); - expect(screen.queryByText('hello')).not.toBeInTheDocument(); - }); - - it('renders the message when the count is great than 0', () => { - render(); - - expect(screen.getByText('hello')).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx deleted file mode 100644 index 87839fb7c34826..00000000000000 --- a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -const SelectedStatusMessageComponent: React.FC<{ - selectedCount: number; - message: string; -}> = ({ selectedCount, message }) => { - if (selectedCount <= 0) { - return null; - } - - return <>{message}; -}; -SelectedStatusMessageComponent.displayName = 'SelectedStatusMessage'; - -export const SelectedStatusMessage = React.memo(SelectedStatusMessageComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/translations.ts b/x-pack/plugins/cases/public/components/user_profiles/translations.ts index 2624ec834cd2e1..f3c338c4d7b4ed 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/translations.ts +++ b/x-pack/plugins/cases/public/components/user_profiles/translations.ts @@ -65,3 +65,10 @@ export const INVALID_ASSIGNEES = i18n.translate('xpack.cases.create.invalidAssig maxAssignees: MAX_ASSIGNEES_PER_CASE, }, }); + +export const MAX_SELECTED_ASSIGNEES = (limit: number) => + i18n.translate('xpack.cases.userProfile.maxSelectedAssignees', { + defaultMessage: + "You've selected the maximum number of {count, plural, one {# assignee} other {# assignees}}", + values: { count: limit }, + }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts index f8b38e4d31dbf2..3622b66aef0066 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts @@ -13,7 +13,7 @@ import { useAssignees } from './use_assignees'; describe('useAssignees', () => { it('returns an empty array when the caseAssignees is empty', () => { const { result } = renderHook(() => - useAssignees({ caseAssignees: [], userProfiles: new Map(), currentUserProfile: undefined }) + useAssignees({ caseAssignees: [], userProfiles: new Map() }) ); expect(result.current.allAssignees).toHaveLength(0); @@ -26,7 +26,6 @@ describe('useAssignees', () => { useAssignees({ caseAssignees: userProfiles.map((profile) => ({ uid: profile.uid })), userProfiles: userProfilesMap, - currentUserProfile: undefined, }) ); @@ -41,7 +40,6 @@ describe('useAssignees', () => { useAssignees({ caseAssignees: unsorted.map((profile) => ({ uid: profile.uid })), userProfiles: userProfilesMap, - currentUserProfile: undefined, }) ); @@ -56,7 +54,6 @@ describe('useAssignees', () => { useAssignees({ caseAssignees: unknownProfiles, userProfiles: userProfilesMap, - currentUserProfile: undefined, }) ); @@ -71,7 +68,6 @@ describe('useAssignees', () => { useAssignees({ caseAssignees: assignees, userProfiles: userProfilesMap, - currentUserProfile: undefined, }) ); @@ -86,28 +82,6 @@ describe('useAssignees', () => { { uid: '1' }, ]); }); - - it('returns assignees with profiles with the current user at the front', () => { - const { result } = renderHook(() => - useAssignees({ - caseAssignees: userProfiles, - userProfiles: userProfilesMap, - currentUserProfile: userProfiles[2], - }) - ); - - expect(result.current.assigneesWithProfiles).toHaveLength(3); - expect(result.current.allAssignees).toHaveLength(3); - - const asAssignees = userProfiles.map(asAssigneeWithProfile); - - expect(result.current.assigneesWithProfiles).toEqual([ - asAssignees[2], - asAssignees[0], - asAssignees[1], - ]); - expect(result.current.allAssignees).toEqual([asAssignees[2], asAssignees[0], asAssignees[1]]); - }); }); const asAssigneeWithProfile = (profile: UserProfileWithAvatar) => ({ uid: profile.uid, profile }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts index 069eae715f2b9e..8e1f15d7d979a5 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts @@ -8,8 +8,7 @@ import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { useMemo } from 'react'; import type { CaseAssignees } from '../../../common/api'; -import type { CurrentUserProfile } from '../../components/types'; -import { bringCurrentUserToFrontAndSort } from '../../components/user_profiles/sort'; +import { sortProfiles } from '../../components/user_profiles/sort'; import type { Assignee, AssigneeWithProfile } from '../../components/user_profiles/types'; interface PartitionedAssignees { @@ -20,11 +19,9 @@ interface PartitionedAssignees { export const useAssignees = ({ caseAssignees, userProfiles, - currentUserProfile, }: { caseAssignees: CaseAssignees; userProfiles: Map; - currentUserProfile: CurrentUserProfile; }): { assigneesWithProfiles: AssigneeWithProfile[]; assigneesWithoutProfiles: Assignee[]; @@ -46,14 +43,14 @@ export const useAssignees = ({ { usersWithProfiles: [], usersWithoutProfiles: [] } ); - const orderedProf = bringCurrentUserToFrontAndSort(currentUserProfile, usersWithProfiles); + const orderedProf = sortProfiles(usersWithProfiles); - const assigneesWithProfile2 = orderedProf?.map((profile) => ({ uid: profile.uid, profile })); + const withProfiles = orderedProf?.map((profile) => ({ uid: profile.uid, profile })); return { - assigneesWithProfiles: assigneesWithProfile2 ?? [], + assigneesWithProfiles: withProfiles ?? [], assigneesWithoutProfiles: usersWithoutProfiles, }; - }, [caseAssignees, currentUserProfile, userProfiles]); + }, [caseAssignees, userProfiles]); const allAssignees = useMemo( () => [...assigneesWithProfiles, ...assigneesWithoutProfiles], From 10fcf61d56280c382e6b687543ebc5842c0aca1d Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 14 Nov 2022 08:29:36 -0500 Subject: [PATCH 02/20] Fix Health Gateway Checks (#144985) This PR has a few changes that are needed after learning that the existing control plane container health check uses `/` as opposed to `/api/status`: 1. The health gateway server now listens at `/` as opposed to `/api/status` 2. The health gateway now calls Kibana's `/` not `/api/status` 3. The health gateway will treat a 200-299 or 302 response code OR a 401 response code with a `www-authenticate` response header as healthy Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-health-gateway-server/README.md | 8 +-- .../src/kibana/kibana_service.test.ts | 4 +- .../src/kibana/kibana_service.ts | 4 +- .../src/kibana/routes/index.ts | 2 +- .../src/kibana/routes/{status.ts => root.ts} | 51 ++++++++++++------- 5 files changed, 42 insertions(+), 27 deletions(-) rename packages/kbn-health-gateway-server/src/kibana/routes/{status.ts => root.ts} (68%) diff --git a/packages/kbn-health-gateway-server/README.md b/packages/kbn-health-gateway-server/README.md index 70de6130b82abb..7474b6a5c3440e 100644 --- a/packages/kbn-health-gateway-server/README.md +++ b/packages/kbn-health-gateway-server/README.md @@ -1,7 +1,7 @@ # @kbn/health-gateway-server -This package runs a small server called the Health Gateway, which exists to query -the status APIs of multiple Kibana instances and return an aggregated result. +This package runs a small server called the Health Gateway, which exists to +check the health of multiple Kibana instances and return an aggregated result. This is used by the Elastic Cloud infrastructure to run two different Kibana processes with different `node.roles`: one process for handling UI requests, and one for background @@ -70,8 +70,8 @@ above (5605-5606). Once you have your `gateway.yml` and have started docker-compose, you can run the server from the `/packages/kbn-health-gateway-server` directory with `yarn start`. Then you should -be able to make requests to the `/api/status` endpoint: +be able to make requests to the `/` endpoint: ```bash -$ curl "https://localhost:3000/api/status" +$ curl "https://localhost:3000/" ``` diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts index 5940e8de9682b0..2f7d9813e2bf42 100644 --- a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts @@ -43,13 +43,13 @@ describe('KibanaService', () => { expect(kibanaStart).toBeUndefined(); }); - test('registers /api/status route with the server', async () => { + test('registers / route with the server', async () => { const kibanaService = new KibanaService({ config, logger }); await kibanaService.start({ server }); expect(server.addRoute).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', - path: '/api/status', + path: '/', }) ); }); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts index f1ef43e2b70b28..261de08711a73e 100644 --- a/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts @@ -9,7 +9,7 @@ import type { IConfigService } from '@kbn/config'; import type { Logger, LoggerFactory } from '@kbn/logging'; import { ServerStart } from '../server'; -import { createStatusRoute } from './routes'; +import { createRootRoute } from './routes'; interface KibanaServiceStartDependencies { server: ServerStart; @@ -33,7 +33,7 @@ export class KibanaService { } async start({ server }: KibanaServiceStartDependencies) { - server.addRoute(createStatusRoute({ config: this.config, log: this.log })); + server.addRoute(createRootRoute({ config: this.config, log: this.log })); } stop() { diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/index.ts b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts index f7fcbda3c6d6e8..e5e078cf3c3964 100644 --- a/packages/kbn-health-gateway-server/src/kibana/routes/index.ts +++ b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { createStatusRoute } from './status'; +export { createRootRoute } from './root'; diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/status.ts b/packages/kbn-health-gateway-server/src/kibana/routes/root.ts similarity index 68% rename from packages/kbn-health-gateway-server/src/kibana/routes/status.ts rename to packages/kbn-health-gateway-server/src/kibana/routes/root.ts index 1ad66107013e38..f8f73bf92b78cf 100644 --- a/packages/kbn-health-gateway-server/src/kibana/routes/status.ts +++ b/packages/kbn-health-gateway-server/src/kibana/routes/root.ts @@ -9,7 +9,7 @@ import https from 'https'; import { URL } from 'url'; import type { Request, ResponseToolkit } from '@hapi/hapi'; -import nodeFetch, { RequestInit, Response } from 'node-fetch'; +import nodeFetch, { Headers, RequestInit, Response } from 'node-fetch'; import type { IConfigService } from '@kbn/config'; import type { Logger } from '@kbn/logging'; import type { KibanaConfigType } from '../kibana_config'; @@ -17,32 +17,32 @@ import { KibanaConfig } from '../kibana_config'; const HTTPS = 'https:'; -const GATEWAY_STATUS_ROUTE = '/api/status'; -const KIBANA_STATUS_ROUTE = '/api/status'; +const GATEWAY_ROOT_ROUTE = '/'; +const KIBANA_ROOT_ROUTE = '/'; -interface StatusRouteDependencies { +interface RootRouteDependencies { log: Logger; config: IConfigService; } type Fetch = (path: string) => Promise; -export function createStatusRoute({ config, log }: StatusRouteDependencies) { +export function createRootRoute({ config, log }: RootRouteDependencies) { const kibanaConfig = new KibanaConfig(config.atPathSync('kibana')); const fetch = configureFetch(kibanaConfig); return { method: 'GET', - path: GATEWAY_STATUS_ROUTE, + path: GATEWAY_ROOT_ROUTE, handler: async (req: Request, h: ResponseToolkit) => { - const responses = await fetchKibanaStatuses({ fetch, kibanaConfig, log }); - const { body, statusCode } = mergeStatusResponses(responses); + const responses = await fetchKibanaRoots({ fetch, kibanaConfig, log }); + const { body, statusCode } = mergeResponses(responses); return h.response(body).type('application/json').code(statusCode); }, }; } -async function fetchKibanaStatuses({ +async function fetchKibanaRoots({ fetch, kibanaConfig, log, @@ -53,19 +53,18 @@ async function fetchKibanaStatuses({ }) { const requests = await Promise.allSettled( kibanaConfig.hosts.map(async (host) => { - log.debug(`Fetching response from ${host}${KIBANA_STATUS_ROUTE}`); - const response = fetch(`${host}${KIBANA_STATUS_ROUTE}`).then((res) => res.json()); - return response; + log.debug(`Fetching response from ${host}${KIBANA_ROOT_ROUTE}`); + return fetch(`${host}${KIBANA_ROOT_ROUTE}`); }) ); return requests.map((r, i) => { if (r.status === 'rejected') { - log.error(`Unable to retrieve status from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}`); + log.error(`No response from ${kibanaConfig.hosts[i]}${KIBANA_ROOT_ROUTE}`); } else { log.info( - `Got response from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}: ${JSON.stringify( - r.value.status?.overall ? r.value.status.overall : r.value + `Got response from ${kibanaConfig.hosts[i]}${KIBANA_ROOT_ROUTE}: ${JSON.stringify( + r.value.status )}` ); } @@ -73,22 +72,37 @@ async function fetchKibanaStatuses({ }); } -function mergeStatusResponses( +function mergeResponses( responses: Array | PromiseRejectedResult> ) { let statusCode = 200; for (const response of responses) { - if (response.status === 'rejected') { + if ( + response.status === 'rejected' || + !isHealthyResponse(response.value.status, response.value.headers) + ) { statusCode = 503; } } return { - body: {}, // Need to determine what response body, if any, we want to include + body: {}, // The control plane health check ignores the body, so we do the same statusCode, }; } +function isHealthyResponse(statusCode: number, headers: Headers) { + return isSuccess(statusCode) || isUnauthorized(statusCode, headers); +} + +function isUnauthorized(statusCode: number, headers: Headers): boolean { + return statusCode === 401 && headers.has('www-authenticate'); +} + +function isSuccess(statusCode: number): boolean { + return (statusCode >= 200 && statusCode <= 299) || statusCode === 302; +} + function generateAgentConfig(sslConfig: KibanaConfig['ssl']) { const options: https.AgentOptions = { ca: sslConfig.certificateAuthorities, @@ -133,6 +147,7 @@ function configureFetch(kibanaConfig: KibanaConfig) { const fetchOptions: RequestInit = { ...(protocol === HTTPS && { agent }), signal: controller.signal, + redirect: 'manual', }; try { const response = await nodeFetch(url, fetchOptions); From 46a71979c06cbe9576849ea286edd29387eac507 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 14 Nov 2022 14:37:51 +0100 Subject: [PATCH 03/20] [Cases] Increase default page size cases table and save preferences in url/localStorage (#144228) ## Summary Issues: https://github.com/elastic/kibana/issues/131806 https://github.com/elastic/kibana/issues/140008 * Increase the default table size of the cases table to 10 * Changed the available page sizes to 10, 25, 50 and 100 * Save the visualization preferences of the cases table in localStorage * Display the current visualization preferences of the cases table in the URL * This logic is not applied if the cases table is opened in a modal ### Screenshots Screenshot 2022-10-31 at 12 19 10 ### Checklist Delete any items that are not applicable to this PR. - [ ] [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 - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- Fixes #140008 ## Release notes * Increase the default table size of the cases table to 10 * Save the visualization preferences of the cases table in localStorage * Display the current visualization preferences of the cases table in the URL Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/cases/common/constants.ts | 5 + x-pack/plugins/cases/common/ui/types.ts | 8 + .../cases/public/common/hooks.test.tsx | 6 +- .../public/common/mock/test_providers.tsx | 51 +++--- .../all_cases/all_cases_list.test.tsx | 38 ++--- .../components/all_cases/all_cases_list.tsx | 50 ++---- .../use_all_cases_query_params.test.tsx | 155 ++++++++++++++++++ .../all_cases/use_all_cases_query_params.tsx | 127 ++++++++++++++ .../components/all_cases/utils.test.tsx | 71 ++++++++ .../public/components/all_cases/utils.ts | 30 ++++ .../cases/public/containers/constants.ts | 2 +- .../security_solution/public/app/app.tsx | 28 +--- .../security_solution/public/app/routes.tsx | 13 +- .../apps/cases/list_view.ts | 7 +- .../plugins/cases/public/application.tsx | 11 +- 15 files changed, 489 insertions(+), 113 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/utils.ts diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 30c1d7d186de07..3c7a9b49fb96ca 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -185,3 +185,8 @@ export const NO_ASSIGNEES_FILTERING_KEYWORD = 'none'; * Delays */ export const SEARCH_DEBOUNCE_MS = 500; + +/** + * Local storage keys + */ +export const LOCAL_STORAGE_KEYS = { casesFiltering: 'cases.list.filtering' }; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 5fc6b999c3d61b..dc8ccf55c9f5d6 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -97,7 +97,15 @@ export interface QueryParams { sortField: SortFieldCase; sortOrder: 'asc' | 'desc'; } +export type UrlQueryParams = Partial; +export type ParsedUrlQueryParams = Partial> & { + page?: string; + perPage?: string; + [index: string]: string | string[] | undefined | null; +}; + +export type LocalStorageQueryParams = Partial>; export interface FilterOptions { search: string; searchFields: string[]; diff --git a/x-pack/plugins/cases/public/common/hooks.test.tsx b/x-pack/plugins/cases/public/common/hooks.test.tsx index a83932551f3945..80602f23012f53 100644 --- a/x-pack/plugins/cases/public/common/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/hooks.test.tsx @@ -19,10 +19,13 @@ const useApplicationMock = useApplication as jest.Mock; describe('hooks', () => { beforeEach(() => { jest.clearAllMocks(); - useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' }); }); describe('useIsMainApplication', () => { + beforeEach(() => { + useApplicationMock.mockReturnValue({ appId: 'management', appTitle: 'Management' }); + }); + it('returns true if it is the main application', () => { const { result } = renderHook(() => useIsMainApplication(), { wrapper: ({ children }) => {children}, @@ -33,6 +36,7 @@ describe('hooks', () => { it('returns false if it is not the main application', () => { useApplicationMock.mockReturnValue({ appId: 'testAppId', appTitle: 'Test app' }); + const { result } = renderHook(() => useIsMainApplication(), { wrapper: ({ children }) => {children}, }); diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index b8c1b33507c3e2..2a5a75bf7a7898 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -8,6 +8,7 @@ /* eslint-disable no-console */ import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; import { ThemeProvider } from 'styled-components'; @@ -73,17 +74,19 @@ const TestProvidersComponent: React.FC = ({ ({ eui: euiDarkVars, darkMode: true })}> - - {children} - + + + {children} + + @@ -149,18 +152,20 @@ export const createAppMockRenderer = ({ ({ eui: euiDarkVars, darkMode: true })}> - - {children} - + + + {children} + + diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index c252b090226c3b..838ebeea590302 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -23,7 +23,7 @@ import { } from '../../common/mock'; import { useGetCasesMockState, connectorsMock } from '../../containers/mock'; -import { StatusAll } from '../../../common/ui/types'; +import { SortFieldCase, StatusAll } from '../../../common/ui/types'; import { CaseSeverity, CaseStatuses } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; @@ -39,7 +39,7 @@ import { useCreateAttachments } from '../../containers/use_create_attachments'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetCases } from '../../containers/use_get_cases'; +import { useGetCases, DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; @@ -127,6 +127,7 @@ describe('AllCasesListGeneric', () => { useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => false }); mockKibana(); moment.tz.setDefault('UTC'); + window.localStorage.clear(); }); it('should render AllCasesList', async () => { @@ -255,9 +256,7 @@ describe('AllCasesListGeneric', () => { expect(useGetCasesMock).toBeCalledWith( expect.objectContaining({ queryParams: { - page: 1, - perPage: 5, - sortField: 'createdAt', + ...DEFAULT_QUERY_PARAMS, sortOrder: 'asc', }, }) @@ -402,12 +401,7 @@ describe('AllCasesListGeneric', () => { await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( expect.objectContaining({ - queryParams: { - page: 1, - perPage: 5, - sortField: 'closedAt', - sortOrder: 'desc', - }, + queryParams: { ...DEFAULT_QUERY_PARAMS, sortField: SortFieldCase.closedAt }, }) ); }); @@ -421,12 +415,7 @@ describe('AllCasesListGeneric', () => { await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( expect.objectContaining({ - queryParams: { - page: 1, - perPage: 5, - sortField: 'createdAt', - sortOrder: 'desc', - }, + queryParams: DEFAULT_QUERY_PARAMS, }) ); }); @@ -440,12 +429,7 @@ describe('AllCasesListGeneric', () => { await waitFor(() => { expect(useGetCasesMock).toHaveBeenLastCalledWith( expect.objectContaining({ - queryParams: { - page: 1, - perPage: 5, - sortField: 'createdAt', - sortOrder: 'desc', - }, + queryParams: DEFAULT_QUERY_PARAMS, }) ); }); @@ -618,7 +602,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution', 'observability'], }, - queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' }, + queryParams: DEFAULT_QUERY_PARAMS, }); userEvent.click(getByTestId('options-filter-popover-button-Solution')); @@ -644,7 +628,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution'], }, - queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' }, + queryParams: DEFAULT_QUERY_PARAMS, }); userEvent.click( @@ -666,7 +650,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution', 'observability'], }, - queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' }, + queryParams: DEFAULT_QUERY_PARAMS, }); }); @@ -698,7 +682,7 @@ describe('AllCasesListGeneric', () => { assignees: [], owner: ['securitySolution'], }, - queryParams: { page: 1, perPage: 5, sortField: 'createdAt', sortOrder: 'desc' }, + queryParams: DEFAULT_QUERY_PARAMS, }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 42ef26d6ba1ace..d4ba5692a0b728 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -11,12 +11,7 @@ import { EuiProgress } from '@elastic/eui'; import { difference, head, isEmpty } from 'lodash/fp'; import styled, { css } from 'styled-components'; -import type { - Case, - CaseStatusWithAllStatus, - FilterOptions, - QueryParams, -} from '../../../common/ui/types'; +import type { Case, CaseStatusWithAllStatus, FilterOptions } from '../../../common/ui/types'; import { SortFieldCase, StatusAll } from '../../../common/ui/types'; import { CaseStatuses, caseStatuses } from '../../../common/api'; @@ -29,16 +24,12 @@ import { CasesTable } from './table'; import { useCasesContext } from '../cases_context/use_cases_context'; import { CasesMetrics } from './cases_metrics'; import { useGetConnectors } from '../../containers/configure/use_connectors'; -import { - DEFAULT_FILTER_OPTIONS, - DEFAULT_QUERY_PARAMS, - initialData, - useGetCases, -} from '../../containers/use_get_cases'; +import { DEFAULT_FILTER_OPTIONS, initialData, useGetCases } from '../../containers/use_get_cases'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { getAllPermissionsExceptFrom, isReadOnlyPermissions } from '../../utils/permissions'; import { useIsLoadingCases } from './use_is_loading_cases'; +import { useAllCasesQueryParams } from './use_all_cases_query_params'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -69,7 +60,6 @@ export const AllCasesList = React.memo( const isLoading = useIsLoadingCases(); const hasOwner = !!owner.length; - const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); const initialFilterOptions = { ...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: firstAvailableStatus }), @@ -80,7 +70,7 @@ export const AllCasesList = React.memo( ...DEFAULT_FILTER_OPTIONS, ...initialFilterOptions, }); - const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_PARAMS); + const { queryParams, setQueryParams } = useAllCasesQueryParams(isSelectorView); const [selectedCases, setSelectedCases] = useState([]); const { data = initialData, isFetching: isLoadingCases } = useGetCases({ @@ -112,7 +102,10 @@ export const AllCasesList = React.memo( const sorting = useMemo( () => ({ - sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, + sort: { + field: queryParams.sortField, + direction: queryParams.sortOrder, + }, }), [queryParams.sortField, queryParams.sortOrder] ); @@ -150,23 +143,14 @@ export const AllCasesList = React.memo( const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) { - setQueryParams((prevQueryParams) => ({ - ...prevQueryParams, - sortField: SortFieldCase.closedAt, - })); - } else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) { - setQueryParams((prevQueryParams) => ({ - ...prevQueryParams, - sortField: SortFieldCase.createdAt, - })); + setQueryParams({ sortField: SortFieldCase.closedAt }); } else if ( newFilterOptions.status && - newFilterOptions.status === CaseStatuses['in-progress'] + [CaseStatuses.open, CaseStatuses['in-progress'], StatusAll].includes( + newFilterOptions.status + ) ) { - setQueryParams((prevQueryParams) => ({ - ...prevQueryParams, - sortField: SortFieldCase.createdAt, - })); + setQueryParams({ sortField: SortFieldCase.createdAt }); } deselectCases(); @@ -193,7 +177,7 @@ export const AllCasesList = React.memo( : {}), })); }, - [deselectCases, hasOwner, availableSolutions, owner] + [deselectCases, hasOwner, availableSolutions, owner, setQueryParams] ); const { columns } = useCasesColumns({ @@ -208,10 +192,10 @@ export const AllCasesList = React.memo( const pagination = useMemo( () => ({ - pageIndex: (queryParams?.page ?? DEFAULT_QUERY_PARAMS.page) - 1, - pageSize: queryParams?.perPage ?? DEFAULT_QUERY_PARAMS.perPage, + pageIndex: queryParams.page - 1, + pageSize: queryParams.perPage, totalItemCount: data.total ?? 0, - pageSizeOptions: [5, 10, 15, 20, 25], + pageSizeOptions: [10, 25, 50, 100], }), [data, queryParams] ); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.test.tsx new file mode 100644 index 00000000000000..928592c571241e --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks'; + +import { TestProviders } from '../../common/mock'; +import { + useAllCasesQueryParams, + getQueryParamsLocalStorageKey, +} from './use_all_cases_query_params'; +import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; +import { stringify } from 'query-string'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../containers/constants'; + +const LOCAL_STORAGE_DEFAULTS = { + perPage: DEFAULT_QUERY_PARAMS.perPage, + sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, +}; +const URL_DEFAULTS = { + page: DEFAULT_QUERY_PARAMS.page, + perPage: DEFAULT_QUERY_PARAMS.perPage, + sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, +}; + +const mockLocation = { search: '' }; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn().mockImplementation(() => { + return mockLocation; + }), + useHistory: jest.fn().mockReturnValue({ + push: jest.fn(), + location: { + search: '', + }, + }), +})); + +const APP_ID = 'testAppId'; +const LOCALSTORAGE_KEY = getQueryParamsLocalStorageKey(APP_ID); + +describe('useAllCasesQueryParams', () => { + beforeEach(() => { + global.localStorage.clear(); + }); + + it('calls setState with default values on first run', () => { + const { result } = renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toStrictEqual(DEFAULT_QUERY_PARAMS); + }); + + it('updates localstorage with default values on first run', () => { + expect(global.localStorage.getItem(LOCALSTORAGE_KEY)).toStrictEqual(null); + + renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(JSON.parse(global.localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}')).toMatchObject({ + ...LOCAL_STORAGE_DEFAULTS, + }); + }); + + it('calls history.push with default values on first run', () => { + renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(useHistory().push).toHaveBeenCalledWith({ + search: stringify(URL_DEFAULTS), + }); + }); + + it('takes into account existing localStorage values on first run', () => { + const existingLocalStorageValues = { perPage: DEFAULT_TABLE_LIMIT + 10, sortOrder: 'asc' }; + + global.localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(existingLocalStorageValues)); + + const { result } = renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toMatchObject({ + ...LOCAL_STORAGE_DEFAULTS, + ...existingLocalStorageValues, + }); + }); + + it('takes into account existing urlParams on first run', () => { + const nonDefaultUrlParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE + 1, + perPage: DEFAULT_TABLE_LIMIT + 5, + }; + const expectedUrl = { ...URL_DEFAULTS, ...nonDefaultUrlParams }; + + mockLocation.search = stringify(nonDefaultUrlParams); + + renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(useHistory().push).toHaveBeenCalledWith({ + search: stringify(expectedUrl), + }); + }); + + it('preserves other url parameters', () => { + const nonDefaultUrlParams = { + foo: 'bar', + }; + const expectedUrl = { ...URL_DEFAULTS, ...nonDefaultUrlParams }; + + mockLocation.search = stringify(nonDefaultUrlParams); + + renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(useHistory().push).toHaveBeenCalledWith({ + search: stringify(expectedUrl), + }); + }); + + it('urlParams take precedence over localStorage values', () => { + const nonDefaultUrlParams = { + perPage: DEFAULT_TABLE_LIMIT + 5, + }; + + mockLocation.search = stringify(nonDefaultUrlParams); + + global.localStorage.setItem( + LOCALSTORAGE_KEY, + JSON.stringify({ perPage: DEFAULT_TABLE_LIMIT + 10 }) // existingLocalStorageValues + ); + + const { result } = renderHook(() => useAllCasesQueryParams(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.queryParams).toMatchObject({ + ...DEFAULT_QUERY_PARAMS, + ...nonDefaultUrlParams, + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.tsx b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.tsx new file mode 100644 index 00000000000000..fd387023d099a9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_all_cases_query_params.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useRef, useState } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { isEqual } from 'lodash'; + +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { parse, stringify } from 'query-string'; + +import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; +import { parseUrlQueryParams } from './utils'; +import { LOCAL_STORAGE_KEYS } from '../../../common/constants'; + +import type { + LocalStorageQueryParams, + ParsedUrlQueryParams, + QueryParams, + UrlQueryParams, +} from '../../../common/ui/types'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +export const getQueryParamsLocalStorageKey = (appId: string) => { + const filteringKey = LOCAL_STORAGE_KEYS.casesFiltering; + return `${appId}.${filteringKey}`; +}; + +const getQueryParams = ( + params: UrlQueryParams, + queryParams: UrlQueryParams, + urlParams: UrlQueryParams, + localStorageQueryParams?: LocalStorageQueryParams +): QueryParams => { + const result = { ...DEFAULT_QUERY_PARAMS }; + + result.perPage = + params.perPage ?? + urlParams.perPage ?? + localStorageQueryParams?.perPage ?? + DEFAULT_QUERY_PARAMS.perPage; + + result.sortField = params.sortField ?? queryParams.sortField ?? DEFAULT_QUERY_PARAMS.sortField; + + result.sortOrder = + params.sortOrder ?? + urlParams.sortOrder ?? + localStorageQueryParams?.sortOrder ?? + DEFAULT_QUERY_PARAMS.sortOrder; + + result.page = params.page ?? urlParams.page ?? DEFAULT_QUERY_PARAMS.page; + + return result; +}; + +export function useAllCasesQueryParams(isModalView: boolean = false) { + const { appId } = useCasesContext(); + const location = useLocation(); + const history = useHistory(); + const isFirstRenderRef = useRef(true); + + const [queryParams, setQueryParams] = useState({ ...DEFAULT_QUERY_PARAMS }); + + const [localStorageQueryParams, setLocalStorageQueryParams] = + useLocalStorage(getQueryParamsLocalStorageKey(appId)); + + const persistAndUpdateQueryParams = useCallback( + (params) => { + if (isModalView) { + setQueryParams((prevParams) => ({ ...prevParams, ...params })); + return; + } + + const parsedUrlParams: ParsedUrlQueryParams = parse(location.search); + const urlParams: UrlQueryParams = parseUrlQueryParams(parsedUrlParams); + const newQueryParams: QueryParams = getQueryParams( + params, + queryParams, + urlParams, + localStorageQueryParams + ); + const newLocalStorageQueryParams = { + perPage: newQueryParams.perPage, + sortOrder: newQueryParams.sortOrder, + }; + const newUrlParams = { + page: newQueryParams.page, + ...newLocalStorageQueryParams, + }; + + if (!isEqual(newUrlParams, urlParams)) { + try { + history.push({ + ...location, + search: stringify({ ...parsedUrlParams, ...newUrlParams }), + }); + } catch { + // silently fail + } + } + + setLocalStorageQueryParams(newLocalStorageQueryParams); + setQueryParams(newQueryParams); + }, + [ + isModalView, + location, + localStorageQueryParams, + queryParams, + setLocalStorageQueryParams, + history, + ] + ); + + if (isFirstRenderRef.current) { + persistAndUpdateQueryParams(isModalView ? DEFAULT_QUERY_PARAMS : {}); + isFirstRenderRef.current = false; + } + + return { + queryParams, + setQueryParams: persistAndUpdateQueryParams, + }; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/utils.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utils.test.tsx new file mode 100644 index 00000000000000..6606f2e18d735b --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseUrlQueryParams } from './utils'; +import { DEFAULT_QUERY_PARAMS } from '../../containers/use_get_cases'; + +const DEFAULT_STRING_QUERY_PARAMS = { + ...DEFAULT_QUERY_PARAMS, + page: String(DEFAULT_QUERY_PARAMS.page), + perPage: String(DEFAULT_QUERY_PARAMS.perPage), +}; + +describe('utils', () => { + describe('parseUrlQueryParams', () => { + it('valid input is processed correctly', () => { + expect(parseUrlQueryParams(DEFAULT_STRING_QUERY_PARAMS)).toStrictEqual(DEFAULT_QUERY_PARAMS); + }); + + it('empty string value for page/perPage is ignored', () => { + expect( + parseUrlQueryParams({ + ...DEFAULT_STRING_QUERY_PARAMS, + page: '', + perPage: '', + }) + ).toStrictEqual({ + sortField: DEFAULT_QUERY_PARAMS.sortField, + sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, + }); + }); + + it('0 value for page/perPage is ignored', () => { + expect( + parseUrlQueryParams({ + ...DEFAULT_STRING_QUERY_PARAMS, + page: '0', + perPage: '0', + }) + ).toStrictEqual({ + sortField: DEFAULT_QUERY_PARAMS.sortField, + sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, + }); + }); + + it('invalid string values for page/perPage are ignored', () => { + expect( + parseUrlQueryParams({ + ...DEFAULT_STRING_QUERY_PARAMS, + page: 'foo', + perPage: 'bar', + }) + ).toStrictEqual({ + sortField: DEFAULT_QUERY_PARAMS.sortField, + sortOrder: DEFAULT_QUERY_PARAMS.sortOrder, + }); + }); + + it('additional URL parameters are ignored', () => { + expect( + parseUrlQueryParams({ + ...DEFAULT_STRING_QUERY_PARAMS, + foo: 'bar', + }) + ).toStrictEqual(DEFAULT_QUERY_PARAMS); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utils.ts b/x-pack/plugins/cases/public/components/all_cases/utils.ts new file mode 100644 index 00000000000000..4c789e2008b13d --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utils.ts @@ -0,0 +1,30 @@ +/* + * 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 { ParsedUrlQueryParams, UrlQueryParams } from '../../../common/ui/types'; + +export const parseUrlQueryParams = (parsedUrlParams: ParsedUrlQueryParams): UrlQueryParams => { + const urlParams: UrlQueryParams = { + ...(parsedUrlParams.sortField && { sortField: parsedUrlParams.sortField }), + ...(parsedUrlParams.sortOrder && { sortOrder: parsedUrlParams.sortOrder }), + }; + + const intPage = parsedUrlParams.page && parseInt(parsedUrlParams.page, 10); + const intPerPage = parsedUrlParams.perPage && parseInt(parsedUrlParams.perPage, 10); + + // page=0 is deliberately ignored + if (intPage) { + urlParams.page = intPage; + } + + // perPage=0 is deliberately ignored + if (intPerPage) { + urlParams.perPage = intPerPage; + } + + return urlParams; +}; diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index f6b69fbac920a4..b9e7dea71c3f60 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -8,7 +8,7 @@ import type { SingleCaseMetricsFeature } from './types'; export const DEFAULT_TABLE_ACTIVE_PAGE = 1; -export const DEFAULT_TABLE_LIMIT = 5; +export const DEFAULT_TABLE_LIMIT = 10; export const casesQueriesKeys = { all: ['cases'] as const, diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 990c089a35fc1d..b20b8ace7de170 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -17,16 +17,11 @@ import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { ManageUserInfo } from '../detections/components/user_info'; -import { DEFAULT_DARK_MODE, APP_NAME, APP_ID } from '../../common/constants'; +import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; import { ErrorToastDispatcher } from '../common/components/error_toast_dispatcher'; import { MlCapabilitiesProvider } from '../common/components/ml/permissions/ml_capabilities_provider'; import { GlobalToaster, ManageGlobalToaster } from '../common/components/toasters'; -import { - KibanaContextProvider, - useGetUserCasesPermissions, - useKibana, - useUiSetting$, -} from '../common/lib/kibana'; +import { KibanaContextProvider, useKibana, useUiSetting$ } from '../common/lib/kibana'; import type { State } from '../common/store'; import type { StartServices } from '../types'; @@ -54,11 +49,8 @@ const StartAppComponent: FC = ({ const { i18n, application: { capabilities }, - cases, } = useKibana().services; const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); - const userCasesPermissions = useGetUserCasesPermissions(); - const CasesContext = cases.ui.getCasesContext(); return ( @@ -70,15 +62,13 @@ const StartAppComponent: FC = ({ - - - {children} - - + + {children} + diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index 02b69267e31a17..0e8fb5f69cb25e 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -11,11 +11,13 @@ import React, { memo, useEffect } from 'react'; import { Router, Switch } from 'react-router-dom'; import { Route } from '@kbn/kibana-react-plugin/public'; import { useDispatch } from 'react-redux'; - import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public'; -import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; + +import { APP_ID } from '../../common/constants'; import { RouteCapture } from '../common/components/endpoint/route_capture'; +import { useGetUserCasesPermissions, useKibana } from '../common/lib/kibana'; import type { AppAction } from '../common/store/actions'; +import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; import { NotFoundPage } from './404'; import { HomePage } from './home'; @@ -32,6 +34,9 @@ const PageRouterComponent: FC = ({ onAppLeave, setHeaderActionMenu, }) => { + const { cases } = useKibana().services; + const CasesContext = cases.ui.getCasesContext(); + const userCasesPermissions = useGetUserCasesPermissions(); const dispatch = useDispatch<(action: AppAction) => void>(); useEffect(() => { return () => { @@ -50,7 +55,9 @@ const PageRouterComponent: FC = ({ - {children} + + {children} + diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index 983b2fdd04cc84..98a1f8d2507c2a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -376,7 +376,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('pagination', () => { before(async () => { - await cases.api.createNthRandomCases(8); + await cases.api.createNthRandomCases(12); await header.waitUntilLoadingHasFinished(); await cases.casesTable.waitForCasesToBeListed(); }); @@ -388,7 +388,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('paginates cases correctly', async () => { await testSubjects.click('tablePaginationPopoverButton'); - await testSubjects.click('tablePagination-5-rows'); + await testSubjects.click('tablePagination-25-rows'); + await testSubjects.missingOrFail('pagination-button-1'); + await testSubjects.click('tablePaginationPopoverButton'); + await testSubjects.click('tablePagination-10-rows'); await testSubjects.isEnabled('pagination-button-1'); await testSubjects.click('pagination-button-1'); await testSubjects.isEnabled('pagination-button-0'); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/public/application.tsx b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/public/application.tsx index 6a7f16e3e35c3d..8499ce501c23d4 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/public/application.tsx +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/public/application.tsx @@ -16,6 +16,7 @@ import { EuiButton, EuiFlexGroup, } from '@elastic/eui'; +import { Router } from 'react-router-dom'; import { AppMountParameters, CoreStart } from '@kbn/core/public'; import { CasesUiStart } from '@kbn/cases-plugin/public'; import { CommentType } from '@kbn/cases-plugin/common'; @@ -92,7 +93,7 @@ const CasesFixtureAppWithContext: React.FC = (props) => { const CasesFixtureApp: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { const { mountParams, coreStart, pluginsStart } = deps; - const { theme$ } = mountParams; + const { history, theme$ } = mountParams; const { cases } = pluginsStart; const CasesContext = cases.ui.getCasesContext(); @@ -108,9 +109,11 @@ const CasesFixtureApp: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { }} > - - - + + + + + From ea6a270c21ea6abbb63bc84c7ae673120d24beb6 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 14 Nov 2022 15:54:29 +0200 Subject: [PATCH 04/20] Fix "techinical preview" label overlap on the connector's create flyout (#145095) ## Summary Fixes https://github.com/elastic/kibana/issues/144976 ### Checklist Delete any items that are not applicable to this PR. - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/lib/check_action_type_enabled.scss | 7 ------- 1 file changed, 7 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index bb622829e997ab..601f09df1de8bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -13,10 +13,3 @@ .actAccordionActionForm__button { padding: $euiSizeM; } - -.actConnectorsListGrid { - .euiToolTipAnchor, - .euiCard { - height: 100%; - } -} From e580f23b13c3a6b1f0f3d47289afdfcdf7227f99 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 14 Nov 2022 15:00:20 +0100 Subject: [PATCH 05/20] [Synthetics] Added total step duration (#144993) --- .../synthetics/single_metric_config.ts | 1 + .../shared/exploratory_view/types.ts | 2 +- .../components/step_metrics.tsx | 27 +++++++++++++------ .../hooks/use_step_metrics.ts | 9 +++++-- .../hooks/use_step_prev_metrics.ts | 5 ++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts index 1ad3410ca344e9..a8f1e56b49c4fe 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/single_metric_config.ts @@ -91,6 +91,7 @@ export function getSyntheticsSingleMetricConfig({ dataView }: ConfigProps): Seri }), metricStateOptions: { titlePosition: 'bottom', + textAlign: 'center', }, }, { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 5094088b436fcd..a193862b25238a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -69,7 +69,7 @@ export interface MetricOption { timeScale?: string; showPercentileAnnotations?: boolean; formula?: string; - metricStateOptions?: Pick; + metricStateOptions?: Pick; palette?: PaletteOutput; format?: 'percent' | 'number'; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/step_metrics.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/step_metrics.tsx index cacf96ecb54f80..3845fdf2def59a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/step_metrics.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/components/step_metrics.tsx @@ -14,6 +14,7 @@ import { EuiTitle, EuiIcon, EuiIconTip, + EuiFlexGrid, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CLS_HELP_LABEL, DCL_TOOLTIP, FCP_TOOLTIP, LCP_HELP_LABEL } from './labels'; @@ -31,7 +32,7 @@ export const formatMillisecond = (ms: number) => { export const StepMetrics = () => { const stepMetrics = useStepMetrics(); - const { fcpThreshold, lcpThreshold, clsThreshold, dclThreshold } = + const { fcpThreshold, lcpThreshold, clsThreshold, dclThreshold, totalThreshold } = useStepPrevMetrics(stepMetrics); return ( @@ -48,7 +49,14 @@ export const StepMetrics = () => { - + + + + { helpText={CLS_HELP_LABEL} /> - - - { - + ); }; @@ -106,7 +112,7 @@ const StatThreshold = ({ threshold: number; title: number | string; description: string; - helpText: string; + helpText?: string; }) => { const isUp = threshold >= 5; const isDown = threshold < 5; @@ -116,10 +122,11 @@ const StatThreshold = ({ - {description} + {description} {helpText && } } title={ @@ -144,3 +151,7 @@ const StatThreshold = ({ const METRICS_LABEL = i18n.translate('xpack.synthetics.stepDetailsRoute.metrics', { defaultMessage: 'Metrics', }); + +const TOTAL_DURATION_LABEL = i18n.translate('xpack.synthetics.totalDuration.metrics', { + defaultMessage: 'Step duration', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_metrics.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_metrics.ts index ab82850723ab84..3db64c5113305e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_metrics.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_metrics.ts @@ -32,8 +32,8 @@ export const useStepMetrics = (loadData = true, prevCheckGroupId?: string) => { bool: { filter: [ { - term: { - 'synthetics.type': 'step/metrics', + terms: { + 'synthetics.type': ['step/metrics', 'step/end'], }, }, ...useStepFilters(prevCheckGroupId), @@ -61,6 +61,11 @@ export const useStepMetrics = (loadData = true, prevCheckGroupId?: string) => { field: SYNTHETICS_DCL, }, }, + totalDuration: { + sum: { + field: SYNTHETICS_STEP_DURATION, + }, + }, }, }, }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_prev_metrics.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_prev_metrics.ts index 28ea74c22ae4b6..bf8f1e14259de0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_prev_metrics.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/step_details_page/hooks/use_step_prev_metrics.ts @@ -31,12 +31,17 @@ export const useStepPrevMetrics = (stepMetrics: StepMetrics) => { const lcpThreshold = findThreshold(stepMetrics?.lcp?.value, prevMetrics?.lcp?.value); const clsThreshold = findThreshold(stepMetrics?.cls?.value, prevMetrics?.cls?.value); const dclThreshold = findThreshold(stepMetrics?.dcl?.value, prevMetrics?.dcl?.value); + const totalThreshold = findThreshold( + stepMetrics?.totalDuration?.value, + prevMetrics?.totalDuration?.value + ); return { fcpThreshold, lcpThreshold, clsThreshold, dclThreshold, + totalThreshold, }; }; From a5cfe8ec0df497955c6f5ba77e3b8ce5530f36f9 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 14 Nov 2022 16:14:21 +0200 Subject: [PATCH 06/20] [Lens] Hide the random sampling settings from the UI (#145071) ## Summary Hides the random sampling layer settings from the UI until we decide how we want to introduce it to our users. --- .../editor_frame/config_panel/layer_panel.tsx | 22 ++++++++++++------- .../apps/lens/group1/layer_actions.ts | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 6d95002bf9079c..656daef46fccdd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -52,6 +52,9 @@ const initialActiveDimensionState = { isNew: false, }; +// hide the random sampling settings from the UI +const DISPLAY_RANDOM_SAMPLING_SETTINGS = false; + export function LayerPanel( props: Exclude & { activeVisualization: Visualization; @@ -323,12 +326,14 @@ export function LayerPanel( updateVisualization, () => setPanelSettingsOpen(true) ) || []), - ...(layerDatasource?.getSupportedActionsForLayer?.( - layerId, - layerDatasourceState, - (newState) => updateDatasource(datasourceId, newState), - () => setPanelSettingsOpen(true) - ) || []), + ...((DISPLAY_RANDOM_SAMPLING_SETTINGS && + layerDatasource?.getSupportedActionsForLayer?.( + layerId, + layerDatasourceState, + (newState) => updateDatasource(datasourceId, newState), + () => setPanelSettingsOpen(true) + )) || + []), ...getSharedActions({ activeVisualization, core, @@ -639,7 +644,8 @@ export function LayerPanel( })} - {(layerDatasource?.renderLayerSettings || activeVisualization?.renderLayerSettings) && ( + {((DISPLAY_RANDOM_SAMPLING_SETTINGS && layerDatasource?.renderLayerSettings) || + activeVisualization?.renderLayerSettings) && ( (settingsPanelRef.current = el)} isOpen={isPanelSettingsOpen} @@ -655,7 +661,7 @@ export function LayerPanel( >
- {layerDatasource?.renderLayerSettings && ( + {DISPLAY_RANDOM_SAMPLING_SETTINGS && layerDatasource?.renderLayerSettings && ( { + // skip random sampling FTs until we figure out next steps + describe.skip('lens layer actions tests', () => { it('should allow creation of lens xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From 296769a314163d339108788bbf6361891b2ecf6f Mon Sep 17 00:00:00 2001 From: Boris Kirov Date: Mon, 14 Nov 2022 15:22:30 +0100 Subject: [PATCH 07/20] [APM] Small design uplifts of the Mobile APM overview page (#144998) ## Summary closes https://github.com/elastic/kibana/issues/144455 In this PR we've updated some of the initial overview experience for Mobile APM services by: - rearranging the panels on the Overview page for improved mobile and APM experience - fitting all the Most used widgets into one panel, and rearranging them - adding a callout for feedback - adding a technical preview badge - update the Embeddable component visual and size ![image](https://user-images.githubusercontent.com/13353203/201143633-20b8adb4-e342-4d7f-8e87-d7b3f7e10121.png) Related links: https://github.com/elastic/apm-dev/issues/823 https://github.com/elastic/kibana/issues/143498 https://github.com/elastic/kibana/issues/143501 https://github.com/elastic/kibana/issues/143504 Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kate Patticha --- .../service_overview_charts/filters/index.tsx | 6 +- .../most_used_chart.test.tsx.snap | 4 +- .../most_used_chart/get_lens_attributes.ts | 4 +- .../most_used_chart/index.tsx | 9 +- .../service_oveview_mobile_charts.tsx | 216 +++++++++++------- .../templates/apm_service_template/index.tsx | 7 + 6 files changed, 161 insertions(+), 85 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/filters/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/filters/index.tsx index 22a1544d2013f4..c74de9051224c7 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/filters/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/filters/index.tsx @@ -72,7 +72,11 @@ export function MobileFilters({ {data.mobileFilters.map((filter) => { return ( - + + - +

{title}

+
- + )} + + + + + +

+ + {i18n.translate( + 'xpack.apm.serviceOverview.mobileCallOutLink', + { + defaultMessage: 'Give feedback', + } + )} + + ), + }} + /> +

+
+ +
+ + + + + + + + + + + + +

+ {i18n.translate('xpack.apm.serviceOverview.mostUsedTitle', { + defaultMessage: 'Most used', + })} +

+
+
+ + {/* Device */} + + + + {/* NCT */} + + + + + + {/* OS version */} + + + + {/* App version */} + + + + +
+
+
+
@@ -156,82 +292,6 @@ export function ServiceOverviewMobileCharts({
- - - - - - - - - - {/* Device */} - - - - {/* NCT */} - - - - - - - - {/* OS Version */} - - - - {/* App version */} - - - - - ); } diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index ff978bebe8fb9b..58d97d79163843 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -84,6 +84,8 @@ function TemplateWithContext({ const tabs = useTabs({ selectedTab }); + const { agentName } = useApmServiceContext(); + useBreadcrumb( () => ({ title, @@ -117,6 +119,11 @@ function TemplateWithContext({ end={end} /> + {isMobileAgentName(agentName) && ( + + + + )} From c4aca1fc656792e0e1d62442e39d01efe4a2fc02 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 14 Nov 2022 15:28:43 +0100 Subject: [PATCH 08/20] [Synthetics] Test run details step tabs (#144966) --- .../journey_step_image_popover.tsx | 20 ++- .../common/screenshot/empty_image.tsx | 20 ++- .../journey_step_screenshot_container.tsx | 5 +- .../hooks/use_selected_location.tsx | 2 +- .../monitor_summary/last_test_run.tsx | 7 +- .../monitor_summary/monitor_details_panel.tsx | 5 +- .../monitor_summary/test_runs_table.tsx | 7 +- .../overview/monitor_detail_flyout.tsx | 4 +- .../components/step_number_nav.tsx | 82 +++++++++++ .../step_screenshot_details.tsx | 41 ++++++ .../components/test_run_details/step_tabs.tsx | 131 ++++++++++++++++++ .../test_run_details/test_run_details.tsx | 49 ++++++- .../apps/synthetics/hooks/use_url_params.ts | 14 +- 13 files changed, 351 insertions(+), 36 deletions(-) create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/components/step_number_nav.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_screenshot_details.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_tabs.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx index dda8c5343eb085..1e66d32578ad2e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/journey_step_image_popover.tsx @@ -9,13 +9,11 @@ import React from 'react'; import { css } from '@emotion/react'; import { EuiImage, EuiPopover, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useRouteMatch } from 'react-router-dom'; import { EmptyImage } from '../screenshot/empty_image'; import { ScreenshotRefImageData } from '../../../../../../common/runtime_types'; import { useCompositeImage } from '../../../hooks/use_composite_image'; import { EmptyThumbnail, thumbnailStyle } from './empty_thumbnail'; -import { STEP_DETAIL_ROUTE } from '../../../../../../common/constants'; const POPOVER_IMG_HEIGHT = 360; const POPOVER_IMG_WIDTH = 640; @@ -26,6 +24,7 @@ interface ScreenshotImageProps { isStepFailed: boolean; isLoading: boolean; asThumbnail?: boolean; + size?: 'm'; } const ScreenshotThumbnail: React.FC = ({ @@ -35,6 +34,7 @@ const ScreenshotThumbnail: React.FC { return imageData ? ( ) : asThumbnail ? ( ) : ( - + ); }; /** @@ -72,6 +72,7 @@ const RecomposedScreenshotImage: React.FC< isStepFailed, isLoading, asThumbnail, + size, }) => { // initially an undefined URL value is passed to the image display, and a loading spinner is rendered. // `useCompositeImage` will call `setImageData` when the image is composited, and the updated `imageData` will display. @@ -85,6 +86,7 @@ const RecomposedScreenshotImage: React.FC< isStepFailed={isStepFailed} isLoading={isLoading} asThumbnail={asThumbnail} + size={size} /> ); }; @@ -98,6 +100,7 @@ export interface StepImagePopoverProps { isStepFailed: boolean; isLoading: boolean; asThumbnail?: boolean; + size?: 'm'; } const JourneyStepImage: React.FC< @@ -115,6 +118,7 @@ const JourneyStepImage: React.FC< isStepFailed, isLoading, asThumbnail = true, + size, }) => { if (imgSrc) { return ( @@ -125,6 +129,7 @@ const JourneyStepImage: React.FC< isStepFailed={isStepFailed} isLoading={isLoading} asThumbnail={asThumbnail} + size={size} /> ); } else if (imgRef) { @@ -138,6 +143,7 @@ const JourneyStepImage: React.FC< isStepFailed={isStepFailed} isLoading={isLoading} asThumbnail={asThumbnail} + size={size} /> ); } @@ -153,6 +159,7 @@ export const JourneyStepImagePopover: React.FC = ({ isStepFailed, isLoading, asThumbnail = true, + size, }) => { const { euiTheme } = useEuiTheme(); @@ -172,9 +179,7 @@ export const JourneyStepImagePopover: React.FC = ({ const isImageLoading = isLoading || (!!imgRef && !imageData); - const isStepDetailPage = useRouteMatch(STEP_DETAIL_ROUTE)?.isExact; - - const thumbnailS = isStepDetailPage ? null : thumbnailStyle; + const thumbnailS = asThumbnail ? thumbnailStyle : null; return ( = ({ isStepFailed={isStepFailed} isLoading={isImageLoading} asThumbnail={asThumbnail} + size={size} /> } isOpen={isImagePopoverOpen} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/screenshot/empty_image.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/screenshot/empty_image.tsx index 57295b3e1daf23..d3c22311099271 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/screenshot/empty_image.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/screenshot/empty_image.tsx @@ -19,6 +19,9 @@ import { export const IMAGE_WIDTH = 360; export const IMAGE_HEIGHT = 203; +export const IMAGE_WIDTH_M = 200; +export const IMAGE_HEIGHT_M = 114; + export const imageStyle = css` padding: 0; margin: auto; @@ -31,17 +34,12 @@ export const imageStyle = css` justify-content: center; `; -export const EmptyImage = ({ - isLoading = false, - width = IMAGE_WIDTH, - height = IMAGE_HEIGHT, -}: { - isLoading: boolean; - width?: number; - height?: number; -}) => { +export const EmptyImage = ({ size, isLoading = false }: { isLoading: boolean; size?: 'm' }) => { const { euiTheme } = useEuiTheme(); + const imgWidth = size === 'm' ? IMAGE_WIDTH_M : IMAGE_WIDTH; + const imgHeight = size === 'm' ? IMAGE_HEIGHT_M : IMAGE_HEIGHT; + return (
{ const [stepNumber, setStepNumber] = useState(initialStepNo); const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); @@ -139,11 +141,12 @@ export const JourneyStepScreenshotContainer = ({ isStepFailed={stepStatus === 'failed'} isLoading={Boolean(loading)} asThumbnail={asThumbnail} + size={size} /> ) : asThumbnail ? ( ) : ( - + )}
); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx index 779b8d8001ad55..30a522147cf4e6 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_location.tsx @@ -22,7 +22,7 @@ export const useSelectedLocation = () => { if (!urlLocationId) { const firstLocationId = locations?.[0]?.id; if (firstLocationId) { - updateUrlParams({ locationId: firstLocationId }); + updateUrlParams({ locationId: firstLocationId }, true); } } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx index 9fd35bc60cd0d9..c2e8e9691f1eb1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_test_run.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useParams } from 'react-router-dom'; import { ConfigKey, DataStream, @@ -102,6 +103,8 @@ const PanelHeader = ({ const { basePath } = useSyntheticsSettingsContext(); + const { monitorId } = useParams<{ monitorId: string }>(); + const format = useKibanaDateFormat(); const lastRunTimestamp = useMemo( @@ -160,9 +163,7 @@ const PanelHeader = ({ size="xs" iconType="inspect" iconSide="left" - href={`${basePath}/app/uptime/journey/${ - latestPing?.monitor?.check_group ?? '' - }/steps`} + href={`${basePath}/app/synthetics/monitor/${monitorId}/test-run/${latestPing?.monitor.check_group}`} > {i18n.translate('xpack.synthetics.monitorDetails.summary.viewTestRun', { defaultMessage: 'View test run', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx index 80db9f667f5d7c..fb12c51f7eb537 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_details_panel.tsx @@ -23,6 +23,7 @@ import { capitalize } from 'lodash'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { frequencyStr } from '../../monitors_page/overview/overview/monitor_detail_flyout'; import { useSelectedMonitor } from '../hooks/use_selected_monitor'; import { MonitorTags } from './monitor_tags'; import { MonitorEnabled } from '../../monitors_page/management/monitor_list_table/monitor_enabled'; @@ -81,7 +82,9 @@ export const MonitorDetailsPanel = () => { {capitalize(monitor?.type)} {FREQUENCY_LABEL} - Every 10 mins + + {monitor && frequencyStr(monitor[ConfigKey.SCHEDULE])} + {LOCATIONS_LABEL} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx index 6bb819be8d4484..19a7ebb33d7dbe 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/test_runs_table.tsx @@ -147,9 +147,12 @@ export const TestRunsTable = ({ paginable = true, from, to }: TestRunsTableProps 'data-test-subj': `row-${item.monitor.check_group}`, onClick: (evt: MouseEvent) => { const targetElem = evt.target as HTMLElement; - // we dont want to capture image click event - if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'path') { + if ( + targetElem.tagName !== 'IMG' && + targetElem.tagName !== 'path' && + !targetElem.parentElement?.classList.contains('euiLink') + ) { history.push(`/monitor/${monitorId}/test-run/${item.monitor.check_group}`); } }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx index b448f98c3cfd5c..2904e4a9f858ee 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx @@ -388,7 +388,7 @@ export function MonitorDetailFlyout(props: Props) { }, { title: FREQUENCY_HEADER_TEXT, - description: freqeuncyStr(monitorSavedObject?.attributes[ConfigKey.SCHEDULE]), + description: frequencyStr(monitorSavedObject?.attributes[ConfigKey.SCHEDULE]), }, monitorSavedObject?.attributes[ConfigKey.TAGS] && monitorSavedObject?.attributes[ConfigKey.TAGS].length @@ -439,7 +439,7 @@ export function MonitorDetailFlyout(props: Props) { ); } -function freqeuncyStr(frequency: { number: string; unit: string }) { +export function frequencyStr(frequency: { number: string; unit: string }) { return translateUnitMessage( `${frequency.number} ${unitToString(frequency.unit, parseInt(frequency.number, 10))}` ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/components/step_number_nav.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/components/step_number_nav.tsx new file mode 100644 index 00000000000000..254b1e62c96bff --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/components/step_number_nav.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface Props { + stepIndex: number; + totalSteps: number; + handlePreviousStep: () => void; + handleNextStep: () => void; +} + +export const StepNumberNav = ({ + stepIndex, + totalSteps, + handleNextStep, + handlePreviousStep, +}: Props) => { + const hasPreviousStep = stepIndex > 1; + const hasNextStep = stepIndex < totalSteps; + + return ( + + + + + + {PREVIOUS_STEP_BUTTON_TEXT} + + + + + + + + + + {NEXT_STEP_BUTTON_TEXT} + + + + + + ); +}; + +export const PREVIOUS_STEP_BUTTON_TEXT = i18n.translate( + 'xpack.synthetics.synthetics.stepDetail.previousStepButtonText', + { + defaultMessage: 'Previous', + } +); + +export const NEXT_STEP_BUTTON_TEXT = i18n.translate( + 'xpack.synthetics.synthetics.stepDetail.nextStepButtonText', + { + defaultMessage: 'Next', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_screenshot_details.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_screenshot_details.tsx new file mode 100644 index 00000000000000..e5851191601d3c --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_screenshot_details.tsx @@ -0,0 +1,41 @@ +/* + * 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, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import { useTheme } from '@kbn/observability-plugin/public'; +import { useParams } from 'react-router-dom'; +import { JourneyStepScreenshotContainer } from '../common/screenshot/journey_step_screenshot_container'; + +export const StepScreenshotDetails = ({ stepIndex }: { stepIndex: number }) => { + const { checkGroupId } = useParams<{ checkGroupId: string }>(); + + const theme = useTheme(); + return ( + + + + + + {/* TODO: add image details*/} + + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_tabs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_tabs.tsx new file mode 100644 index 00000000000000..b7f884a7eee96d --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/step_tabs.tsx @@ -0,0 +1,131 @@ +/* + * 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 { EuiCodeBlock, EuiLoadingContent, EuiTab, EuiTabs } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { JourneyStep, SyntheticsJourneyApiResponse } from '../../../../../common/runtime_types'; + +type TabId = 'code' | 'console' | 'stackTrace'; + +export const StepTabs = ({ + stepsData, + step, + loading, +}: { + stepsData?: SyntheticsJourneyApiResponse; + step?: JourneyStep; + loading: boolean; +}) => { + let tabs: Array<{ id: TabId; name: string }> = [ + { + id: 'code', + name: CODE_EXECUTED, + }, + { + id: 'console', + name: CONSOLE_LABEL, + }, + ]; + + const isFailedStep = step?.synthetics.step?.status === 'failed'; + + if (isFailedStep) { + tabs = [ + { + id: 'stackTrace', + name: STACKTRACE_LABEL, + }, + ...tabs, + ]; + } + + const [selectedTabId, setSelectedTabId] = useState('code'); + + useEffect(() => { + if (isFailedStep) { + setSelectedTabId('stackTrace'); + } else { + setSelectedTabId('code'); + } + }, [isFailedStep]); + + const onSelectedTabChanged = (id: TabId) => { + setSelectedTabId(id); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + > + {tab.name} + + )); + }; + + const renderTabContent = () => { + if (loading) { + return ; + } + switch (selectedTabId) { + case 'code': + return ( + + {step?.synthetics?.payload?.source} + + ); + case 'console': + return ( + + {step?.synthetics?.error?.stack} + + ); + + default: + return ( + + {getBrowserConsoles(1)?.join('\n')} + + ); + } + }; + + const getBrowserConsoles = useCallback( + (index: number) => { + return stepsData?.steps + .filter( + (stepF) => + stepF.synthetics?.type === 'journey/browserconsole' && + stepF.synthetics?.step?.index! === index + ) + .map((stepF) => stepF.synthetics?.payload?.text!); + }, + [stepsData?.steps] + ); + + return ( + <> + {renderTabs()} + {renderTabContent()} + + ); +}; + +const CODE_EXECUTED = i18n.translate('xpack.synthetics.testDetails.codeExecuted', { + defaultMessage: 'Code executed', +}); + +const STACKTRACE_LABEL = i18n.translate('xpack.synthetics.testDetails.stackTrace', { + defaultMessage: 'Stacktrace', +}); + +const CONSOLE_LABEL = i18n.translate('xpack.synthetics.testDetails.console', { + defaultMessage: 'Console', +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/test_run_details.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/test_run_details.tsx index 5b3cf62d1b9956..4d109c2240e8e3 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/test_run_details.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/test_run_details/test_run_details.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { StepNumberNav } from './components/step_number_nav'; +import { StepScreenshotDetails } from './step_screenshot_details'; +import { StepTabs } from './step_tabs'; import { MonitorDetailsPanel } from '../monitor_details/monitor_summary/monitor_details_panel'; import { useJourneySteps } from '../monitor_details/hooks/use_journey_steps'; import { StepDurationPanel } from '../monitor_details/monitor_summary/step_duration_panel'; @@ -15,20 +19,55 @@ import { TestRunSteps } from './test_run_steps'; import { useTestRunDetailsBreadcrumbs } from './hooks/use_test_run_details_breadcrumbs'; export const TestRunDetails = () => { - const { data: stepsData, loading: stepsLoading } = useJourneySteps(); + // Step index from starts at 1 in synthetics + const [stepIndex, setStepIndex] = React.useState(1); + + const { data: stepsData, loading: stepsLoading, stepEnds } = useJourneySteps(); useTestRunDetailsBreadcrumbs([ { text: stepsData ? moment(stepsData.details?.timestamp).format('LLL') : '' }, ]); + const step = stepEnds.find((stepN) => stepN.synthetics?.step?.index === stepIndex); + + const totalSteps = stepsLoading ? 1 : stepEnds.length; + return ( - - {/* TODO: Add step detail panel*/} -

Step 1 of {stepsData?.steps.length}

-
+ + + +

+ +

+
+
+ + { + setStepIndex(stepIndex + 1); + }} + handlePreviousStep={() => { + setStepIndex(stepIndex - 1); + }} + /> + +
+ + + +
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_url_params.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_url_params.ts index 572468b805de98..56d547361db2aa 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_url_params.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_url_params.ts @@ -18,7 +18,8 @@ export type GetUrlParams = () => SyntheticsUrlParams; export type UpdateUrlParams = ( updatedParams: { [key: string]: string | number | boolean | undefined; - } | null + } | null, + replaceState?: boolean ) => void; export type SyntheticsUrlParamsHook = () => [GetUrlParams, UpdateUrlParams]; @@ -34,7 +35,7 @@ export const useUrlParams: SyntheticsUrlParamsHook = () => { const history = useHistory(); const updateUrlParams: UpdateUrlParams = useCallback( - (updatedParams, clearAllParams = false) => { + (updatedParams, replaceState = false) => { const currentParams = getParsedParams(search); const mergedParams = { ...currentParams, @@ -60,7 +61,14 @@ export const useUrlParams: SyntheticsUrlParamsHook = () => { // only update the URL if the search has actually changed if (search !== updatedSearch) { - history.push({ pathname, search: updatedSearch || undefined }); + if (replaceState) { + history.replace({ + pathname, + search: updatedSearch || undefined, + }); + } else { + history.push({ pathname, search: updatedSearch || undefined }); + } } }, [history, pathname, search] From e5b27b36bd1790f60400c41eff10cc7785240069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Mon, 14 Nov 2022 09:32:41 -0500 Subject: [PATCH 09/20] Allow task manager health stats to be logged as info messages (#144986) In this PR, I'm adding a new setting (`xpack.task_manager.monitored_stats_health_verbose_log.level`) that allows the task manager monitoring stats to be verbosely logged at info level instead of warning. The two supported values are: - debug (default) - info This will help debug SDHs on Cloud where we won't want to turn on debug level on the entire cluster but would still like to see the task manager monitored stats over time. ## Cloud allow-list PR https://github.com/elastic/cloud/pull/109563 ## To verify 1. Set the following two configuration options: ``` xpack.task_manager.monitored_stats_health_verbose_log.enabled: true xpack.task_manager.monitored_stats_health_verbose_log.level: info ``` 2. Startup Kibana 3. Notice `Latest Monitored Stats:` are logged at info level 4. Remove `xpack.task_manager.monitored_stats_health_verbose_log.level` configuration 5. Add the following configuration ``` logging: loggers: - name: plugins.taskManager level: debug ``` 6. Restart Kibana 7. Notice `Latest Monitored Stats:` are logged at debug level (as usual) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../task_manager/server/config.test.ts | 3 ++ x-pack/plugins/task_manager/server/config.ts | 3 ++ .../server/ephemeral_task_lifecycle.test.ts | 1 + .../managed_configuration.test.ts | 1 + .../server/lib/log_health_metrics.test.ts | 31 +++++++++++++++++++ .../server/lib/log_health_metrics.ts | 7 ++++- .../configuration_statistics.test.ts | 1 + .../monitoring_stats_stream.test.ts | 1 + .../task_manager/server/plugin.test.ts | 1 + .../server/polling_lifecycle.test.ts | 1 + .../task_manager/server/routes/health.test.ts | 3 ++ 11 files changed, 52 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index f5ba0a3bcee0a7..54e9562ec62fb3 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -26,6 +26,7 @@ describe('config validation', () => { "monitored_aggregated_stats_refresh_rate": 60000, "monitored_stats_health_verbose_log": Object { "enabled": false, + "level": "debug", "warn_delayed_task_start_in_seconds": 60, }, "monitored_stats_required_freshness": 4000, @@ -76,6 +77,7 @@ describe('config validation', () => { "monitored_aggregated_stats_refresh_rate": 60000, "monitored_stats_health_verbose_log": Object { "enabled": false, + "level": "debug", "warn_delayed_task_start_in_seconds": 60, }, "monitored_stats_required_freshness": 4000, @@ -124,6 +126,7 @@ describe('config validation', () => { "monitored_aggregated_stats_refresh_rate": 60000, "monitored_stats_health_verbose_log": Object { "enabled": false, + "level": "debug", "warn_delayed_task_start_in_seconds": 60, }, "monitored_stats_required_freshness": 4000, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index f650ed093cee0c..40b915e8979a2d 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -111,6 +111,9 @@ export const configSchema = schema.object( }), monitored_stats_health_verbose_log: schema.object({ enabled: schema.boolean({ defaultValue: false }), + level: schema.oneOf([schema.literal('debug'), schema.literal('info')], { + defaultValue: 'debug', + }), /* The amount of seconds we allow a task to delay before printing a warning server log */ warn_delayed_task_start_in_seconds: schema.number({ defaultValue: DEFAULT_MONITORING_STATS_WARN_DELAYED_TASK_START_IN_SECONDS, diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts index f43bc0799ad73c..3ff7341faab4c9 100644 --- a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -52,6 +52,7 @@ describe('EphemeralTaskLifecycle', () => { monitored_stats_running_average_window: 50, monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, monitored_task_execution_thresholds: { diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index aa945c74d71ffe..308aa9f556797f 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -47,6 +47,7 @@ describe('managed configuration', () => { monitored_aggregated_stats_refresh_rate: 60000, monitored_stats_health_verbose_log: { enabled: false, + level: 'debug' as const, warn_delayed_task_start_in_seconds: 60, }, monitored_stats_required_freshness: 4000, diff --git a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts index 08c93f12ecad8e..0b8c0e5f42ccf3 100644 --- a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts +++ b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts @@ -31,6 +31,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: false, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -66,6 +67,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: false, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -88,6 +90,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: false, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -107,6 +110,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -120,11 +124,31 @@ describe('logHealthMetrics', () => { expect(firstDebug).toMatchObject(health); }); + it('should log as info if status is OK and level is info', () => { + const logger = loggingSystemMock.create().get(); + const config = getTaskManagerConfig({ + monitored_stats_health_verbose_log: { + enabled: true, + level: 'info', + warn_delayed_task_start_in_seconds: 60, + }, + }); + const health = getMockMonitoredHealth(); + + logHealthMetrics(health, logger, config, true); + + const firstInfo = JSON.parse( + (logger as jest.Mocked).info.mock.calls[0][0].replace('Latest Monitored Stats: ', '') + ); + expect(firstInfo).toMatchObject(health); + }); + it('should log as debug if status is OK even if not enabled', () => { const logger = loggingSystemMock.create().get(); const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: false, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -143,6 +167,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -168,6 +193,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -191,6 +217,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -234,6 +261,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -277,6 +305,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -301,6 +330,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); @@ -337,6 +367,7 @@ describe('logHealthMetrics', () => { const config = getTaskManagerConfig({ monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 60, }, }); diff --git a/x-pack/plugins/task_manager/server/lib/log_health_metrics.ts b/x-pack/plugins/task_manager/server/lib/log_health_metrics.ts index 55c25f6ce35776..86974c6ff747e4 100644 --- a/x-pack/plugins/task_manager/server/lib/log_health_metrics.ts +++ b/x-pack/plugins/task_manager/server/lib/log_health_metrics.ts @@ -15,6 +15,7 @@ import { MonitoredHealth } from '../routes/health'; import { calculateHealthStatus } from './calculate_health_status'; enum LogLevel { + Info = 'info', Warn = 'warn', Error = 'error', Debug = 'debug', @@ -30,7 +31,8 @@ export function logHealthMetrics( config: TaskManagerConfig, shouldRunTasks: boolean ) { - let logLevel: LogLevel = LogLevel.Debug; + let logLevel: LogLevel = + config.monitored_stats_health_verbose_log.level === 'info' ? LogLevel.Info : LogLevel.Debug; const enabled = config.monitored_stats_health_verbose_log.enabled; const healthWithoutCapacity: MonitoredHealth = { ...monitoredHealth, @@ -82,6 +84,9 @@ export function logHealthMetrics( logLevel = LogLevel.Warn; } switch (logLevel) { + case LogLevel.Info: + logger.info(message); + break; case LogLevel.Warn: logger.warn(message); break; diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 776f5bc9388f7b..764b7aa15335b6 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -23,6 +23,7 @@ describe('Configuration Statistics Aggregator', () => { monitored_aggregated_stats_refresh_rate: 5000, monitored_stats_health_verbose_log: { enabled: false, + level: 'debug' as const, warn_delayed_task_start_in_seconds: 60, }, monitored_stats_running_average_window: 50, diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index a6ef665966ddd7..610e0bf080b051 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -27,6 +27,7 @@ describe('createMonitoringStatsStream', () => { monitored_aggregated_stats_refresh_rate: 5000, monitored_stats_health_verbose_log: { enabled: false, + level: 'debug' as const, warn_delayed_task_start_in_seconds: 60, }, monitored_stats_running_average_window: 50, diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 93e44c4000750b..ad02e6d7490f5d 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -47,6 +47,7 @@ const pluginInitializerContextParams = { monitored_aggregated_stats_refresh_rate: 5000, monitored_stats_health_verbose_log: { enabled: false, + level: 'debug' as const, warn_delayed_task_start_in_seconds: 60, }, monitored_stats_required_freshness: 5000, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index 8e89d929c1a021..f4616b2f8c2056 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -48,6 +48,7 @@ describe('TaskPollingLifecycle', () => { monitored_aggregated_stats_refresh_rate: 5000, monitored_stats_health_verbose_log: { enabled: false, + level: 'debug' as const, warn_delayed_task_start_in_seconds: 60, }, monitored_stats_required_freshness: 5000, diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index f2d4dae1532a4c..f9ad895d0f5da3 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -208,6 +208,7 @@ describe('healthRoute', () => { monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 100, }, monitored_aggregated_stats_refresh_rate: 60000, @@ -267,6 +268,7 @@ describe('healthRoute', () => { monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 120, }, monitored_aggregated_stats_refresh_rate: 60000, @@ -344,6 +346,7 @@ describe('healthRoute', () => { monitored_stats_required_freshness: 1000, monitored_stats_health_verbose_log: { enabled: true, + level: 'debug', warn_delayed_task_start_in_seconds: 120, }, monitored_aggregated_stats_refresh_rate: 60000, From fa69b424bcc3fe8a2a78c66288a2b24d75051cba Mon Sep 17 00:00:00 2001 From: Chenhui Wang <54903978+wangch079@users.noreply.github.com> Date: Mon, 14 Nov 2022 22:37:03 +0800 Subject: [PATCH 10/20] Update job index mapping (#144777) ## Summary Part of https://github.com/elastic/enterprise-search-team/issues/3193 Part of https://github.com/elastic/enterprise-search-team/issues/3283 The changes in this PR: 1. adds metadata to `.elastic-connectors-sync-jobs` index mapping. 2. groups connector data under key `connector` Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../index_management/setup_indices.test.ts | 76 ++++++++++++------- .../server/index_management/setup_indices.ts | 74 ++++++++++++------ 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts index 49a17f5e0d6c28..e312bada49e413 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts @@ -154,47 +154,69 @@ describe('Setup Indices', () => { version: CONNECTORS_VERSION, }, properties: { + cancelation_requested_at: { type: 'date' }, + canceled_at: { type: 'date' }, completed_at: { type: 'date' }, - connector_id: { - type: 'keyword', - }, - created_at: { type: 'date' }, - deleted_document_count: { type: 'integer' }, - error: { type: 'keyword' }, - filtering: { + connector: { properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - domain: { type: 'keyword' }, - rules: { + configuration: { type: 'object' }, + filtering: { properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, + advanced_snippet: { + properties: { + created_at: { type: 'date' }, + updated_at: { type: 'date' }, + value: { type: 'object' }, + }, + }, + domain: { type: 'keyword' }, + rules: { + properties: { + created_at: { type: 'date' }, + field: { type: 'keyword' }, + id: { type: 'keyword' }, + order: { type: 'short' }, + policy: { type: 'keyword' }, + rule: { type: 'keyword' }, + updated_at: { type: 'date' }, + value: { type: 'keyword' }, + }, + }, + warnings: { + properties: { + ids: { type: 'keyword' }, + messages: { type: 'text' }, + }, + }, }, }, - warnings: { + id: { type: 'keyword' }, + index_name: { type: 'keyword' }, + language: { type: 'keyword' }, + pipeline: { properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, + extract_binary_content: { type: 'boolean' }, + name: { type: 'keyword' }, + reduce_whitespace: { type: 'boolean' }, + run_ml_inference: { type: 'boolean' }, }, }, + service_type: { type: 'keyword' }, }, }, + created_at: { type: 'date' }, + deleted_document_count: { type: 'integer' }, + error: { type: 'keyword' }, indexed_document_count: { type: 'integer' }, + indexed_document_volume: { type: 'integer' }, + last_seen: { type: 'date' }, + metadata: { type: 'object' }, + started_at: { type: 'date' }, status: { type: 'keyword', }, + total_document_count: { type: 'integer' }, + trigger_method: { type: 'keyword' }, worker_hostname: { type: 'keyword' }, }, }; diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts index a6142459ca093b..36757667193a31 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts @@ -180,43 +180,67 @@ const indices: IndexDefinition[] = [ version: 1, }, properties: { + cancelation_requested_at: { type: 'date' }, + canceled_at: { type: 'date' }, completed_at: { type: 'date' }, - connector_id: { type: 'keyword' }, - created_at: { type: 'date' }, - deleted_document_count: { type: 'integer' }, - error: { type: 'keyword' }, - filtering: { + connector: { properties: { - advanced_snippet: { - properties: { - created_at: { type: 'date' }, - updated_at: { type: 'date' }, - value: { type: 'object' }, - }, - }, - domain: { type: 'keyword' }, - rules: { + configuration: { type: 'object' }, + filtering: { properties: { - created_at: { type: 'date' }, - field: { type: 'keyword' }, - id: { type: 'keyword' }, - order: { type: 'short' }, - policy: { type: 'keyword' }, - rule: { type: 'keyword' }, - updated_at: { type: 'date' }, - value: { type: 'keyword' }, + advanced_snippet: { + properties: { + created_at: { type: 'date' }, + updated_at: { type: 'date' }, + value: { type: 'object' }, + }, + }, + domain: { type: 'keyword' }, + rules: { + properties: { + created_at: { type: 'date' }, + field: { type: 'keyword' }, + id: { type: 'keyword' }, + order: { type: 'short' }, + policy: { type: 'keyword' }, + rule: { type: 'keyword' }, + updated_at: { type: 'date' }, + value: { type: 'keyword' }, + }, + }, + warnings: { + properties: { + ids: { type: 'keyword' }, + messages: { type: 'text' }, + }, + }, }, }, - warnings: { + id: { type: 'keyword' }, + index_name: { type: 'keyword' }, + language: { type: 'keyword' }, + pipeline: { properties: { - ids: { type: 'keyword' }, - messages: { type: 'text' }, + extract_binary_content: { type: 'boolean' }, + name: { type: 'keyword' }, + reduce_whitespace: { type: 'boolean' }, + run_ml_inference: { type: 'boolean' }, }, }, + service_type: { type: 'keyword' }, }, }, + created_at: { type: 'date' }, + deleted_document_count: { type: 'integer' }, + error: { type: 'keyword' }, indexed_document_count: { type: 'integer' }, + indexed_document_volume: { type: 'integer' }, + last_seen: { type: 'date' }, + metadata: { type: 'object' }, + started_at: { type: 'date' }, status: { type: 'keyword' }, + total_document_count: { type: 'integer' }, + trigger_method: { type: 'keyword' }, worker_hostname: { type: 'keyword' }, }, }, From 9c27f3d798e7f5f32bbe3c1648f24177d4c7663b Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 14 Nov 2022 15:40:49 +0100 Subject: [PATCH 11/20] [APM] Agent explorer (PoC) (#143844) Closes [142218](https://github.com/elastic/kibana/issues/142218) - Introducing the Agent explorer view https://user-images.githubusercontent.com/1313018/198403801-bd9aab9c-1f7e-4775-b3ed-e0e488eef513.mov Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/lib/apm/apm_fields.ts | 1 + .../src/scenarios/many_services.ts | 41 +++- .../server/collectors/management/schema.ts | 4 + .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 6 + x-pack/plugins/apm/common/agent_explorer.ts | 16 ++ x-pack/plugins/apm/common/agent_name.ts | 3 + .../agent_explorer_docs_link/index.tsx | 60 +++++ .../agent_contextual_information/index.tsx | 121 ++++++++++ .../agent_instances_details/index.tsx | 217 ++++++++++++++++++ .../agent_explorer/agent_instances/index.tsx | 113 +++++++++ .../agent_explorer/agent_list/index.tsx | 210 +++++++++++++++++ .../app/settings/agent_explorer/index.tsx | 196 ++++++++++++++++ .../components/routing/settings/index.tsx | 42 +++- .../routing/templates/settings_template.tsx | 41 +++- .../shared/environment_badge/index.tsx | 35 +-- .../components/shared/item_badge/index.tsx | 47 ++++ .../shared/popover_tooltip/index.tsx | 41 ++++ .../public/hooks/use_default_time_range.ts | 21 ++ .../agent_explorer/get_agent_instances.ts | 108 +++++++++ .../get_agent_url_repository.ts | 47 ++++ .../routes/agent_explorer/get_agents.ts | 50 ++++ .../routes/agent_explorer/get_agents_items.ts | 136 +++++++++++ .../apm/server/routes/agent_explorer/route.ts | 119 ++++++++++ .../get_global_apm_server_route_repository.ts | 10 +- .../get_services/get_services_items.ts | 2 +- x-pack/plugins/observability/common/index.ts | 1 + .../observability/common/ui_settings_keys.ts | 1 + x-pack/plugins/observability/public/index.ts | 1 + .../observability/server/ui_settings.ts | 24 +- .../test/apm_api_integration/common/config.ts | 51 +++- .../agent_explorer/agent_explorer.spec.ts | 191 +++++++++++++++ 32 files changed, 1889 insertions(+), 68 deletions(-) create mode 100644 x-pack/plugins/apm/common/agent_explorer.ts create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_explorer_docs_link/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_contextual_information/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_instances_details/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_list/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/settings/agent_explorer/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/item_badge/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/popover_tooltip/index.tsx create mode 100644 x-pack/plugins/apm/public/hooks/use_default_time_range.ts create mode 100644 x-pack/plugins/apm/server/routes/agent_explorer/get_agent_instances.ts create mode 100644 x-pack/plugins/apm/server/routes/agent_explorer/get_agent_url_repository.ts create mode 100644 x-pack/plugins/apm/server/routes/agent_explorer/get_agents.ts create mode 100644 x-pack/plugins/apm/server/routes/agent_explorer/get_agents_items.ts create mode 100644 x-pack/plugins/apm/server/routes/agent_explorer/route.ts create mode 100644 x-pack/test/apm_api_integration/tests/agent_explorer/agent_explorer.spec.ts diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts index e9b89fa4739d04..1d11dc1d7f3b39 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -81,6 +81,7 @@ export type ApmFields = Fields & 'service.name': string; 'service.version': string; 'service.environment': string; + 'service.language.name': string; 'service.node.name': string; 'service.runtime.name': string; 'service.runtime.version': string; diff --git a/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts b/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts index 1829d46e172324..6ff91c23fb0589 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/many_services.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { random } from 'lodash'; +import { flatten, random } from 'lodash'; import { apm, timerange } from '../..'; -import { Instance } from '../lib/apm/instance'; import { Scenario } from '../cli/scenario'; import { getLogger } from '../cli/utils/get_common_services'; import { RunOptions } from '../cli/utils/parse_run_cli_flags'; import { ApmFields } from '../lib/apm/apm_fields'; +import { Instance } from '../lib/apm/instance'; import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; const ENVIRONMENT = getSynthtraceEnvironment(__filename); @@ -24,6 +24,12 @@ const scenario: Scenario = async (runOptions: RunOptions) => { const numServices = 500; const languages = ['go', 'dotnet', 'java', 'python']; const services = ['web', 'order-processing', 'api-backend', 'proxy']; + const agentVersions: Record = { + go: ['2.1.0', '2.0.0', '1.15.0', '1.14.0', '1.13.1'], + dotnet: ['1.18.0', '1.17.0', '1.16.1', '1.16.0', '1.15.0'], + java: ['1.34.1', '1.34.0', '1.33.0', '1.32.0', '1.32.0'], + python: ['6.12.0', '6.11.0', '6.10.2', '6.10.1', '6.10.0'], + }; return { generate: ({ from, to }) => { @@ -31,16 +37,27 @@ const scenario: Scenario = async (runOptions: RunOptions) => { const successfulTimestamps = range.ratePerMinute(180); - const instances = [...Array(numServices).keys()].map((index) => - apm - .service({ - name: `${services[index % services.length]}-${ - languages[index % languages.length] - }-${index}`, - environment: ENVIRONMENT, - agentName: languages[index % languages.length], - }) - .instance(`instance-${index}`) + const instances = flatten( + [...Array(numServices).keys()].map((index) => { + const language = languages[index % languages.length]; + const agentLanguageVersions = agentVersions[language]; + + const numOfInstances = (index % 3) + 1; + + return [...Array(numOfInstances).keys()].map((instanceIndex) => + apm + .service({ + name: `${services[index % services.length]}-${language}-${index}`, + environment: ENVIRONMENT, + agentName: language, + }) + .instance(`instance-${index}-${instanceIndex}`) + .defaults({ + 'agent.version': agentLanguageVersions[index % agentLanguageVersions.length], + 'service.language.name': language, + }) + ); + }) ); const urls = ['GET /order/{id}', 'POST /basket/{id}', 'DELETE /basket', 'GET /products']; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 0aa6830adf8679..150bc768601f48 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -442,6 +442,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:apmAgentExplorerView': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'observability:apmAWSLambdaPriceFactor': { type: 'text', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 88220ed367886d..8a897721b6dc67 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -46,6 +46,7 @@ export interface UsageStats { 'observability:apmAWSLambdaPriceFactor': string; 'observability:apmAWSLambdaRequestCostPerMillion': number; 'observability:enableInfrastructureHostsView': boolean; + 'observability:apmAgentExplorerView': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index ddf1ba3df941a5..b59177259ece35 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8916,6 +8916,12 @@ "description": "Non-default value of setting." } }, + "observability:apmAgentExplorerView": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "observability:apmAWSLambdaPriceFactor": { "type": "text", "_meta": { diff --git a/x-pack/plugins/apm/common/agent_explorer.ts b/x-pack/plugins/apm/common/agent_explorer.ts new file mode 100644 index 00000000000000..07ce5ed7ab1f04 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_explorer.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum AgentExplorerFieldName { + ServiceName = 'serviceName', + Environments = 'environments', + AgentName = 'agentName', + AgentVersion = 'agentVersion', + AgentLastVersion = 'agentLastVersion', + AgentDocsPageUrl = 'agentDocsPageUrl', + Instances = 'instances', +} diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 7902b57c7c72c3..ef4197fb072b6f 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -45,6 +45,9 @@ export const AGENT_NAMES: AgentName[] = [ ...OPEN_TELEMETRY_AGENT_NAMES, ]; +export const isOpenTelemetryAgentName = (agentName: AgentName) => + OPEN_TELEMETRY_AGENT_NAMES.includes(agentName); + export const JAVA_AGENT_NAMES: AgentName[] = ['java', 'opentelemetry/java']; export function isJavaAgentName( diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_explorer_docs_link/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_explorer_docs_link/index.tsx new file mode 100644 index 00000000000000..2e2014a787be5f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_explorer_docs_link/index.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { isOpenTelemetryAgentName } from '../../../../../../common/agent_name'; +import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n'; +import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent'; + +interface AgentExplorerDocsLinkProps { + agentName: AgentName; + repositoryUrl?: string; +} + +export function AgentExplorerDocsLink({ + agentName, + repositoryUrl, +}: AgentExplorerDocsLinkProps) { + if (!repositoryUrl) { + return <>{NOT_AVAILABLE_LABEL}; + } + + return ( + + {isOpenTelemetryAgentName(agentName) ? ( + + ) : ( + + )}{' '} + {i18n.translate('xpack.apm.agentExplorer.docsLink.message', { + defaultMessage: 'Docs', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_contextual_information/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_contextual_information/index.tsx new file mode 100644 index 00000000000000..64ea20f1e14d85 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_contextual_information/index.tsx @@ -0,0 +1,121 @@ +/* + * 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 } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { TypeOf } from '@kbn/typed-react-router-config'; +import React from 'react'; +import { AgentExplorerFieldName } from '../../../../../../../common/agent_explorer'; +import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent'; +import { useApmPluginContext } from '../../../../../../context/apm_plugin/use_apm_plugin_context'; +import { useDefaultTimeRange } from '../../../../../../hooks/use_default_time_range'; +import { ApmRoutes } from '../../../../../routing/apm_route_config'; +import { ServiceLink } from '../../../../../shared/service_link'; +import { StickyProperties } from '../../../../../shared/sticky_properties'; +import { getComparisonEnabled } from '../../../../../shared/time_comparison/get_comparison_enabled'; +import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip'; +import { AgentExplorerDocsLink } from '../../agent_explorer_docs_link'; + +export function AgentContextualInformation({ + agentName, + serviceName, + agentDocsPageUrl, + instances, + query, +}: { + agentName: AgentName; + serviceName: string; + agentDocsPageUrl?: string; + instances: number; + query: TypeOf['query']; +}) { + const { core } = useApmPluginContext(); + const comparisonEnabled = getComparisonEnabled({ core }); + const { rangeFrom, rangeTo } = useDefaultTimeRange(); + + const stickyProperties = [ + { + label: i18n.translate('xpack.apm.agentInstancesDetails.serviceLabel', { + defaultMessage: 'Service', + }), + fieldName: AgentExplorerFieldName.ServiceName, + val: ( + + } + /> + ), + width: '25%', + }, + { + label: i18n.translate('xpack.apm.agentInstancesDetails.agentNameLabel', { + defaultMessage: 'Agent Name', + }), + fieldName: AgentExplorerFieldName.AgentName, + val: ( + + + {agentName} + + + ), + width: '25%', + }, + { + label: i18n.translate('xpack.apm.agentInstancesDetails.intancesLabel', { + defaultMessage: 'Instances', + }), + fieldName: 'instances', + val: ( + + + {instances} + + + ), + width: '25%', + }, + { + label: i18n.translate( + 'xpack.apm.agentInstancesDetails.agentDocsUrlLabel', + { + defaultMessage: 'Agent documentation', + } + ), + fieldName: AgentExplorerFieldName.AgentDocsPageUrl, + val: ( + + } + /> + ), + width: '25%', + }, + ]; + + return ; +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_instances_details/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_instances_details/index.tsx new file mode 100644 index 00000000000000..61dbf513859520 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/agent_instances_details/index.tsx @@ -0,0 +1,217 @@ +/* + * 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 { EuiLink, EuiLoadingContent, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { ValuesType } from 'utility-types'; +import { AgentExplorerFieldName } from '../../../../../../../common/agent_explorer'; +import { isOpenTelemetryAgentName } from '../../../../../../../common/agent_name'; +import { + getServiceNodeName, + SERVICE_NODE_NAME_MISSING, +} from '../../../../../../../common/service_nodes'; +import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { unit } from '../../../../../../utils/style'; +import { EnvironmentBadge } from '../../../../../shared/environment_badge'; +import { ItemsBadge } from '../../../../../shared/item_badge'; +import { ServiceNodeMetricOverviewLink } from '../../../../../shared/links/apm/service_node_metric_overview_link'; +import { + ITableColumn, + ManagedTable, +} from '../../../../../shared/managed_table'; +import { PopoverTooltip } from '../../../../../shared/popover_tooltip'; +import { TimestampTooltip } from '../../../../../shared/timestamp_tooltip'; +import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip'; + +type AgentExplorerInstance = ValuesType< + APIReturnType<'GET /internal/apm/services/{serviceName}/agent_instances'>['items'] +>; + +enum AgentExplorerInstanceFieldName { + InstanceName = 'serviceNode', + Environments = 'environments', + AgentVersion = 'agentVersion', + LastReport = 'lastReport', +} + +export function getInstanceColumns( + serviceName: string, + agentName: AgentName, + agentDocsPageUrl?: string +): Array> { + return [ + { + field: AgentExplorerInstanceFieldName.InstanceName, + name: i18n.translate( + 'xpack.apm.agentExplorerInstanceTable.InstanceColumnLabel', + { + defaultMessage: 'Instance', + } + ), + sortable: true, + render: (_, { serviceNode }) => { + const displayedName = getServiceNodeName(serviceNode); + + return serviceNode === SERVICE_NODE_NAME_MISSING ? ( + <> + {displayedName} + + +

+ + {i18n.translate( + 'xpack.apm.agentExplorerInstanceTable.noServiceNodeName.configurationOptions', + { + defaultMessage: 'configuration options', + } + )} + + ), + }} + /> +

+
+
+ + ) : ( + + {serviceNode ? ( + + {displayedName} + + ) : ( + {displayedName} + )} + + } + /> + ); + }, + }, + { + field: AgentExplorerInstanceFieldName.Environments, + name: i18n.translate( + 'xpack.apm.agentExplorerInstanceTable.environmentColumnLabel', + { + defaultMessage: 'Environment', + } + ), + width: `${unit * 16}px`, + sortable: true, + render: (_, { environments }) => ( + + ), + }, + { + field: AgentExplorerInstanceFieldName.AgentVersion, + name: i18n.translate( + 'xpack.apm.agentExplorerInstanceTable.agentVersionColumnLabel', + { defaultMessage: 'Agent Version' } + ), + width: `${unit * 16}px`, + sortable: true, + render: (_, { agentVersion }) => { + const versions = [agentVersion]; + return ( + + ); + }, + }, + { + field: AgentExplorerInstanceFieldName.LastReport, + name: i18n.translate( + 'xpack.apm.agentExplorerInstanceTable.lastReportColumnLabel', + { + defaultMessage: 'Last report', + } + ), + width: `${unit * 16}px`, + sortable: true, + render: (_, { lastReport }) => , + }, + ]; +} + +interface Props { + serviceName: string; + agentName: AgentName; + agentDocsPageUrl?: string; + items: AgentExplorerInstance[]; + isLoading: boolean; +} + +export function AgentInstancesDetails({ + serviceName, + agentName, + agentDocsPageUrl, + items, + isLoading, +}: Props) { + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/index.tsx new file mode 100644 index 00000000000000..33b2b780d4e346 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_instances/index.tsx @@ -0,0 +1,113 @@ +/* + * 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, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiPortal, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useApmParams } from '../../../../../hooks/use_apm_params'; +import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; +import { useProgressiveFetcher } from '../../../../../hooks/use_progressive_fetcher'; +import { useTimeRange } from '../../../../../hooks/use_time_range'; +import { ResponsiveFlyout } from '../../../transaction_details/waterfall_with_summary/waterfall_container/waterfall/responsive_flyout'; +import { AgentExplorerItem } from '../agent_list'; +import { AgentContextualInformation } from './agent_contextual_information'; +import { AgentInstancesDetails } from './agent_instances_details'; + +function useAgentInstancesFetcher({ serviceName }: { serviceName: string }) { + const { + query: { environment, kuery }, + } = useApmParams('/settings/agent-explorer'); + + const { start, end } = useTimeRange({ rangeFrom: 'now-24h', rangeTo: 'now' }); + + return useProgressiveFetcher( + (callApmApi) => { + return callApmApi( + 'GET /internal/apm/services/{serviceName}/agent_instances', + { + params: { + path: { + serviceName, + }, + query: { + environment, + start, + end, + kuery, + }, + }, + } + ); + }, + [start, end, serviceName, environment, kuery] + ); +} + +interface Props { + agent: AgentExplorerItem; + onClose: () => void; +} + +export function AgentInstances({ agent, onClose }: Props) { + const { query } = useApmParams('/settings/agent-explorer'); + + const instances = useAgentInstancesFetcher({ + serviceName: agent.serviceName, + }); + + const isLoading = instances.status === FETCH_STATUS.LOADING; + + return ( + + + + + + +

+ {i18n.translate( + 'xpack.apm.agentExplorer.instancesFlyout.title', + { + defaultMessage: 'Agent Instances', + } + )} +

+
+
+
+
+ + + + + + +
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_list/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_list/index.tsx new file mode 100644 index 00000000000000..8d934c7570413e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/agent_list/index.tsx @@ -0,0 +1,210 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo, useState } from 'react'; +import { ValuesType } from 'utility-types'; +import { AgentExplorerFieldName } from '../../../../../../common/agent_explorer'; +import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent'; +import { APIReturnType } from '../../../../../services/rest/create_call_apm_api'; +import { unit } from '../../../../../utils/style'; +import { AgentIcon } from '../../../../shared/agent_icon'; +import { EnvironmentBadge } from '../../../../shared/environment_badge'; +import { ItemsBadge } from '../../../../shared/item_badge'; +import { ITableColumn, ManagedTable } from '../../../../shared/managed_table'; +import { TruncateWithTooltip } from '../../../../shared/truncate_with_tooltip'; +import { AgentExplorerDocsLink } from '../agent_explorer_docs_link'; +import { AgentInstances } from '../agent_instances'; + +export type AgentExplorerItem = ValuesType< + APIReturnType<'GET /internal/apm/get_agents_per_service'>['items'] +>; + +export function getAgentsColumns({ + selectedAgent, + onAgentSelected, +}: { + selectedAgent?: AgentExplorerItem; + onAgentSelected: (agent: AgentExplorerItem) => void; +}): Array> { + return [ + { + field: AgentExplorerFieldName.ServiceName, + name: '', + width: `${unit * 3}px`, + render: (_, agent) => { + const isSelected = selectedAgent === agent; + + return ( + + onAgentSelected(agent)} + display={isSelected ? 'base' : 'empty'} + iconType={isSelected ? 'minimize' : 'expand'} + isSelected={isSelected} + /> + + ); + }, + }, + { + field: AgentExplorerFieldName.ServiceName, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.serviceNameColumnLabel', + { + defaultMessage: 'Service Name', + } + ), + sortable: true, + render: (_, { serviceName, agentName }) => ( + + + + + + {serviceName} + +
+ } + /> + ), + }, + { + field: AgentExplorerFieldName.Environments, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.environmentColumnLabel', + { + defaultMessage: 'Environment', + } + ), + width: `${unit * 16}px`, + sortable: true, + render: (_, { environments }) => ( + + ), + }, + { + field: AgentExplorerFieldName.Instances, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.instancesColumnLabel', + { + defaultMessage: 'Instances', + } + ), + width: `${unit * 8}px`, + sortable: true, + }, + { + field: AgentExplorerFieldName.AgentName, + width: `${unit * 12}px`, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.agentNameColumnLabel', + { defaultMessage: 'Agent Name' } + ), + sortable: true, + }, + { + field: AgentExplorerFieldName.AgentVersion, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.agentVersionColumnLabel', + { defaultMessage: 'Agent Version' } + ), + width: `${unit * 8}px`, + render: (_, { agentVersion }) => ( + + ), + }, + { + field: AgentExplorerFieldName.AgentDocsPageUrl, + name: i18n.translate( + 'xpack.apm.agentExplorerTable.agentDocsColumnLabel', + { defaultMessage: 'Agent Docs' } + ), + width: `${unit * 8}px`, + render: (_, { agentName, agentDocsPageUrl }) => ( + + + + ), + }, + ]; +} + +interface Props { + items: AgentExplorerItem[]; + noItemsMessage: React.ReactNode; + isLoading: boolean; +} + +export function AgentList({ items, noItemsMessage, isLoading }: Props) { + const [selectedAgent, setSelectedAgent] = useState(); + + const onAgentSelected = (agent: AgentExplorerItem) => { + setSelectedAgent(agent); + }; + + const onCloseFlyout = () => { + setSelectedAgent(undefined); + }; + + const agentColumns = useMemo( + () => getAgentsColumns({ selectedAgent, onAgentSelected }), + [selectedAgent] + ); + + return ( + <> + {selectedAgent && ( + + )} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_explorer/index.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/index.tsx new file mode 100644 index 00000000000000..11f4f8d0d79a1d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_explorer/index.tsx @@ -0,0 +1,196 @@ +/* + * 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 { + EuiCallOut, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { + SERVICE_LANGUAGE_NAME, + SERVICE_NAME, +} from '../../../../../common/elasticsearch_fieldnames'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { useProgressiveFetcher } from '../../../../hooks/use_progressive_fetcher'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { KueryBar } from '../../../shared/kuery_bar'; +import * as urlHelpers from '../../../shared/links/url_helpers'; +import { SuggestionsSelect } from '../../../shared/suggestions_select'; +import { TechnicalPreviewBadge } from '../../../shared/technical_preview_badge'; +import { AgentList } from './agent_list'; + +function useAgentExplorerFetcher({ + start, + end, +}: { + start: string; + end: string; +}) { + const { + query: { environment, serviceName, agentLanguage, kuery }, + } = useApmParams('/settings/agent-explorer'); + + return useProgressiveFetcher( + (callApmApi) => { + return callApmApi('GET /internal/apm/get_agents_per_service', { + params: { + query: { + environment, + serviceName, + agentLanguage, + kuery, + start, + end, + }, + }, + }); + }, + [environment, serviceName, agentLanguage, kuery, start, end] + ); +} + +export function AgentExplorer() { + const history = useHistory(); + + const { + query: { serviceName, agentLanguage }, + } = useApmParams('/settings/agent-explorer'); + + const { start, end } = useTimeRange({ rangeFrom: 'now-24h', rangeTo: 'now' }); + const agents = useAgentExplorerFetcher({ start, end }); + + const isLoading = agents.status === FETCH_STATUS.LOADING; + + const noItemsMessage = ( + + {i18n.translate('xpack.apm.agentExplorer.notFoundLabel', { + defaultMessage: 'No Agents found', + })} +
+ } + titleSize="s" + /> + ); + + return ( + + + + {i18n.translate('xpack.apm.settings.agentExplorer.descriptionText', { + defaultMessage: + 'Agent Explorer Technical Preview provides an inventory and details of deployed Agents.', + })} + + + + + + + +

+ {i18n.translate('xpack.apm.settings.agentExplorer.title', { + defaultMessage: 'Agent explorer', + })} +

+
+ + + +
+
+
+ + + + + + + + + { + urlHelpers.push(history, { + query: { serviceName: value ?? '' }, + }); + }} + placeholder={i18n.translate( + 'xpack.apm.agentExplorer.serviceNameSelect.placeholder', + { + defaultMessage: 'All', + } + )} + start={start} + end={end} + dataTestSubj="agentExplorerServiceNameSelect" + /> + + + { + urlHelpers.push(history, { + query: { agentLanguage: value ?? '' }, + }); + }} + placeholder={i18n.translate( + 'xpack.apm.agentExplorer.agentLanguageSelect.placeholder', + { + defaultMessage: 'All', + } + )} + start={start} + end={end} + dataTestSubj="agentExplorerAgentLanguageSelect" + /> + + + + + + + + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/routing/settings/index.tsx b/x-pack/plugins/apm/public/components/routing/settings/index.tsx index 161afd2706b680..2e1cfde3ee58b2 100644 --- a/x-pack/plugins/apm/public/components/routing/settings/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/settings/index.tsx @@ -4,23 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; -import * as t from 'io-ts'; -import { Outlet } from '@kbn/typed-react-router-config'; import { i18n } from '@kbn/i18n'; +import { Outlet } from '@kbn/typed-react-router-config'; +import * as t from 'io-ts'; +import React from 'react'; import { Redirect } from 'react-router-dom'; import { agentConfigurationPageStepRt } from '../../../../common/agent_configuration/constants'; +import { environmentRt } from '../../../../common/environment_rt'; import { Breadcrumb } from '../../app/breadcrumb'; -import { SettingsTemplate } from '../templates/settings_template'; import { AgentConfigurations } from '../../app/settings/agent_configurations'; -import { CreateAgentConfigurationRouteView } from './create_agent_configuration_route_view'; -import { EditAgentConfigurationRouteView } from './edit_agent_configuration_route_view'; +import { AgentExplorer } from '../../app/settings/agent_explorer'; +import { AgentKeys } from '../../app/settings/agent_keys'; +import { AnomalyDetection } from '../../app/settings/anomaly_detection'; import { ApmIndices } from '../../app/settings/apm_indices'; import { CustomLinkOverview } from '../../app/settings/custom_link'; -import { Schema } from '../../app/settings/schema'; -import { AnomalyDetection } from '../../app/settings/anomaly_detection'; -import { AgentKeys } from '../../app/settings/agent_keys'; import { GeneralSettings } from '../../app/settings/general_settings'; +import { Schema } from '../../app/settings/schema'; +import { SettingsTemplate } from '../templates/settings_template'; +import { CreateAgentConfigurationRouteView } from './create_agent_configuration_route_view'; +import { EditAgentConfigurationRouteView } from './edit_agent_configuration_route_view'; function page({ title, @@ -141,6 +143,28 @@ export const settings = { element: , tab: 'agent-keys', }), + '/settings/agent-explorer': { + ...page({ + title: i18n.translate( + 'xpack.apm.views.settings.agentExplorer.title', + { + defaultMessage: 'Agent explorer', + } + ), + element: , + tab: 'agent-explorer', + }), + params: t.type({ + query: t.intersection([ + environmentRt, + t.type({ + kuery: t.string, + agentLanguage: t.string, + serviceName: t.string, + }), + ]), + }), + }, '/settings': { element: , }, diff --git a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx index 3305ecb6c4a548..8070018d9fea4b 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx @@ -6,13 +6,17 @@ */ import { EuiPageHeaderProps } from '@elastic/eui'; +import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { enableAgentExplorerView } from '@kbn/observability-plugin/public'; import React from 'react'; -import { CoreStart } from '@kbn/core/public'; -import { ApmMainTemplate } from './apm_main_template'; +import { useDefaultEnvironment } from '../../../hooks/use_default_environment'; +import { Environment } from '../../../../common/environment_rt'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useApmRouter } from '../../../hooks/use_apm_router'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; import { ApmRouter } from '../apm_route_config'; +import { ApmMainTemplate } from './apm_main_template'; type Tab = NonNullable[0] & { key: @@ -22,7 +26,8 @@ type Tab = NonNullable[0] & { | 'apm-indices' | 'custom-links' | 'schema' - | 'general-settings'; + | 'general-settings' + | 'agent-explorer'; hidden?: boolean; }; @@ -34,7 +39,9 @@ interface Props { export function SettingsTemplate({ children, selectedTab }: Props) { const { core } = useApmPluginContext(); const router = useApmRouter(); - const tabs = getTabs({ core, selectedTab, router }); + const defaultEnvironment = useDefaultEnvironment(); + + const tabs = getTabs({ core, selectedTab, router, defaultEnvironment }); return ( ( + enableAgentExplorerView, + false + ); + const tabs: Tab[] = [ { key: 'general-settings', @@ -77,6 +91,22 @@ function getTabs({ }), href: router.link('/settings/agent-configuration'), }, + { + key: 'agent-explorer', + label: i18n.translate('xpack.apm.settings.agentExplorer', { + defaultMessage: 'Agent Explorer', + }), + href: router.link('/settings/agent-explorer', { + query: { + environment: defaultEnvironment, + kuery: '', + agentLanguage: '', + serviceName: '', + }, + }), + append: , + hidden: !agentExplorerEnabled, + }, { key: 'agent-keys', label: i18n.translate('xpack.apm.settings.agentKeys', { @@ -117,9 +147,10 @@ function getTabs({ return tabs .filter((t) => !t.hidden) - .map(({ href, key, label }) => ({ + .map(({ href, key, label, append }) => ({ href, label, + append, isSelected: key === selectedTab, })); } diff --git a/x-pack/plugins/apm/public/components/shared/environment_badge/index.tsx b/x-pack/plugins/apm/public/components/shared/environment_badge/index.tsx index aa269fb87222f7..9ac3139678316b 100644 --- a/x-pack/plugins/apm/public/components/shared/environment_badge/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/environment_badge/index.tsx @@ -7,40 +7,23 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { ItemsBadge } from '../item_badge'; interface Props { environments: string[]; } export function EnvironmentBadge({ environments = [] }: Props) { - if (environments.length < 2) { - return ( - <> - {environments.map((env) => ( - - {env} - - ))} - - ); - } return ( - ( - - {env} -
-
- ))} - > - - {i18n.translate('xpack.apm.servicesTable.environmentCount', { + -
+ } + )} + /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/item_badge/index.tsx b/x-pack/plugins/apm/public/components/shared/item_badge/index.tsx new file mode 100644 index 00000000000000..ab0b9154bd8cb3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/item_badge/index.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +interface Props { + items: string[]; + multipleItemsMessage?: string; +} +export function ItemsBadge({ + items = [], + multipleItemsMessage = i18n.translate('xpack.apm.itemsBadge.placeholder', { + values: { itemsCount: items.length }, + defaultMessage: '{itemsCount, plural, one {1 item} other {# items}}', + }), +}: Props) { + if (items.length < 2) { + return ( + <> + {items.map((item) => ( + + {item} + + ))} + + ); + } + return ( + ( + + {item} +
+
+ ))} + > + {multipleItemsMessage} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/popover_tooltip/index.tsx b/x-pack/plugins/apm/public/components/shared/popover_tooltip/index.tsx new file mode 100644 index 00000000000000..a34c3d7bf124f0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/popover_tooltip/index.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiButtonIcon, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; + +interface PopoverTooltipProps { + ariaLabel?: string; + children: React.ReactNode; +} + +export function PopoverTooltip({ ariaLabel, children }: PopoverTooltipProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(false)} + button={ + ) => { + setIsPopoverOpen(!isPopoverOpen); + event.stopPropagation(); + }} + size="xs" + color="primary" + iconType="questionInCircle" + style={{ height: 'auto' }} + /> + } + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/hooks/use_default_time_range.ts b/x-pack/plugins/apm/public/hooks/use_default_time_range.ts new file mode 100644 index 00000000000000..39732025cbec55 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_default_time_range.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import { TimePickerTimeDefaults } from '../components/shared/date_picker/typings'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; + +export function useDefaultTimeRange() { + const { core } = useApmPluginContext(); + + const { from: rangeFrom, to: rangeTo } = + core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + return { rangeFrom, rangeTo }; +} diff --git a/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_instances.ts b/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_instances.ts new file mode 100644 index 00000000000000..abebc4285a1e2d --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_instances.ts @@ -0,0 +1,108 @@ +/* + * 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 { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server'; +import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; +import { + AGENT_NAME, + AGENT_VERSION, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + SERVICE_NODE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; + +const MAX_NUMBER_OF_SERVICE_NODES = 500; + +export async function getAgentInstances({ + environment, + serviceName, + kuery, + apmEventClient, + start, + end, +}: { + environment: string; + serviceName?: string; + kuery: string; + apmEventClient: APMEventClient; + start: number; + end: number; +}) { + const response = await apmEventClient.search('get_agent_instances', { + apm: { + events: [ProcessorEvent.metric], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + { + exists: { + field: AGENT_NAME, + }, + }, + { + exists: { + field: AGENT_VERSION, + }, + }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...(serviceName ? termQuery(SERVICE_NAME, serviceName) : []), + ], + }, + }, + aggs: { + serviceNodes: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size: MAX_NUMBER_OF_SERVICE_NODES, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + sample: { + top_metrics: { + metrics: [{ field: AGENT_VERSION } as const], + sort: { + '@timestamp': 'desc' as const, + }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.serviceNodes.buckets.map((agentInstance) => ({ + serviceNode: agentInstance.key as string, + environments: agentInstance.environments.buckets.map( + (environmentBucket) => environmentBucket.key as string + ), + agentVersion: agentInstance.sample.top[0].metrics[ + AGENT_VERSION + ] as string, + lastReport: agentInstance.sample.top[0].sort[0] as string, + })) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_url_repository.ts b/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_url_repository.ts new file mode 100644 index 00000000000000..2ec8d5b66c4c5c --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_explorer/get_agent_url_repository.ts @@ -0,0 +1,47 @@ +/* + * 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 { isOpenTelemetryAgentName } from '../../../common/agent_name'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +const agentsDocPageName: Partial> = { + go: 'go', + java: 'java', + 'js-base': 'rum-js', + 'iOS/swift': 'swift', + 'rum-js': 'rum-js', + nodejs: 'nodejs', + python: 'python', + dotnet: 'dotnet', + ruby: 'ruby', + php: 'php', + 'opentelemetry/cpp': 'cpp', + 'opentelemetry/dotnet': 'net', + 'opentelemetry/erlang': 'erlang', + 'opentelemetry/go': 'go', + 'opentelemetry/java': 'java', + 'opentelemetry/nodejs': 'js', + 'opentelemetry/php': 'php', + 'opentelemetry/python': 'python', + 'opentelemetry/ruby': 'ruby', + 'opentelemetry/swift': 'swift', + 'opentelemetry/webjs': 'js', +}; + +export const getAgentDocsPageUrl = (agentName: AgentName) => { + const agentDocsPageName = agentsDocPageName[agentName]; + + if (!agentDocsPageName) { + return undefined; + } + + if (isOpenTelemetryAgentName(agentName)) { + return `https://opentelemetry.io/docs/instrumentation/${agentDocsPageName}`; + } + + return `https://www.elastic.co/guide/en/apm/agent/${agentDocsPageName}/current/`; +}; diff --git a/x-pack/plugins/apm/server/routes/agent_explorer/get_agents.ts b/x-pack/plugins/apm/server/routes/agent_explorer/get_agents.ts new file mode 100644 index 00000000000000..bce408391d661a --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_explorer/get_agents.ts @@ -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 { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { RandomSampler } from '../../lib/helpers/get_random_sampler'; +import { getAgentsItems } from './get_agents_items'; +import { getAgentDocsPageUrl } from './get_agent_url_repository'; + +export async function getAgents({ + environment, + serviceName, + agentLanguage, + kuery, + apmEventClient, + start, + end, + randomSampler, +}: { + environment: string; + serviceName?: string; + agentLanguage?: string; + kuery: string; + apmEventClient: APMEventClient; + start: number; + end: number; + randomSampler: RandomSampler; +}) { + const items = await getAgentsItems({ + environment, + serviceName, + agentLanguage, + kuery, + apmEventClient, + start, + end, + randomSampler, + }); + + return { + items: items.map((item) => ({ + ...item, + agentDocsPageUrl: getAgentDocsPageUrl(item.agentName as AgentName), + })), + }; +} diff --git a/x-pack/plugins/apm/server/routes/agent_explorer/get_agents_items.ts b/x-pack/plugins/apm/server/routes/agent_explorer/get_agents_items.ts new file mode 100644 index 00000000000000..9fbb4a772e8907 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_explorer/get_agents_items.ts @@ -0,0 +1,136 @@ +/* + * 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 { ProcessorEvent } from '@kbn/observability-plugin/common/processor_event'; +import { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server/utils/queries'; +import { + AGENT_NAME, + AGENT_VERSION, + SERVICE_ENVIRONMENT, + SERVICE_LANGUAGE_NAME, + SERVICE_NAME, + SERVICE_NODE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { RandomSampler } from '../../lib/helpers/get_random_sampler'; +import { MAX_NUMBER_OF_SERVICES } from '../services/get_services/get_services_items'; + +interface AggregationParams { + environment: string; + serviceName?: string; + agentLanguage?: string; + kuery: string; + apmEventClient: APMEventClient; + start: number; + end: number; + randomSampler: RandomSampler; +} + +export async function getAgentsItems({ + environment, + agentLanguage, + serviceName, + kuery, + apmEventClient, + start, + end, + randomSampler, +}: AggregationParams) { + const response = await apmEventClient.search('get_agent_details', { + apm: { + events: [ProcessorEvent.metric], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + { + exists: { + field: AGENT_NAME, + }, + }, + { + exists: { + field: AGENT_VERSION, + }, + }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...(serviceName ? termQuery(SERVICE_NAME, serviceName) : []), + ...(agentLanguage + ? termQuery(SERVICE_LANGUAGE_NAME, agentLanguage) + : []), + ], + }, + }, + aggs: { + sample: { + random_sampler: randomSampler, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: MAX_NUMBER_OF_SERVICES, + }, + aggs: { + instances: { + cardinality: { + field: SERVICE_NODE_NAME, + }, + }, + agentVersions: { + terms: { + field: AGENT_VERSION, + }, + }, + sample: { + top_metrics: { + metrics: [{ field: AGENT_NAME } as const], + sort: { + '@timestamp': 'desc' as const, + }, + }, + }, + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.sample.services.buckets.map((bucket) => { + return { + serviceName: bucket.key as string, + environments: bucket.environments.buckets.map( + (env) => env.key as string + ), + agentName: bucket.sample.top[0].metrics[AGENT_NAME] as AgentName, + agentVersion: bucket.agentVersions.buckets.map( + (version) => version.key as string + ), + // service.node.name is set by the server only if a container.id or host.name are set. Otherwise should be explicitly set by agents. + instances: (bucket.instances.value as number) || 1, + }; + }) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/routes/agent_explorer/route.ts b/x-pack/plugins/apm/server/routes/agent_explorer/route.ts new file mode 100644 index 00000000000000..ad55723be9aefa --- /dev/null +++ b/x-pack/plugins/apm/server/routes/agent_explorer/route.ts @@ -0,0 +1,119 @@ +/* + * 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 * as t from 'io-ts'; +import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; +import { getRandomSampler } from '../../lib/helpers/get_random_sampler'; +import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { + environmentRt, + kueryRt, + probabilityRt, + rangeRt, +} from '../default_api_types'; +import { getAgents } from './get_agents'; +import { getAgentInstances } from './get_agent_instances'; + +const agentExplorerRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/get_agents_per_service', + options: { tags: ['access:apm'] }, + params: t.type({ + query: t.intersection([ + environmentRt, + kueryRt, + rangeRt, + probabilityRt, + t.partial({ + serviceName: t.string, + agentLanguage: t.string, + }), + ]), + }), + async handler(resources): Promise<{ + items: Array<{ + serviceName: string; + environments: string[]; + agentName: import('./../../../typings/es_schemas/ui/fields/agent').AgentName; + agentVersion: string[]; + agentDocsPageUrl?: string; + instances: number; + }>; + }> { + const { + params, + request, + plugins: { security }, + } = resources; + + const { + environment, + kuery, + start, + end, + probability, + serviceName, + agentLanguage, + } = params.query; + + const [apmEventClient, randomSampler] = await Promise.all([ + getApmEventClient(resources), + getRandomSampler({ security, request, probability }), + ]); + + return getAgents({ + environment, + serviceName, + agentLanguage, + kuery, + apmEventClient, + start, + end, + randomSampler, + }); + }, +}); + +const agentExplorerInstanceRoute = createApmServerRoute({ + endpoint: 'GET /internal/apm/services/{serviceName}/agent_instances', + options: { tags: ['access:apm'] }, + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([environmentRt, kueryRt, rangeRt, probabilityRt]), + }), + async handler(resources): Promise<{ + items: Array<{ + serviceNode?: string; + environments: string[]; + agentVersion: string; + lastReport: string; + }>; + }> { + const { params } = resources; + + const { environment, kuery, start, end } = params.query; + + const { serviceName } = params.path; + + const apmEventClient = await getApmEventClient(resources); + + return { + items: await getAgentInstances({ + environment, + serviceName, + kuery, + apmEventClient, + start, + end, + }), + }; + }, +}); + +export const agentExplorerRouteRepository = { + ...agentExplorerRoute, + ...agentExplorerInstanceRoute, +}; diff --git a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts index 84ef941462b39d..91e78880bf5481 100644 --- a/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/apm_routes/get_global_apm_server_route_repository.ts @@ -10,12 +10,13 @@ import type { ServerRouteRepository, } from '@kbn/server-route-repository'; import { PickByValue } from 'utility-types'; +import { agentExplorerRouteRepository } from '../agent_explorer/route'; import { agentKeysRouteRepository } from '../agent_keys/route'; import { alertsChartPreviewRouteRepository } from '../alerts/route'; -import { dependencisRouteRepository } from '../dependencies/route'; import { correlationsRouteRepository } from '../correlations/route'; import { dataViewRouteRepository } from '../data_view/route'; import { debugTelemetryRoute } from '../debug_telemetry/route'; +import { dependencisRouteRepository } from '../dependencies/route'; import { environmentsRouteRepository } from '../environments/route'; import { errorsRouteRepository } from '../errors/route'; import { eventMetadataRouteRepository } from '../event_metadata/route'; @@ -25,6 +26,7 @@ import { historicalDataRouteRepository } from '../historical_data/route'; import { infrastructureRouteRepository } from '../infrastructure/route'; import { latencyDistributionRouteRepository } from '../latency_distribution/route'; import { metricsRouteRepository } from '../metrics/route'; +import { mobileRouteRepository } from '../mobile/route'; import { observabilityOverviewRouteRepository } from '../observability_overview/route'; import { serviceRouteRepository } from '../services/route'; import { serviceGroupRouteRepository } from '../service_groups/route'; @@ -33,15 +35,14 @@ import { agentConfigurationRouteRepository } from '../settings/agent_configurati import { anomalyDetectionRouteRepository } from '../settings/anomaly_detection/route'; import { apmIndicesRouteRepository } from '../settings/apm_indices/route'; import { customLinkRouteRepository } from '../settings/custom_link/route'; +import { labsRouteRepository } from '../settings/labs/route'; import { sourceMapsRouteRepository } from '../source_maps/route'; import { spanLinksRouteRepository } from '../span_links/route'; +import { storageExplorerRouteRepository } from '../storage_explorer/route'; import { suggestionsRouteRepository } from '../suggestions/route'; import { timeRangeMetadataRoute } from '../time_range_metadata/route'; import { traceRouteRepository } from '../traces/route'; import { transactionRouteRepository } from '../transactions/route'; -import { storageExplorerRouteRepository } from '../storage_explorer/route'; -import { labsRouteRepository } from '../settings/labs/route'; -import { mobileRouteRepository } from '../mobile/route'; function getTypedGlobalApmServerRouteRepository() { const repository = { @@ -76,6 +77,7 @@ function getTypedGlobalApmServerRouteRepository() { ...debugTelemetryRoute, ...timeRangeMetadataRoute, ...labsRouteRepository, + ...agentExplorerRouteRepository, ...mobileRouteRepository, }; diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts index b72ab6f2860052..b489f811ab511a 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts @@ -17,7 +17,7 @@ import { ServiceGroup } from '../../../../common/service_groups'; import { RandomSampler } from '../../../lib/helpers/get_random_sampler'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -const MAX_NUMBER_OF_SERVICES = 500; +export const MAX_NUMBER_OF_SERVICES = 500; export async function getServicesItems({ environment, diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index a8332262eda946..4c48bd938562f5 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -26,6 +26,7 @@ export { enableInfrastructureHostsView, enableServiceMetrics, enableAwsLambdaMetrics, + enableAgentExplorerView, apmAWSLambdaPriceFactor, apmAWSLambdaRequestCostPerMillion, enableCriticalPath, diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 52258c5711d1c3..a5af57e3453cac 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -21,6 +21,7 @@ export const apmLabsButton = 'observability:apmLabsButton'; export const enableInfrastructureHostsView = 'observability:enableInfrastructureHostsView'; export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics'; export const enableServiceMetrics = 'observability:apmEnableServiceMetrics'; +export const enableAgentExplorerView = 'observability:apmAgentExplorerView'; export const apmAWSLambdaPriceFactor = 'observability:apmAWSLambdaPriceFactor'; export const apmAWSLambdaRequestCostPerMillion = 'observability:apmAWSLambdaRequestCostPerMillion'; export const enableCriticalPath = 'observability:apmEnableCriticalPath'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index dcd7a1cd08c93f..eefdb546a060ed 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -30,6 +30,7 @@ export { enableNewSyntheticsView, apmServiceGroupMaxNumberOfServices, enableInfrastructureHostsView, + enableAgentExplorerView, } from '../common/ui_settings_keys'; export { uptimeOverviewLocatorID } from '../common'; diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 13d1fc645be71b..52d7bb1e66a967 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -6,8 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import { i18n } from '@kbn/i18n'; import { UiSettingsParams } from '@kbn/core/types'; +import { i18n } from '@kbn/i18n'; import { observabilityFeatureId, ProgressiveLoadingQuality } from '../common'; import { enableComparisonByDefault, @@ -21,12 +21,13 @@ import { apmTraceExplorerTab, apmOperationsTab, apmLabsButton, - enableInfrastructureHostsView, - enableServiceMetrics, + enableAgentExplorerView, enableAwsLambdaMetrics, apmAWSLambdaPriceFactor, apmAWSLambdaRequestCostPerMillion, enableCriticalPath, + enableInfrastructureHostsView, + enableServiceMetrics, } from '../common/ui_settings_keys'; const technicalPreviewLabel = i18n.translate( @@ -294,6 +295,23 @@ export const uiSettings: Record = { type: 'boolean', showInLabs: true, }, + [enableAgentExplorerView]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.enableAgentExplorer', { + defaultMessage: 'Agent explorer', + }), + description: i18n.translate('xpack.observability.enableAgentExplorerDescription', { + defaultMessage: '{technicalPreviewLabel} Enables Agent explorer view.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + }, + }), + schema: schema.boolean(), + value: false, + requiresPageReload: true, + type: 'boolean', + showInLabs: true, + }, [apmAWSLambdaPriceFactor]: { category: [observabilityFeatureId], name: i18n.translate('xpack.observability.apmAWSLambdaPricePerGbSeconds', { diff --git a/x-pack/test/apm_api_integration/common/config.ts b/x-pack/test/apm_api_integration/common/config.ts index 8b98fc9422c069..86b5ae98201999 100644 --- a/x-pack/test/apm_api_integration/common/config.ts +++ b/x-pack/test/apm_api_integration/common/config.ts @@ -5,20 +5,25 @@ * 2.0. */ -import { FtrConfigProviderContext } from '@kbn/test'; -import supertest from 'supertest'; -import { format, UrlObject } from 'url'; import { ApmUsername, APM_TEST_PASSWORD, } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/authentication'; import { createApmUsers } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/create_apm_users'; -import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context'; +import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +import { FtrConfigProviderContext } from '@kbn/test'; +import supertest from 'supertest'; +import { format, UrlObject } from 'url'; +import { MachineLearningAPIProvider } from '../../functional/services/ml/api'; import { APMFtrConfigName } from '../configs'; import { createApmApiClient } from './apm_api_supertest'; -import { RegistryProvider } from './registry'; import { bootstrapApmSynthtrace } from './bootstrap_apm_synthtrace'; -import { MachineLearningAPIProvider } from '../../functional/services/ml/api'; +import { + FtrProviderContext, + InheritedFtrProviderContext, + InheritedServices, +} from './ftr_provider_context'; +import { RegistryProvider } from './registry'; export interface ApmFtrConfig { name: APMFtrConfigName; @@ -43,7 +48,37 @@ async function getApmApiClient({ export type CreateTestConfig = ReturnType; -export function createTestConfig(config: ApmFtrConfig) { +type ApmApiClientKey = + | 'noAccessUser' + | 'readUser' + | 'writeUser' + | 'annotationWriterUser' + | 'noMlAccessUser' + | 'manageOwnAgentKeysUser' + | 'createAndAllAgentKeysUser' + | 'monitorClusterAndIndicesUser'; + +export interface CreateTest { + testFiles: string[]; + servers: any; + servicesRequiredForTestAnalysis: string[]; + services: InheritedServices & { + apmFtrConfig: () => ApmFtrConfig; + registry: ({ getService }: FtrProviderContext) => ReturnType; + synthtraceEsClient: (context: InheritedFtrProviderContext) => Promise; + apmApiClient: ( + context: InheritedFtrProviderContext + ) => Record>>; + ml: ({ getService }: FtrProviderContext) => ReturnType; + }; + junit: { reportName: string }; + esTestCluster: any; + kbnTestServer: any; +} + +export function createTestConfig( + config: ApmFtrConfig +): ({ readConfigFile }: FtrConfigProviderContext) => Promise { const { license, name, kibanaConfig } = config; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -51,7 +86,7 @@ export function createTestConfig(config: ApmFtrConfig) { require.resolve('../../api_integration/config.ts') ); - const services = xPackAPITestsConfig.get('services') as InheritedServices; + const services = xPackAPITestsConfig.get('services'); const servers = xPackAPITestsConfig.get('servers'); const kibanaServer = servers.kibana as UrlObject; const kibanaServerUrl = format(kibanaServer); diff --git a/x-pack/test/apm_api_integration/tests/agent_explorer/agent_explorer.spec.ts b/x-pack/test/apm_api_integration/tests/agent_explorer/agent_explorer.spec.ts new file mode 100644 index 00000000000000..016b699083b727 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/agent_explorer/agent_explorer.spec.ts @@ -0,0 +1,191 @@ +/* + * 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 expect from '@kbn/expect'; +import { apm, timerange } from '@kbn/apm-synthtrace'; +import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { RecursivePartial } from '@kbn/apm-plugin/typings/common'; +import { keyBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + const goServiceName = 'opbeans-go'; + const nodeServiceName = 'opbeans-node'; + + async function callApi( + overrides?: RecursivePartial< + APIClientRequestParamsOf<'GET /internal/apm/get_agents_per_service'>['params'] + > + ) { + return await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/get_agents_per_service', + params: { + query: { + probability: 1, + environment: 'ENVIRONMENT_ALL', + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery: '', + ...overrides?.query, + }, + }, + }); + } + + registry.when('Agent explorer when data is not loaded', { config: 'basic', archives: [] }, () => { + it('handles empty state', async () => { + const { status, body } = await callApi(); + + expect(status).to.be(200); + expect(body.items).to.be.empty(); + }); + }); + + registry.when('Agent explorer', { config: 'basic', archives: [] }, () => { + describe('when data is loaded', () => { + before(async () => { + const serviceGo = apm + .service({ + name: goServiceName, + environment: 'production', + agentName: 'go', + }) + .instance('instance-go') + .defaults({ + 'agent.version': '5.1.2', + 'service.language.name': 'go', + }); + + const serviceNodeStaging = apm + .service({ + name: nodeServiceName, + environment: 'staging', + agentName: 'nodejs', + }) + .instance('instance-node-staging') + .defaults({ + 'agent.version': '1.0.0', + 'service.language.name': 'javascript', + }); + + const serviceNodeDev = apm + .service({ + name: nodeServiceName, + environment: 'dev', + agentName: 'nodejs', + }) + .instance('instance-node-dev') + .defaults({ + 'agent.version': '1.0.3', + 'service.language.name': 'javascript', + }); + + await synthtraceEsClient.index([ + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + serviceGo + .transaction({ transactionName: 'GET /api/product/list' }) + .duration(2000) + .timestamp(timestamp) + ), + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + serviceNodeStaging + .transaction({ transactionName: 'GET /api/users/list' }) + .duration(2000) + .timestamp(timestamp) + ), + timerange(start, end) + .interval('5m') + .rate(1) + .generator((timestamp) => + serviceNodeDev + .transaction({ transactionName: 'GET /api/users/list' }) + .duration(2000) + .timestamp(timestamp) + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + it('returns correct agents information', async () => { + const { status, body } = await callApi(); + expect(status).to.be(200); + expect(body.items).to.have.length(2); + + const agents = keyBy(body.items, 'serviceName'); + + const goAgent = agents[goServiceName]; + expect(goAgent?.environments).to.have.length(1); + expect(goAgent?.environments).to.contain('production'); + expect(goAgent?.agentName).to.be('go'); + expect(goAgent?.agentVersion).to.contain('5.1.2'); + expect(goAgent?.agentDocsPageUrl).to.be( + 'https://www.elastic.co/guide/en/apm/agent/go/current/' + ); + + const nodeAgent = agents[nodeServiceName]; + expect(nodeAgent?.environments).to.have.length(2); + expect(nodeAgent?.environments).to.contain('staging'); + expect(nodeAgent?.environments).to.contain('dev'); + expect(nodeAgent?.agentName).to.be('nodejs'); + expect(nodeAgent?.agentVersion).to.contain('1.0.0'); + expect(nodeAgent?.agentVersion).to.contain('1.0.3'); + expect(nodeAgent?.agentDocsPageUrl).to.be( + 'https://www.elastic.co/guide/en/apm/agent/nodejs/current/' + ); + }); + + const matchingFilterTests = [ + ['environment', 'dev', nodeServiceName], + ['serviceName', nodeServiceName, nodeServiceName], + ['agentLanguage', 'go', goServiceName], + ['kuery', `service.name : ${goServiceName}`, goServiceName], + ]; + + matchingFilterTests.forEach(([filterName, filterValue, expectedService]) => { + it(`returns only agents matching selected ${filterName}`, async () => { + const { status, body } = await callApi({ + query: { + [filterName]: filterValue, + }, + }); + expect(status).to.be(200); + expect(body.items).to.have.length(1); + expect(body.items[0]?.serviceName).to.be(expectedService); + }); + }); + + const notMatchingFilterTests = [ + ['serviceName', 'my-service'], + ['agentLanguage', 'my-language'], + ]; + + notMatchingFilterTests.forEach(([filterName, filterValue]) => { + it(`returns empty agents when there is no matching ${filterName}`, async () => { + const { status, body } = await callApi({ + query: { + [filterName]: filterValue, + }, + }); + expect(status).to.be(200); + expect(body.items).to.be.empty(); + }); + }); + }); + }); +} From 2c996970fca9ebc6b3ca4ba47be90910bf961d2d Mon Sep 17 00:00:00 2001 From: Michael Katsoulis Date: Mon, 14 Nov 2022 16:56:29 +0200 Subject: [PATCH 12/20] Set the correct enrollment token in case of multi page layout in k8s manifest (#145098) ## Summary Setting the correct enrolment token to kubernetes manifest in case of multi page layout steps when kubernetes has been selected as a platform. ### Checklist Delete any items that are not applicable to this PR. - [ ] 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 - [ ] [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)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] 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) Closes https://github.com/elastic/kibana/issues/145072 --- .../page_steps/install_agent/install_agent_managed.tsx | 1 + 1 file changed, 1 insertion(+) 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_managed.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_managed.tsx index ecbda47baad793..ec78655ce7fede 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_managed.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_managed.tsx @@ -64,6 +64,7 @@ export const InstallElasticAgentManagedPageStep: React.FC InstallManagedAgentStep({ installCommand: installManagedCommands, apiKeyData: { item: enrollmentAPIKey }, + enrollToken: enrollmentAPIKey.api_key, selectedApiKeyId: enrollmentAPIKey.id, isComplete: commandCopied || !!enrolledAgentIds.length, fullCopyButton: true, From 6ac78d740e65a929809c18a657ca463e19344e98 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 14 Nov 2022 10:05:46 -0500 Subject: [PATCH 13/20] [ResponseOps][Actions] Don't show rule.tags for test mode (#145001) This PR fixes a bug where the `rule.tags` were shown as an option in the connector test mode. The test mode doesn't provide variables so it shouldn't be shown. ### Rule Form Still available when in the rule flow ![image](https://user-images.githubusercontent.com/56361221/201150790-0d23f0f7-4fb8-4fe1-973a-e4afde297192.png) ### Test Mode Not available in the test mode ![image](https://user-images.githubusercontent.com/56361221/201150621-52e07b7f-ef97-42d9-80f1-eced195b7a9a.png) --- .../stack/opsgenie/create_alert/index.tsx | 10 +++++-- .../stack/opsgenie/create_alert/tags.test.tsx | 29 +++++++++++++------ .../stack/opsgenie/create_alert/tags.tsx | 7 +++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx index e5ca5c3741f5d4..75e67debc81437 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx @@ -44,6 +44,7 @@ const FormView: React.FC = ({ messageVariables, subActionParams, showSaveError, + executionMode, }) => { const isMessageInvalid = (errors['subActionParams.message'] !== undefined && @@ -72,7 +73,11 @@ const FormView: React.FC = ({ - + @@ -104,7 +109,7 @@ FormView.displayName = 'FormView'; export type CreateAlertProps = Pick< ActionParamsProps, - 'errors' | 'index' | 'messageVariables' | 'editAction' + 'errors' | 'index' | 'messageVariables' | 'editAction' | 'executionMode' > & { subActionParams?: Partial; editSubAction: EditActionCallback; @@ -121,6 +126,7 @@ const CreateAlertComponent: React.FC = ({ messageVariables, subActionParams, showSaveError, + executionMode, }) => { const [showingMoreOptions, setShowingMoreOptions] = useState(false); const [showJsonEditor, setShowJsonEditor] = useState(false); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx index be010274f4179e..dcfd95aab0caf9 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { screen, render, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Tags } from './tags'; +import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public'; describe('Tags', () => { const onChange = jest.fn(); @@ -16,6 +17,7 @@ describe('Tags', () => { const options = { values: [], onChange, + executionMode: ActionConnectorMode.ActionForm, }; beforeEach(() => jest.clearAllMocks()); @@ -74,9 +76,7 @@ describe('Tags', () => { expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); - }); + userEvent.click(screen.getByTestId('comboBoxSearchInput')); userEvent.type(screen.getByTestId('comboBoxSearchInput'), 'awesome{enter}'); @@ -99,9 +99,7 @@ describe('Tags', () => { expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); - }); + userEvent.click(screen.getByTestId('comboBoxSearchInput')); await waitFor(() => { expect(screen.getByTestId('opsgenie-tags-rule-tags')).toBeInTheDocument(); @@ -116,9 +114,7 @@ describe('Tags', () => { expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); }); - act(() => { - userEvent.click(screen.getByTestId('comboBoxSearchInput')); - }); + userEvent.click(screen.getByTestId('comboBoxSearchInput')); await waitFor(() => { expect(screen.getByTestId('opsgenie-tags-rule-tags')).toBeInTheDocument(); @@ -140,4 +136,19 @@ describe('Tags', () => { `) ); }); + + it('does not contain the rule.tags option when in test mode', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('comboBoxSearchInput')); + + await waitFor(() => { + expect(screen.queryByTestId('opsgenie-tags-rule-tags')).not.toBeInTheDocument(); + expect(screen.queryByText('The tags of the rule.')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx index cf393b543c14a3..ffaa3a5460f909 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx @@ -17,6 +17,8 @@ import { EuiTextColor, } from '@elastic/eui'; +import { ActionConnectorMode, ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import type { OpsgenieActionParams } from '../../../../../server/connector_types/stack'; import { RULE_TAGS_TEMPLATE } from '../../../../../common/opsgenie'; import * as i18n from './translations'; import { EditActionCallback } from '../types'; @@ -24,6 +26,7 @@ import { EditActionCallback } from '../types'; interface TagsProps { onChange: EditActionCallback; values: string[]; + executionMode: ActionParamsProps['executionMode']; } const options: Array> = [ @@ -35,7 +38,7 @@ const options: Array> = [ }, ]; -const TagsComponent: React.FC = ({ onChange, values }) => { +const TagsComponent: React.FC = ({ onChange, values, executionMode }) => { const tagOptions = useMemo(() => values.map((value) => getTagAsOption(value)), [values]); const onCreateOption = useCallback( @@ -85,7 +88,7 @@ const TagsComponent: React.FC = ({ onChange, values }) => { rowHeight={50} fullWidth isClearable - options={options} + options={executionMode === ActionConnectorMode.ActionForm ? options : undefined} selectedOptions={tagOptions} onCreateOption={onCreateOption} onChange={onTagsChange} From 94437f8addaba2656525c1ca8aad99044528d4aa Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Mon, 14 Nov 2022 20:10:53 +0500 Subject: [PATCH 14/20] [Guided onboarding] Fix card footer button markup (#145050) --- .../guide_card_footer.test.tsx.snap | 126 ++++++++++-------- .../landing_page/guide_card_footer.tsx | 46 ++++--- 2 files changed, 98 insertions(+), 74 deletions(-) diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap index 74be4594642a54..6f9f2dcc9e2e3a 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap +++ b/packages/kbn-guided-onboarding/src/components/landing_page/__snapshots__/guide_card_footer.test.tsx.snap @@ -12,8 +12,32 @@ exports[`guide card footer snapshots should render the footer when the guide has -
+ + + View guide + + + + +`; + +exports[`guide card footer snapshots should render the footer when the guide has not started yet 1`] = ` + + View guide -
- -`; - -exports[`guide card footer snapshots should render the footer when the guide has not started yet 1`] = ` -
- - View guide - -
+
+
`; exports[`guide card footer snapshots should render the footer when the guide is in progress 1`] = ` @@ -69,19 +77,23 @@ exports[`guide card footer snapshots should render the footer when the guide is -
- - Continue - -
+ + Continue + + + `; @@ -110,34 +122,42 @@ exports[`guide card footer snapshots should render the footer when the guide is -
- - Continue - -
+ + Continue + + + `; exports[`guide card footer snapshots should render the footer when the guided onboarding has not started yet 1`] = ` -
- - View guide - -
+ + View guide + + + `; diff --git a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx index b123e86688811f..b109db15c0abad 100644 --- a/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx +++ b/packages/kbn-guided-onboarding/src/components/landing_page/guide_card_footer.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { css } from '@emotion/react'; -import { EuiButton, EuiProgress, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiProgress, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { GuideId, GuideState } from '../../types'; import { UseCase } from './use_case_card'; @@ -54,16 +54,18 @@ export interface GuideCardFooterProps { export const GuideCardFooter = ({ guides, useCase, activateGuide }: GuideCardFooterProps) => { const guideState = guides.find((guide) => guide.guideId === (useCase as GuideId)); const viewGuideButton = ( -
- activateGuide(useCase, guideState)} - > - {viewGuideLabel} - -
+ + + activateGuide(useCase, guideState)} + > + {viewGuideLabel} + + + ); // guide has not started yet if (!guideState || guideState.status === 'not_started') { @@ -108,16 +110,18 @@ export const GuideCardFooter = ({ guides, useCase, activateGuide }: GuideCardFoo }} /> -
- activateGuide(useCase, guideState)} - > - {continueGuideLabel} - -
+ + + activateGuide(useCase, guideState)} + > + {continueGuideLabel} + + + ); }; From 046543209e38a842cf5504950e8b00feb849b724 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 14 Nov 2022 10:21:41 -0500 Subject: [PATCH 15/20] [Guided onboarding] Address design feedback (#144957) --- .../public/components/guide_button_popover.tsx | 1 + .../home/public/application/components/add_data/add_data.tsx | 2 +- .../fleet/public/components/with_guided_onboarding_tour.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plugins/guided_onboarding/public/components/guide_button_popover.tsx b/src/plugins/guided_onboarding/public/components/guide_button_popover.tsx index fe341108f089eb..658d78f81ebbcb 100644 --- a/src/plugins/guided_onboarding/public/components/guide_button_popover.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_button_popover.tsx @@ -39,6 +39,7 @@ export const GuideButtonPopover = ({ data-test-subj="manualCompletionPopover" button={button} isOpen={isPopoverShown} + repositionOnScroll closePopover={() => { /* do nothing, the popover is closed once the panel is opened */ }} diff --git a/src/plugins/home/public/application/components/add_data/add_data.tsx b/src/plugins/home/public/application/components/add_data/add_data.tsx index a3cdbd92410202..fdfc286a5cbec3 100644 --- a/src/plugins/home/public/application/components/add_data/add_data.tsx +++ b/src/plugins/home/public/application/components/add_data/add_data.tsx @@ -81,7 +81,7 @@ export const AddData: FC = ({ addBasePath, application, isDarkMode, isClo > diff --git a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx index ba4afddf9cc991..b58f3fb5009e5e 100644 --- a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx +++ b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx @@ -85,7 +85,7 @@ export const WithGuidedOnboardingTour: FunctionComponent<{ return config ? ( {config.description}} + content={{config.description}} isStepOpen={isGuidedOnboardingTourOpen} maxWidth={350} onFinish={() => setIsGuidedOnboardingTourOpen(false)} From 6b6cdf8ab7eedd1d6a93bd0815ae8f416f47c239 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 14 Nov 2022 10:22:02 -0500 Subject: [PATCH 16/20] [Security Solution][Endpoint] Misc. updates in support of `get-file` response action (#144948) ## Summary - Updates the `get-file` action response `outputs` to match latest from endpoint - Fix server size `doesFileHanveChunks()` and remove the `.keyword` from the search field term (index mapping will be setup correctly for these indexes) - Updates the names of the File storage indexes - Sets the `endpointRbacV1Enabled` FF to `true` (enables feature by default) - Uses Fleet exposed function utilities to retrieve the indexes for File's metadata and data chunks The following Fleet changes were also done - Created common methods in fleet for retrieving the file metadata and data indexes using an integration name (should protect us against index names going forward and avoid having integrations in kibana keep hard-coded values) - Removed the .keyword from a few places in the file server service (still need to test) - Adjusted both the Fleet and the Security Solution code to use the new methods for getting the integration specific index names (cc/ @juliaElastic ) --- .../fleet/common/constants/file_storage.ts | 12 ++++ .../plugins/fleet/common/constants/index.ts | 1 + x-pack/plugins/fleet/common/index.ts | 2 + .../fleet/common/services/file_storage.ts | 65 +++++++++++++++++++ x-pack/plugins/fleet/common/services/index.ts | 2 + .../fleet/server/constants/fleet_es_assets.ts | 8 ++- .../plugins/fleet/server/constants/index.ts | 2 + .../fleet/server/services/agents/uploads.ts | 11 ++-- .../fleet/server/services/files/index.test.ts | 17 +++-- .../fleet/server/services/files/index.ts | 18 +++-- .../tasks/check_deleted_files_task.test.ts | 18 ++--- .../common/endpoint/constants.ts | 6 +- .../endpoint_action_generator.ts | 11 +++- .../common/endpoint/types/actions.ts | 10 ++- .../common/experimental_features.ts | 2 +- .../pages/integration_tests/index.test.tsx | 2 +- .../services/endpoint_response_actions.ts | 23 ++++--- .../endpoint/services/actions/action_files.ts | 4 +- .../endpoint/services/actions/utils.test.ts | 11 +++- .../apis/security/privileges.ts | 12 +++- .../apis/security/privileges_basic.ts | 12 +++- .../apis/agents/uploads.ts | 8 ++- 22 files changed, 203 insertions(+), 54 deletions(-) create mode 100644 x-pack/plugins/fleet/common/constants/file_storage.ts create mode 100644 x-pack/plugins/fleet/common/services/file_storage.ts diff --git a/x-pack/plugins/fleet/common/constants/file_storage.ts b/x-pack/plugins/fleet/common/constants/file_storage.ts new file mode 100644 index 00000000000000..a1988570a58737 --- /dev/null +++ b/x-pack/plugins/fleet/common/constants/file_storage.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// File storage indexes supporting endpoint Upload/download +// If needing to get an integration specific index name, use the utility functions +// found in `common/services/file_storage` +export const FILE_STORAGE_METADATA_INDEX_PATTERN = '.fleet-files-*'; +export const FILE_STORAGE_DATA_INDEX_PATTERN = '.fleet-file-data-*'; diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index 3aeed30c7e41be..f42b2a372ebb63 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -18,6 +18,7 @@ export * from './preconfiguration'; export * from './download_source'; export * from './fleet_server_policy_config'; export * from './authz'; +export * from './file_storage'; // TODO: This is the default `index.max_result_window` ES setting, which dictates // the maximum amount of results allowed to be returned from a search. It's possible diff --git a/x-pack/plugins/fleet/common/index.ts b/x-pack/plugins/fleet/common/index.ts index e8995b4bf6b741..626a40885b502f 100644 --- a/x-pack/plugins/fleet/common/index.ts +++ b/x-pack/plugins/fleet/common/index.ts @@ -65,6 +65,8 @@ export { INVALID_NAMESPACE_CHARACTERS, // TODO Should probably not be exposed by Fleet decodeCloudId, + getFileMetadataIndexName, + getFileDataIndexName, } from './services'; export type { FleetAuthz } from './authz'; diff --git a/x-pack/plugins/fleet/common/services/file_storage.ts b/x-pack/plugins/fleet/common/services/file_storage.ts new file mode 100644 index 00000000000000..4c5e9ac204c58f --- /dev/null +++ b/x-pack/plugins/fleet/common/services/file_storage.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FILE_STORAGE_DATA_INDEX_PATTERN, FILE_STORAGE_METADATA_INDEX_PATTERN } from '../constants'; + +/** + * Returns the index name for File Metadata storage for a given integration + * @param integrationName + */ +export const getFileMetadataIndexName = (integrationName: string): string => { + if (FILE_STORAGE_METADATA_INDEX_PATTERN.indexOf('*') !== -1) { + return FILE_STORAGE_METADATA_INDEX_PATTERN.replace('*', integrationName); + } + + throw new Error( + `Unable to define integration file data index. No '*' in index pattern: ${FILE_STORAGE_METADATA_INDEX_PATTERN}` + ); +}; +/** + * Returns the index name for File data (chunks) storage for a given integration + * @param integrationName + */ +export const getFileDataIndexName = (integrationName: string): string => { + if (FILE_STORAGE_DATA_INDEX_PATTERN.indexOf('*') !== -1) { + return FILE_STORAGE_DATA_INDEX_PATTERN.replace('*', integrationName); + } + + throw new Error( + `Unable to define integration file data index. No '*' in index pattern: ${FILE_STORAGE_DATA_INDEX_PATTERN}` + ); +}; + +/** + * Returns back the integration name for a given File Data (chunks) index name. + * + * @example + * // Given a File data index pattern of `.fleet-file-data-*`: + * + * getIntegrationNameFromFileDataIndexName('.fleet-file-data-agent'); + * // return 'agent' + * + * getIntegrationNameFromFileDataIndexName('.fleet-file-data-agent-00001'); + * // return 'agent' + */ +export const getIntegrationNameFromFileDataIndexName = (indexName: string): string => { + const integrationNameIndexPosition = FILE_STORAGE_DATA_INDEX_PATTERN.split('-').indexOf('*'); + + if (integrationNameIndexPosition === -1) { + throw new Error( + `Unable to parse index name. No '*' in index pattern: ${FILE_STORAGE_DATA_INDEX_PATTERN}` + ); + } + + const indexPieces = indexName.split('-'); + + if (indexPieces[integrationNameIndexPosition]) { + return indexPieces[integrationNameIndexPosition]; + } + + throw new Error(`Index name ${indexName} does not seem to be a File Data storage index`); +}; diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index 8261ccb3f82c0e..0fd88833b4ea5d 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -48,3 +48,5 @@ export { getRegistryDataStreamAssetBaseName, getComponentTemplateNameForDatastream, } from './datastream_es_name'; + +export * from './file_storage'; diff --git a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts index 7b76fc195e0974..223a9d293d0702 100644 --- a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts +++ b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { getFileDataIndexName, getFileMetadataIndexName } from '../../common'; + import { getESAssetMetadata } from '../services/epm/elasticsearch/meta'; const meta = getESAssetMetadata(); @@ -195,6 +197,6 @@ on_failure: value: - 'failed in Fleet agent final_pipeline: {{ _ingest.on_failure_message }}'`; -// File storage indexes supporting endpoint Upload/download -export const FILE_STORAGE_METADATA_INDEX_PATTERN = '.fleet-*-files'; -export const FILE_STORAGE_DATA_INDEX_PATTERN = '.fleet-*-file-data'; +// Fleet Agent indexes for storing files +export const FILE_STORAGE_METADATA_AGENT_INDEX = getFileMetadataIndexName('agent'); +export const FILE_STORAGE_DATA_AGENT_INDEX = getFileDataIndexName('agent'); diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 33004fe3030cf4..815e2cbaa9b9fd 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -83,3 +83,5 @@ export { FLEET_INSTALL_FORMAT_VERSION, FLEET_AGENT_POLICIES_SCHEMA_VERSION, } from './fleet_es_assets'; +export { FILE_STORAGE_DATA_AGENT_INDEX } from './fleet_es_assets'; +export { FILE_STORAGE_METADATA_AGENT_INDEX } from './fleet_es_assets'; diff --git a/x-pack/plugins/fleet/server/services/agents/uploads.ts b/x-pack/plugins/fleet/server/services/agents/uploads.ts index 2269a080e41b9a..7596f9cef7a643 100644 --- a/x-pack/plugins/fleet/server/services/agents/uploads.ts +++ b/x-pack/plugins/fleet/server/services/agents/uploads.ts @@ -17,14 +17,15 @@ import type { AgentDiagnostics } from '../../../common/types/models'; import { appContextService } from '../app_context'; import { AGENT_ACTIONS_INDEX, - agentRouteService, AGENT_ACTIONS_RESULTS_INDEX, + agentRouteService, } from '../../../common'; -import { SO_SEARCH_LIMIT } from '../../constants'; - -const FILE_STORAGE_METADATA_AGENT_INDEX = '.fleet-agent-files'; -const FILE_STORAGE_DATA_AGENT_INDEX = '.fleet-agent-file-data'; +import { + FILE_STORAGE_DATA_AGENT_INDEX, + FILE_STORAGE_METADATA_AGENT_INDEX, + SO_SEARCH_LIMIT, +} from '../../constants'; export async function getAgentUploads( esClient: ElasticsearchClient, diff --git a/x-pack/plugins/fleet/server/services/files/index.test.ts b/x-pack/plugins/fleet/server/services/files/index.test.ts index 2448686fd9b0a1..f3cd191a19902f 100644 --- a/x-pack/plugins/fleet/server/services/files/index.test.ts +++ b/x-pack/plugins/fleet/server/services/files/index.test.ts @@ -8,16 +8,19 @@ import type { ElasticsearchClientMock } from '@kbn/core/server/mocks'; import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import { ES_SEARCH_LIMIT } from '../../../common/constants'; import { FILE_STORAGE_DATA_INDEX_PATTERN, FILE_STORAGE_METADATA_INDEX_PATTERN, -} from '../../constants/fleet_es_assets'; +} from '../../../common/constants/file_storage'; + +import { getFileDataIndexName, getFileMetadataIndexName } from '../../../common/services'; + +import { ES_SEARCH_LIMIT } from '../../../common/constants'; import { fileIdsWithoutChunksByIndex, getFilesByStatus, updateFilesStatus } from '.'; -const ENDPOINT_FILE_METADATA_INDEX = '.fleet-endpoint-files'; -const ENDPOINT_FILE_INDEX = '.fleet-endpoint-file-data'; +const ENDPOINT_FILE_METADATA_INDEX = getFileMetadataIndexName('endpoint'); +const ENDPOINT_FILE_INDEX = getFileDataIndexName('endpoint'); describe('files service', () => { let esClientMock: ElasticsearchClientMock; @@ -66,7 +69,7 @@ describe('files service', () => { size: ES_SEARCH_LIMIT, query: { term: { - 'file.Status.keyword': status, + 'file.Status': status, }, }, _source: false, @@ -132,7 +135,7 @@ describe('files service', () => { must: [ { terms: { - 'bid.keyword': Array.from(files.map((file) => file._id)), + bid: Array.from(files.map((file) => file._id)), }, }, { @@ -157,7 +160,7 @@ describe('files service', () => { describe('#updateFilesStatus()', () => { it('calls esClient.updateByQuery with expected values', () => { - const FAKE_INTEGRATION_METADATA_INDEX = '.fleet-someintegration-files'; + const FAKE_INTEGRATION_METADATA_INDEX = getFileMetadataIndexName('someintegration'); const files = { [ENDPOINT_FILE_METADATA_INDEX]: new Set(['delete1', 'delete2']), [FAKE_INTEGRATION_METADATA_INDEX]: new Set(['delete2', 'delete3']), diff --git a/x-pack/plugins/fleet/server/services/files/index.ts b/x-pack/plugins/fleet/server/services/files/index.ts index 77e14496b0a00d..1dadbb66d57884 100644 --- a/x-pack/plugins/fleet/server/services/files/index.ts +++ b/x-pack/plugins/fleet/server/services/files/index.ts @@ -6,13 +6,19 @@ */ import type { ElasticsearchClient } from '@kbn/core/server'; -import type { UpdateByQueryResponse, SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import type { SearchHit, UpdateByQueryResponse } from '@elastic/elasticsearch/lib/api/types'; import type { FileStatus } from '@kbn/files-plugin/common/types'; import { FILE_STORAGE_DATA_INDEX_PATTERN, FILE_STORAGE_METADATA_INDEX_PATTERN, -} from '../../constants/fleet_es_assets'; +} from '../../../common/constants'; + +import { + getFileMetadataIndexName, + getIntegrationNameFromFileDataIndexName, +} from '../../../common/services'; + import { ES_SEARCH_LIMIT } from '../../../common/constants'; /** @@ -34,7 +40,7 @@ export async function getFilesByStatus( size: ES_SEARCH_LIMIT, query: { term: { - 'file.Status.keyword': status, + 'file.Status': status, }, }, _source: false, @@ -82,7 +88,7 @@ export async function fileIdsWithoutChunksByIndex( must: [ { terms: { - 'bid.keyword': Array.from(allFileIds), + bid: Array.from(allFileIds), }, }, { @@ -102,8 +108,8 @@ export async function fileIdsWithoutChunksByIndex( chunks.hits.hits.forEach((hit) => { const fileId = hit._source?.bid; if (!fileId) return; - const integration = hit._index.split('-')[1]; - const metadataIndex = `.fleet-${integration}-files`; + const integration = getIntegrationNameFromFileDataIndexName(hit._index); + const metadataIndex = getFileMetadataIndexName(integration); if (noChunkFileIdsByIndex[metadataIndex]?.delete(fileId)) { allFileIds.delete(fileId); } diff --git a/x-pack/plugins/fleet/server/tasks/check_deleted_files_task.test.ts b/x-pack/plugins/fleet/server/tasks/check_deleted_files_task.test.ts index fac96a8e0b15e3..353b52acc8c13d 100644 --- a/x-pack/plugins/fleet/server/tasks/check_deleted_files_task.test.ts +++ b/x-pack/plugins/fleet/server/tasks/check_deleted_files_task.test.ts @@ -13,15 +13,17 @@ import type { CoreSetup } from '@kbn/core/server'; import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { getFileDataIndexName, getFileMetadataIndexName } from '../../common'; + import { createAppContextStartContractMock } from '../mocks'; -import { - FILE_STORAGE_DATA_INDEX_PATTERN, - FILE_STORAGE_METADATA_INDEX_PATTERN, -} from '../constants/fleet_es_assets'; + import { appContextService } from '../services'; import { CheckDeletedFilesTask, TYPE, VERSION } from './check_deleted_files_task'; +const MOCK_FILE_METADATA_INDEX = getFileMetadataIndexName('mock'); +const MOCK_FILE_DATA_INDEX = getFileDataIndexName('mock'); + const MOCK_TASK_INSTANCE = { id: `${TYPE}:${VERSION}`, runAt: new Date(), @@ -118,12 +120,12 @@ describe('check deleted files task', () => { hits: [ { _id: 'metadata-testid1', - _index: FILE_STORAGE_METADATA_INDEX_PATTERN, + _index: MOCK_FILE_METADATA_INDEX, _source: { file: { status: 'READY' } }, }, { _id: 'metadata-testid2', - _index: FILE_STORAGE_METADATA_INDEX_PATTERN, + _index: MOCK_FILE_METADATA_INDEX, _source: { file: { status: 'READY' } }, }, ], @@ -147,7 +149,7 @@ describe('check deleted files task', () => { hits: [ { _id: 'data-testid1', - _index: FILE_STORAGE_DATA_INDEX_PATTERN, + _index: MOCK_FILE_DATA_INDEX, _source: { bid: 'metadata-testid1', }, @@ -160,7 +162,7 @@ describe('check deleted files task', () => { expect(esClient.updateByQuery).toHaveBeenCalledWith( { - index: FILE_STORAGE_METADATA_INDEX_PATTERN, + index: MOCK_FILE_METADATA_INDEX, query: { ids: { values: ['metadata-testid2'], diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 078adada6d2b67..c34236a908e497 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -6,6 +6,8 @@ */ /** endpoint data streams that are used for host isolation */ +import { getFileDataIndexName, getFileMetadataIndexName } from '@kbn/fleet-plugin/common'; + /** for index patterns `.logs-endpoint.actions-* and .logs-endpoint.action.responses-*`*/ export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions'; export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; @@ -42,8 +44,8 @@ export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; // File storage indexes supporting endpoint Upload/download -export const FILE_STORAGE_METADATA_INDEX = '.fleet-endpoint-files'; -export const FILE_STORAGE_DATA_INDEX = '.fleet-endpoint-file-data'; +export const FILE_STORAGE_METADATA_INDEX = getFileMetadataIndexName('endpoint'); +export const FILE_STORAGE_DATA_INDEX = getFileDataIndexName('endpoint'); // Endpoint API routes export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index 538efd053611a6..4a7eeb07fb82af 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -89,9 +89,16 @@ export class EndpointActionGenerator extends BaseDataGenerator { type: 'json', content: { code: 'ra_get-file_success_done', - path: '/some/path/bad_file.txt', - size: 1234, zip_size: 123, + contents: [ + { + type: 'file', + path: '/some/path/bad_file.txt', + size: 1234, + file_name: 'bad_file.txt', + sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f', + }, + ], }, }; } diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 8ac8745a5d35bc..3597e1e7f6e9b6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -53,9 +53,15 @@ export interface KillProcessActionOutputContent { export interface ResponseActionGetFileOutputContent { code: string; - path: string; - size: number; zip_size: number; + /** The contents of the zip file. One entry per file */ + contents: Array<{ + path: string; + sha256: string; + size: number; + file_name: string; + type: string; + }>; } export const ActivityLogItemTypes = { diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index a7dfd6025ed1a5..936257003b755c 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -70,7 +70,7 @@ export const allowedExperimentalValues = Object.freeze({ * Enables endpoint package level rbac for response actions only. * if endpointRbacEnabled is enabled, it will take precedence. */ - endpointRbacV1Enabled: false, + endpointRbacV1Enabled: true, /** * Enables the Guided Onboarding tour in security diff --git a/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx index 55d6730b8dc8cc..1711aee8b1932d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/integration_tests/index.test.tsx @@ -96,7 +96,7 @@ describe('when in the Administration tab', () => { }); mockedContext.history.push('/administration/response_actions_history'); - expect(await render().findByTestId('noIngestPermissions')).toBeTruthy(); + expect(await render().findByTestId('noPrivilegesPage')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index c4113f0132c053..8247006e079259 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -281,15 +281,22 @@ const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => { output: { type: 'json', content: { - code: 'ra_get-file-success', - path: ( - action as ActionDetails< - ResponseActionGetFileOutputContent, - ResponseActionGetFileParameters - > - ).parameters?.path, - size: 1234, + code: 'ra_get-file_success_done', zip_size: 123, + contents: [ + { + type: 'file', + path: ( + action as ActionDetails< + ResponseActionGetFileOutputContent, + ResponseActionGetFileParameters + > + ).parameters?.path, + size: 1234, + file_name: 'bad_file.txt', + sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f', + }, + ], }, }, } as ResponseOutput; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts index cb161ac189d590..18e09d26896236 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts @@ -99,8 +99,6 @@ export const getFileInfo = async ( } } - // TODO: add `ttl` to the return payload by retrieving the value from ILM? - return { name, id, @@ -123,7 +121,7 @@ const doesFileHaveChunks = async ( body: { query: { term: { - 'bid.keyword': fileId, + bid: fileId, }, }, // Setting `_source` to false - we don't need the actual document to be returned diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts index 920540fd300823..cd97bf96673bfd 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts @@ -445,8 +445,15 @@ describe('When using Actions service utilities', () => { '456': { content: { code: 'ra_get-file_success_done', - path: '/some/path/bad_file.txt', - size: 1234, + contents: [ + { + file_name: 'bad_file.txt', + path: '/some/path/bad_file.txt', + sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f', + size: 1234, + type: 'file', + }, + ], zip_size: 123, }, type: 'json', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 987c22e35064f1..d11ad982a0f45b 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -29,7 +29,17 @@ export default function ({ getService }: FtrProviderContext) { actions: ['all', 'read', 'minimal_all', 'minimal_read'], stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'], ml: ['all', 'read', 'minimal_all', 'minimal_read'], - siem: ['all', 'read', 'minimal_all', 'minimal_read'], + siem: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'actions_log_management_all', + 'actions_log_management_read', + 'host_isolation_all', + 'process_operations_all', + 'file_operations_all', + ], uptime: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 3d470259267e41..ba4fefd9ae691c 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -96,7 +96,17 @@ export default function ({ getService }: FtrProviderContext) { actions: ['all', 'read', 'minimal_all', 'minimal_read'], stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'], ml: ['all', 'read', 'minimal_all', 'minimal_read'], - siem: ['all', 'read', 'minimal_all', 'minimal_read'], + siem: [ + 'actions_log_management_all', + 'actions_log_management_read', + 'all', + 'file_operations_all', + 'host_isolation_all', + 'minimal_all', + 'minimal_read', + 'process_operations_all', + 'read', + ], uptime: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/fleet_api_integration/apis/agents/uploads.ts b/x-pack/test/fleet_api_integration/apis/agents/uploads.ts index fc56b4fc46c18f..07e44bfa2a641c 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/uploads.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/uploads.ts @@ -7,6 +7,10 @@ import expect from '@kbn/expect'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import { + FILE_STORAGE_DATA_AGENT_INDEX, + FILE_STORAGE_METADATA_AGENT_INDEX, +} from '@kbn/fleet-plugin/server/constants'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; import { skipIfNoDockerRegistry } from '../../helpers'; @@ -57,7 +61,7 @@ export default function (providerContext: FtrProviderContext) { ); await esClient.update({ - index: '.fleet-agent-files', + index: FILE_STORAGE_METADATA_AGENT_INDEX, id: 'file1', refresh: true, body: { @@ -102,7 +106,7 @@ export default function (providerContext: FtrProviderContext) { it('should get agent uploaded file', async () => { await esClient.update({ - index: '.fleet-agent-file-data', + index: FILE_STORAGE_DATA_AGENT_INDEX, id: 'file1.0', refresh: true, body: { From 6c9cc6626b9c9fd0a2411426949f20df15d6c7fa Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 14 Nov 2022 17:25:57 +0200 Subject: [PATCH 17/20] [Cases] Fix selected label in the assignees filtering (#145113) ## Summary With the introduction of the "No assignees" filtering in https://github.com/elastic/kibana/pull/143390 we no longer have assignees for filtering. Having the text say "1 assignee selected" when selecting the "No assignees" filtering is misleading. This PR fixes this issue with the label. Screenshot 2022-11-14 at 3 52 28 PM Screenshot 2022-11-14 at 3 52 20 PM Screenshot 2022-11-14 at 3 51 48 PM ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [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 ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../all_cases/assignees_filter.test.tsx | 20 ++++++++++++++++++- .../components/all_cases/translations.ts | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx index c1b0d0cf2445f8..e6592d1358bdea 100644 --- a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx @@ -132,7 +132,7 @@ describe('AssigneesFilterPopover', () => { await waitFor(async () => { userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); - expect(screen.getByText('1 assignee filtered')).toBeInTheDocument(); + expect(screen.getByText('1 filter selected')).toBeInTheDocument(); }); await waitForEuiPopoverOpen(); @@ -140,6 +140,24 @@ describe('AssigneesFilterPopover', () => { expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); }); + it('shows the total when the multiple users are selected', async () => { + const props = { + ...defaultProps, + selectedAssignees: [userProfiles[0], userProfiles[1]], + }; + appMockRender.render(); + + await waitFor(async () => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('2 filters selected')).toBeInTheDocument(); + }); + + await waitForEuiPopoverOpen(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + }); + it('shows three users when initially rendered', async () => { appMockRender.render(); diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 1a215cd1ef8891..2c5f2697541780 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -127,7 +127,7 @@ export const CLEAR_FILTERS = i18n.translate( export const TOTAL_ASSIGNEES_FILTERED = (total: number) => i18n.translate('xpack.cases.allCasesView.totalFilteredUsers', { - defaultMessage: '{total, plural, one {# assignee} other {# assignees}} filtered', + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', values: { total }, }); From e27be55baeedc246fae72755a6657d57d2ebd56f Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Mon, 14 Nov 2022 10:35:36 -0500 Subject: [PATCH 18/20] [Security Solution] [Exceptions] Adds a modal to confirm deletion of exception list (#145034) --- .../cypress/tasks/exceptions_table.ts | 2 ++ .../exceptions/pages/shared_lists/index.tsx | 26 +++++-------------- .../exceptions/translations/shared_list.ts | 6 +++++ 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts index cee6929b3887de..482ea64cf4a8bd 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts @@ -32,6 +32,8 @@ export const exportExceptionList = () => { export const deleteExceptionListWithoutRuleReference = () => { cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click(); cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('exist'); + cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click(); cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx index 0218dda4dbf5f7..d2d1ecf20ad366 100644 --- a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx @@ -129,20 +129,12 @@ export const SharedLists = React.memo(() => { ({ id, listId, namespaceType }: { id: string; listId: string; namespaceType: NamespaceType }) => async () => { try { - if (exceptionsListsRef[id] != null && exceptionsListsRef[id].rules.length === 0) { - await deleteExceptionList({ - id, - namespaceType, - onError: handleDeleteError, - onSuccess: handleDeleteSuccess(listId), - }); - - if (refreshExceptions != null) { - refreshExceptions(); - } - } else { + if (exceptionsListsRef[id] != null) { setReferenceModalState({ - contentText: i18n.referenceErrorMessage(exceptionsListsRef[id].rules.length), + contentText: + exceptionsListsRef[id].rules.length > 0 + ? i18n.referenceErrorMessage(exceptionsListsRef[id].rules.length) + : i18n.defaultDeleteListMessage(exceptionsListsRef[id].name), rulesReferences: exceptionsListsRef[id].rules.map(({ name }) => name), isLoading: true, listId: id, @@ -155,13 +147,7 @@ export const SharedLists = React.memo(() => { handleDeleteError(error); } }, - [ - deleteExceptionList, - exceptionsListsRef, - handleDeleteError, - handleDeleteSuccess, - refreshExceptions, - ] + [exceptionsListsRef, handleDeleteError] ); const handleExportSuccess = useCallback( diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts index 5eaf096400db7e..e545b782951c53 100644 --- a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts +++ b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts @@ -133,6 +133,12 @@ export const REFERENCE_MODAL_TITLE = i18n.translate( } ); +export const defaultDeleteListMessage = (listName: string) => + i18n.translate('xpack.securitySolution.exceptions.referenceModalDefaultDescription', { + defaultMessage: 'Are you sure you wish to DELETE exception list with the name {listName}?', + values: { listName }, + }); + export const REFERENCE_MODAL_CANCEL_BUTTON = i18n.translate( 'xpack.securitySolution.exceptions.referenceModalCancelButton', { From 843eefa7a7e596d6961aa1e1bdda98f530297113 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Mon, 14 Nov 2022 16:36:14 +0100 Subject: [PATCH 19/20] [Cases] truncate long case name by word break (#145003) ## Summary This PR updates uses `break-word` to display text into the next line for the long case name on the case management page. Fixes: https://github.com/elastic/kibana/issues/142647 **Before** ![image](https://user-images.githubusercontent.com/117571355/201154944-f61db2f9-e9d8-4e29-bdbb-240f69e4e2d9.png) ![image](https://user-images.githubusercontent.com/117571355/201155420-44c7cb68-64a5-4c29-8c78-cc0c02e8dc2f.png) **After** ![image](https://user-images.githubusercontent.com/117571355/201155177-9d52bc3e-7d24-4a8b-a9b0-a490fd8375e4.png) ![image](https://user-images.githubusercontent.com/117571355/201155281-a4f2e27f-a0a0-46be-9ce7-aa11f05d955a.png) ### Checklist - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- x-pack/plugins/cases/public/common/use_cases_toast.tsx | 1 + x-pack/plugins/cases/public/components/truncated_text/index.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index 984a5ed607351b..d866f9791fc2d6 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -32,6 +32,7 @@ const Title = styled.span` -webkit-line-clamp: ${LINE_CLAMP}; -webkit-box-orient: vertical; overflow: hidden; + word-break: break-word; `; const EuiTextStyled = styled(EuiText)` ${({ theme }) => ` diff --git a/x-pack/plugins/cases/public/components/truncated_text/index.tsx b/x-pack/plugins/cases/public/components/truncated_text/index.tsx index 7d663dc989c967..83005f8c92e89e 100644 --- a/x-pack/plugins/cases/public/components/truncated_text/index.tsx +++ b/x-pack/plugins/cases/public/components/truncated_text/index.tsx @@ -16,7 +16,7 @@ const Text = styled.span` -webkit-line-clamp: ${LINE_CLAMP}; -webkit-box-orient: vertical; overflow: hidden; - word-break: normal; + word-break: break-word; `; interface Props { From 9fda59f5129203122765d6913abab3455bb4cdee Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 14 Nov 2022 10:41:52 -0500 Subject: [PATCH 20/20] [security solution][endpoint] Add new experimental feature flag for `get-file` and use it hide/display `get-file` response action (#145042) ## Summary - Adds new experimental feature flag that controls the availability of the `get-file` response action - UI updated to remove `get-file` from the console if FF is `false` - Server APIs updated to not register `get-file` related APIs if FF is `false` - Hides the "File Operation" kibana feature privilege --- .../common/experimental_features.ts | 5 ++++ .../experimental_features_service.ts | 25 +++++++++++++++++ .../get_file_action.test.tsx | 1 + .../get_processes_action.test.tsx | 2 ++ .../integration_tests/isolate_action.test.tsx | 2 ++ .../kill_process_action.test.tsx | 2 ++ .../integration_tests/release_action.test.tsx | 2 ++ .../suspend_process_action.test.tsx | 2 ++ .../lib/console_commands_definition.ts | 17 +++++++++--- .../components/hooks.tsx | 13 ++++++++- .../response_actions_log.test.tsx | 2 ++ .../view/response_actions_list_page.test.tsx | 2 ++ .../security_solution/server/config.mock.ts | 5 +++- .../server/endpoint/routes/actions/index.ts | 8 ++++-- .../routes/actions/response_actions.ts | 27 ++++++++++--------- .../security_solution/server/features.ts | 14 +++++++--- 16 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 936257003b755c..bb812c226b605a 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -81,6 +81,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables the alert details page currently only accessible via the alert details flyout and alert table context menu */ alertDetailsPageEnabled: false, + + /** + * Enables the `get-file` endpoint response action + */ + responseActionGetFileEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts b/x-pack/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts new file mode 100644 index 00000000000000..bba0299899f427 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExperimentalFeatures } from '../../../common/experimental_features'; +import { allowedExperimentalValues } from '../../../common/experimental_features'; + +const ExperimentalFeaturesServiceMock = { + init: jest.fn(), + + get: jest.fn(() => { + const ff: ExperimentalFeatures = { + ...allowedExperimentalValues, + + responseActionGetFileEnabled: true, + }; + + return ff; + }), +}; + +export { ExperimentalFeaturesServiceMock as ExperimentalFeaturesService }; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx index d62738896b3c2e..6f8f4f3177ea83 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_file_action.test.tsx @@ -30,6 +30,7 @@ import type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser'; import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; jest.mock('../../../../../common/components/user_privileges'); +jest.mock('../../../../../common/experimental_features_service'); describe('When using get-file action from response actions console', () => { let render: ( diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx index a99e8cdd17ef7b..2851ac8640fb70 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/get_processes_action.test.tsx @@ -20,6 +20,8 @@ import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/ import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; +jest.mock('../../../../../common/experimental_features_service'); + describe('When using processes action from response actions console', () => { let render: ( capabilities?: EndpointCapabilities[] diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/isolate_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/isolate_action.test.tsx index ab98acc5bd3903..e25df018e2bd10 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/isolate_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/isolate_action.test.tsx @@ -21,6 +21,8 @@ import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/ import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; +jest.mock('../../../../../common/experimental_features_service'); + describe('When using isolate action from response actions console', () => { let render: ( capabilities?: EndpointCapabilities[] diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx index e65a8d8e751e47..3f8160f5cf19cf 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/kill_process_action.test.tsx @@ -25,6 +25,8 @@ import type { } from '../../../../../../common/endpoint/types'; import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; +jest.mock('../../../../../common/experimental_features_service'); + describe('When using the kill-process action from response actions console', () => { let render: ( capabilities?: EndpointCapabilities[] diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/release_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/release_action.test.tsx index 633c82a47897fd..ec75d6aaa28dfc 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/release_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/release_action.test.tsx @@ -21,6 +21,8 @@ import { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/ import type { EndpointCapabilities } from '../../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants'; +jest.mock('../../../../../common/experimental_features_service'); + describe('When using the release action from response actions console', () => { let render: ( capabilities?: EndpointCapabilities[] diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx index e02bbcb689c571..fead471cd0c136 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/suspend_process_action.test.tsx @@ -25,6 +25,8 @@ import type { } from '../../../../../../common/endpoint/types'; import { endpointActionResponseCodes } from '../../lib/endpoint_action_response_codes'; +jest.mock('../../../../../common/experimental_features_service'); + describe('When using the suspend-process action from response actions console', () => { let render: ( capabilities?: EndpointCapabilities[] diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index b74304a21280ae..b4068c95d14071 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ExperimentalFeaturesService } from '../../../../common/experimental_features_service'; import type { EndpointCapabilities, ConsoleResponseActionCommands, @@ -134,6 +135,8 @@ export const getEndpointConsoleCommands = ({ endpointCapabilities: ImmutableArray; endpointPrivileges: EndpointPrivileges; }): CommandDefinition[] => { + const isGetFileEnabled = ExperimentalFeaturesService.get().responseActionGetFileEnabled; + const doesEndpointSupportCommand = (commandName: ConsoleResponseActionCommands) => { const responderCapability = commandToCapabilitiesMap.get(commandName); if (responderCapability) { @@ -142,7 +145,7 @@ export const getEndpointConsoleCommands = ({ return false; }; - return [ + const consoleCommands: CommandDefinition[] = [ { name: 'isolate', about: getCommandAboutInfo({ @@ -365,7 +368,11 @@ export const getEndpointConsoleCommands = ({ helpDisabled: doesEndpointSupportCommand('processes') === false, helpHidden: !getRbacControl({ commandName: 'processes', privileges: endpointPrivileges }), }, - { + ]; + + // `get-file` is currently behind feature flag + if (isGetFileEnabled) { + consoleCommands.push({ name: 'get-file', about: getCommandAboutInfo({ aboutInfo: i18n.translate('xpack.securitySolution.endpointConsoleCommands.getFile.about', { @@ -411,6 +418,8 @@ export const getEndpointConsoleCommands = ({ commandName: 'get-file', privileges: endpointPrivileges, }), - }, - ]; + }); + } + + return consoleCommands; }; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx index e72e8614d67645..cc36974c3fe69b 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx @@ -10,6 +10,7 @@ import type { DurationRange, OnRefreshChangeProps, } from '@elastic/eui/src/components/date_picker/types'; +import { ExperimentalFeaturesService } from '../../../../common/experimental_features_service'; import type { ConsoleResponseActionCommands, ResponseActionsApiCommandNames, @@ -232,7 +233,17 @@ export const useActionsLogFilter = ({ })) : isHostsFilter ? [] - : RESPONSE_ACTION_API_COMMANDS_NAMES.map((commandName) => ({ + : RESPONSE_ACTION_API_COMMANDS_NAMES.filter((commandName) => { + // `get-file` is currently behind FF + if ( + commandName === 'get-file' && + !ExperimentalFeaturesService.get().responseActionGetFileEnabled + ) { + return false; + } + + return true; + }).map((commandName) => ({ key: commandName, label: getUiCommand(commandName), checked: diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx index a5d05026d30d0a..64b30352771364 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx @@ -119,6 +119,8 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { jest.mock('../../hooks/endpoint/use_get_endpoints_list'); +jest.mock('../../../common/experimental_features_service'); + jest.mock('../../../common/components/user_privileges'); let mockUseGetFileInfo: { diff --git a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx index a0b3b9050b7ab5..8b82608f27783e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx @@ -20,6 +20,8 @@ import { MANAGEMENT_PATH } from '../../../../../common/constants'; import { getActionListMock } from '../../../components/endpoint_response_actions_list/mocks'; import { useGetEndpointsList } from '../../../hooks/endpoint/use_get_endpoints_list'; +jest.mock('../../../../common/experimental_features_service'); + let mockUseGetEndpointActionList: { isFetched?: boolean; isFetching?: boolean; diff --git a/x-pack/plugins/security_solution/server/config.mock.ts b/x-pack/plugins/security_solution/server/config.mock.ts index d643c5d22b4346..da20826276ae6e 100644 --- a/x-pack/plugins/security_solution/server/config.mock.ts +++ b/x-pack/plugins/security_solution/server/config.mock.ts @@ -11,7 +11,10 @@ import { parseExperimentalConfigValue } from '../common/experimental_features'; import type { ConfigType } from './config'; export const createMockConfig = (): ConfigType => { - const enableExperimental: string[] = []; + const enableExperimental: Array = [ + // Remove property below once `get-file` FF is enabled or removed + 'responseActionGetFileEnabled', + ]; return { [SIGNALS_INDEX_KEY]: DEFAULT_SIGNALS_INDEX, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index e5b3e90de88785..6bba9bc38c9627 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -25,7 +25,11 @@ export function registerActionRoutes( registerActionAuditLogRoutes(router, endpointContext); registerActionListRoutes(router, endpointContext); registerActionDetailsRoutes(router, endpointContext); - registerActionFileDownloadRoutes(router, endpointContext); registerResponseActionRoutes(router, endpointContext); - registerActionFileInfoRoute(router, endpointContext); + + // APIs specific to `get-file` are behind FF + if (endpointContext.experimentalFeatures.responseActionGetFileEnabled) { + registerActionFileDownloadRoutes(router, endpointContext); + registerActionFileInfoRoute(router, endpointContext); + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts index 1ddf38e390d10f..ec08ef4c7fd835 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts @@ -160,18 +160,21 @@ export function registerResponseActionRoutes( ) ); - router.post( - { - path: GET_FILE_ROUTE, - validate: EndpointActionGetFileSchema, - options: { authRequired: true, tags: ['access:securitySolution'] }, - }, - withEndpointAuthz( - { all: ['canWriteFileOperations'] }, - logger, - responseActionRequestHandler(endpointContext, 'get-file') - ) - ); + // `get-file` currently behind FF + if (endpointContext.experimentalFeatures.responseActionGetFileEnabled) { + router.post( + { + path: GET_FILE_ROUTE, + validate: EndpointActionGetFileSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { all: ['canWriteFileOperations'] }, + logger, + responseActionRequestHandler(endpointContext, 'get-file') + ) + ); + } } const commandToFeatureKeyMap = new Map([ diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index 8dd4b56f655b63..4711e978a25fed 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -495,15 +495,21 @@ const subFeatures: SubFeatureConfig[] = [ ]; function getSubFeatures(experimentalFeatures: ConfigType['experimentalFeatures']) { + let filteredSubFeatures: SubFeatureConfig[] = []; + if (experimentalFeatures.endpointRbacEnabled) { - return subFeatures; + filteredSubFeatures = subFeatures; + } else if (experimentalFeatures.endpointRbacV1Enabled) { + filteredSubFeatures = responseActionSubFeatures; } - if (experimentalFeatures.endpointRbacV1Enabled) { - return responseActionSubFeatures; + if (!experimentalFeatures.responseActionGetFileEnabled) { + filteredSubFeatures = filteredSubFeatures.filter((subFeat) => { + return subFeat.name !== 'File Operations'; + }); } - return []; + return filteredSubFeatures; } export const getKibanaPrivilegesFeaturePrivileges = (