diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index 4d96e1d605ab3..0a83a45de1d03 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -75,6 +75,8 @@ export interface SearchBarOwnProps { onSaved?: (savedQuery: SavedQuery) => void; // User has modified the saved query, your app should persist the update onSavedQueryUpdated?: (savedQuery: SavedQuery) => void; + // Execute whenever time range is updated. + onTimeRangeChange?: (payload: { dateRange: TimeRange }) => void; // User has cleared the active query, your app should clear the entire query bar onClearSavedQuery?: () => void; @@ -187,6 +189,19 @@ class SearchBarUI extends C if (nextDateRange) { nextState.dateRangeFrom = nextDateRange.dateRangeFrom; nextState.dateRangeTo = nextDateRange.dateRangeTo; + + /** + * Some applications do not rely on the _g url parameter to update the time. The onTimeRangeChange + * callback can be used in these cases to notify the consumer for the time change. + */ + if (nextDateRange.dateRangeFrom && nextDateRange.dateRangeTo) { + nextProps?.onTimeRangeChange?.({ + dateRange: { + from: nextDateRange.dateRangeFrom, + to: nextDateRange.dateRangeTo, + }, + }); + } } return nextState; } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 8794e81e4a441..1ae377fd65b96 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -97,7 +97,7 @@ export enum SecurityPageName { * All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts */ cloudDefendPolicies = 'cloud_defend-policies', - dashboardsLanding = 'dashboards', + dashboards = 'dashboards', dataQuality = 'data_quality', detections = 'detections', detectionAndResponse = 'detection_response', diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 87d43742a9433..7ea8a9f51f406 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -132,7 +132,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], }, { - id: SecurityPageName.dashboardsLanding, + id: SecurityPageName.dashboards, title: DASHBOARDS, path: OVERVIEW_PATH, navLinkStatus: AppNavLinkStatus.visible, diff --git a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.test.tsx b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.test.tsx new file mode 100644 index 0000000000000..78c57d598c3dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../mock'; +import { DashboardRenderer } from './dashboard_renderer'; + +jest.mock('@kbn/dashboard-plugin/public', () => { + const actual = jest.requireActual('@kbn/dashboard-plugin/public'); + return { + ...actual, + LazyDashboardContainerRenderer: jest + .fn() + .mockImplementation(() =>
), + }; +}); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn().mockReturnValue({ + detailName: '2d50f100-be6f-11ed-964a-ffa67304840e', + }), + }; +}); + +describe('DashboardRenderer', () => { + const props = { + canReadDashboard: true, + id: 'dashboard-savedObjectId', + savedObjectId: 'savedObjectId', + timeRange: { + from: '2023-03-10T00:00:00.000Z', + to: '2023-03-10T23:59:59.999Z', + }, + }; + + it('renders', () => { + const { queryByTestId } = render(, { wrapper: TestProviders }); + expect(queryByTestId(`dashboardRenderer`)).toBeInTheDocument(); + }); + + it('does not render when No Read Permission', () => { + const testProps = { + ...props, + canReadDashboard: false, + }; + const { queryByTestId } = render(, { + wrapper: TestProviders, + }); + expect(queryByTestId(`dashboardRenderer`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.tsx b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.tsx new file mode 100644 index 0000000000000..e8c521e9fd8c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/dashboards/dashboard_renderer.tsx @@ -0,0 +1,91 @@ +/* + * 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, useEffect, useState } from 'react'; +import type { DashboardContainer } from '@kbn/dashboard-plugin/public'; +import { LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { Filter, Query } from '@kbn/es-query'; + +import { useDispatch } from 'react-redux'; +import { InputsModelId } from '../../store/inputs/constants'; +import { inputsActions } from '../../store/inputs'; + +const DashboardRendererComponent = ({ + canReadDashboard, + filters, + id, + inputId = InputsModelId.global, + onDashboardContainerLoaded, + query, + savedObjectId, + timeRange, +}: { + canReadDashboard: boolean; + filters?: Filter[]; + id: string; + inputId?: InputsModelId.global | InputsModelId.timeline; + onDashboardContainerLoaded?: (dashboardContainer: DashboardContainer) => void; + query?: Query; + savedObjectId: string | undefined; + timeRange: { + from: string; + fromStr?: string | undefined; + to: string; + toStr?: string | undefined; + }; +}) => { + const dispatch = useDispatch(); + const [dashboardContainer, setDashboardContainer] = useState(); + + const getCreationOptions = useCallback( + () => + Promise.resolve({ + overrideInput: { timeRange, viewMode: ViewMode.VIEW, query, filters }, + }), + [filters, query, timeRange] + ); + + const refetchByForceRefresh = useCallback(() => { + dashboardContainer?.forceRefresh(); + }, [dashboardContainer]); + + useEffect(() => { + dispatch( + inputsActions.setQuery({ + inputId, + id, + refetch: refetchByForceRefresh, + loading: false, + inspect: null, + }) + ); + return () => { + dispatch(inputsActions.deleteOneQuery({ inputId, id })); + }; + }, [dispatch, id, inputId, refetchByForceRefresh]); + + useEffect(() => { + dashboardContainer?.updateInput({ timeRange, query, filters }); + }, [dashboardContainer, filters, query, timeRange]); + + const handleDashboardLoaded = useCallback( + (container: DashboardContainer) => { + setDashboardContainer(container); + onDashboardContainerLoaded?.(container); + }, + [onDashboardContainerLoaded] + ); + return savedObjectId && canReadDashboard ? ( + + ) : null; +}; +DashboardRendererComponent.displayName = 'DashboardRendererComponent'; +export const DashboardRenderer = React.memo(DashboardRendererComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 6dd4a0e94530d..2b0258d74a45f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -19,6 +19,7 @@ import { getTrailingBreadcrumbs as getCSPBreadcrumbs } from '../../../../cloud_s import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../explore/users/pages/details/utils'; import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs'; import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs'; +import { getTrailingBreadcrumbs as getDashboardBreadcrumbs } from '../../../../dashboards/pages/utils'; import { SecurityPageName } from '../../../../app/types'; import type { RouteSpyState } from '../../../utils/route/types'; import { timelineActions } from '../../../../timelines/store/timeline'; @@ -134,6 +135,8 @@ const getTrailingBreadcrumbsForRoutes = ( return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl); case SecurityPageName.cloudSecurityPostureBenchmarks: return getCSPBreadcrumbs(spyState, getSecuritySolutionUrl); + case SecurityPageName.dashboards: + return getDashboardBreadcrumbs(spyState); } return []; diff --git a/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.test.tsx b/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.test.tsx deleted file mode 100644 index 3621907ea9ef3..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.test.tsx +++ /dev/null @@ -1,33 +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 { PageRoute } from './pageroute'; -import type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; - -describe('pageroute', () => { - const documentTitle = 'Kibana'; - - const fakeComponent = () =>
{'fake component'}
; - let wrapper: ReactWrapper; - beforeAll(() => { - wrapper = mount(); - }); - - afterAll(() => { - document.title = documentTitle; - }); - - test('renders target component correctly', () => { - expect(wrapper.find(fakeComponent)).toBeTruthy(); - }); - - test('updates page title correctly', () => { - expect(document.title).toEqual('test - Kibana'); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.tsx b/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.tsx deleted file mode 100644 index 1270b815d067d..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/page_route/pageroute.tsx +++ /dev/null @@ -1,16 +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, { useEffect } from 'react'; - -export const PageRoute = (props: { title: string; component: React.ReactType }) => { - const { title, ...rest } = props; - useEffect(() => { - document.title = `${title} - Kibana`; - }, [title]); - return ; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index dfbe1e5a39873..55571cc9dfa69 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -295,7 +295,23 @@ export const SearchBarComponent = memo( }, []); const indexPatterns = useMemo(() => [indexPattern], [indexPattern]); - + const onTimeRangeChange = useCallback( + ({ query, dateRange }) => { + const isQuickSelection = dateRange.from.includes('now') || dateRange.to.includes('now'); + updateSearch({ + end: dateRange.to, + filterManager, + id, + isInvalid: false, + isQuickSelection, + query, + setTablesActivePageToZero, + start: dateRange.from, + updateTime: true, + }); + }, + [filterManager, id, setTablesActivePageToZero, updateSearch] + ); return (
( onQuerySubmit={onQuerySubmit} onRefresh={onRefresh} onSaved={onSaved} + onTimeRangeChange={onTimeRangeChange} onSavedQueryUpdated={onSavedQueryUpdated} savedQuery={savedQuery} showFilterBar={!hideFilterBar} diff --git a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx index 143a1335d4de8..c68ca947b89ce 100644 --- a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.test.tsx @@ -17,9 +17,13 @@ import { useSecurityDashboardsTableItems, } from './use_security_dashboards_table'; import * as telemetry from '../../lib/telemetry'; +import { SecurityPageName } from '../../../../common/constants'; +import * as linkTo from '../../components/link_to'; import type { DashboardTableItem } from './types'; jest.mock('../../lib/kibana'); + +const spyUseGetSecuritySolutionUrl = jest.spyOn(linkTo, 'useGetSecuritySolutionUrl'); const spyTrack = jest.spyOn(telemetry, 'track'); const TAG_ID = 'securityTagId'; @@ -202,4 +206,20 @@ describe('Security Dashboards Table hooks', () => { telemetry.TELEMETRY_EVENT.DASHBOARD ); }); + + it('should land on SecuritySolution dashboard view page when dashboard title clicked', async () => { + const mockGetSecuritySolutionUrl = jest.fn(); + spyUseGetSecuritySolutionUrl.mockImplementation(() => mockGetSecuritySolutionUrl); + const { result: itemsResult } = await renderUseSecurityDashboardsTableItems(); + const { result: columnsResult } = renderUseDashboardsTableColumns(); + + render(, { + wrapper: TestProviders, + }); + + expect(mockGetSecuritySolutionUrl).toHaveBeenCalledWith({ + deepLinkId: SecurityPageName.dashboards, + path: 'dashboardId1', + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx index 9380ce31ea49c..21081a66b7df5 100644 --- a/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/dashboards/use_security_dashboards_table.tsx @@ -14,6 +14,9 @@ import { useKibana, useNavigateTo } from '../../lib/kibana'; import * as i18n from './translations'; import { useFetch, REQUEST_NAMES } from '../../hooks/use_fetch'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry'; +import { SecurityPageName } from '../../../../common/constants'; +import { useGetSecuritySolutionUrl } from '../../components/link_to'; + import type { DashboardTableItem } from './types'; const EMPTY_DESCRIPTION = '-' as const; @@ -49,8 +52,9 @@ export const useSecurityDashboardsTableItems = () => { export const useSecurityDashboardsTableColumns = (): Array< EuiBasicTableColumn > => { - const { savedObjectsTagging, dashboard } = useKibana().services; + const { savedObjectsTagging } = useKibana().services; const { navigateTo } = useNavigateTo(); + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); const getNavigationHandler = useCallback( (href: string): MouseEventHandler => @@ -69,7 +73,10 @@ export const useSecurityDashboardsTableColumns = (): Array< name: i18n.DASHBOARD_TITLE, sortable: true, render: (title: string, { id }) => { - const href = dashboard?.locator?.getRedirectUrl({ dashboardId: id }); + const href = `${getSecuritySolutionUrl({ + deepLinkId: SecurityPageName.dashboards, + path: id, + })}`; return href ? ( {title} @@ -90,7 +97,7 @@ export const useSecurityDashboardsTableColumns = (): Array< // adds the tags table column based on the saved object items ...(savedObjectsTagging ? [savedObjectsTagging.ui.getTableColumnDefinition()] : []), ], - [getNavigationHandler, dashboard, savedObjectsTagging] + [savedObjectsTagging, getSecuritySolutionUrl, getNavigationHandler] ); return columns; diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index c00fa87f878da..51f40c692e8cb 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -11,10 +11,11 @@ import { links as detectionLinks } from '../../detections/links'; import { links as timelinesLinks } from '../../timelines/links'; import { getCasesLinkItems } from '../../cases/links'; import { links as managementLinks, getManagementFilteredLinks } from '../../management/links'; -import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; +import { threatHuntingLandingLinks } from '../../landing_pages/links'; import { gettingStartedLinks } from '../../overview/links'; import { rootLinks as cloudSecurityPostureRootLinks } from '../../cloud_security_posture/links'; import type { StartPlugins } from '../../types'; +import { dashboardsLandingLinks } from '../../dashboards/links'; const casesLinks = getCasesLinkItems(); diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 93aee6676b47e..6e10a6f0d0c86 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -35,6 +35,7 @@ export type RouteSpyState = | GenericRouteSpyState | GenericRouteSpyState | GenericRouteSpyState + | GenericRouteSpyState | GenericRouteSpyState< Exclude< SecurityPageName, @@ -59,6 +60,7 @@ export type AdministrationRouteSpyState = GenericRouteSpyState< SecurityPageName.administration, AdministrationType >; +export type DashboardsRouteSpyState = GenericRouteSpyState; export type RouteSpyAction = | { diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx new file mode 100644 index 0000000000000..e4cd6fa206929 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.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 { render } from '@testing-library/react'; +import React from 'react'; +import { useKibana } from '../../common/lib/kibana'; +import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; +import { TestProviders } from '../../common/mock/test_providers'; +import { EditDashboardButton } from './edit_dashboard_button'; + +jest.mock('../../common/lib/kibana/kibana_react', () => { + return { + useKibana: jest.fn(), + }; +}); + +describe('EditDashboardButton', () => { + const timeRange = { + from: '2023-03-24T00:00:00.000Z', + to: '2023-03-24T23:59:59.999Z', + }; + + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: createStartServicesMock(), + }); + }); + + it('should render', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('dashboardEditButton')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx new file mode 100644 index 0000000000000..c1089e95787e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { Query, Filter } from '@kbn/es-query'; +import { EuiButton } from '@elastic/eui'; +import { useDashboardAppLink } from '../hooks/use_dashboard_app_link'; +import { EDIT_DASHBOARD_BUTTON_TITLE } from '../pages/details/translations'; +import { useKibana } from '../../common/lib/kibana'; + +export interface EditDashboardButtonComponentProps { + filters?: Filter[]; + query?: Query; + savedObjectId: string | undefined; + timeRange: { + from: string; + to: string; + fromStr?: string | undefined; + toStr?: string | undefined; + }; +} + +const EditDashboardButtonComponent: React.FC = ({ + filters, + query, + savedObjectId, + timeRange, +}) => { + const { + services: { uiSettings }, + } = useKibana(); + + const { onClick } = useDashboardAppLink({ + query, + filters, + timeRange, + uiSettings, + savedObjectId, + }); + + return ( + + {EDIT_DASHBOARD_BUTTON_TITLE} + + ); +}; + +EditDashboardButtonComponent.displayName = 'EditDashboardComponent'; +export const EditDashboardButton = React.memo(EditDashboardButtonComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.test.tsx new file mode 100644 index 0000000000000..c173aa836af55 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { render } from '@testing-library/react'; +import React from 'react'; +import { DashboardViewPromptState } from '../hooks/use_dashboard_view_prompt_state'; +import { StatusPropmpt } from './status_prompt'; + +describe('StatusPropmpt', () => { + it('hides by default', () => { + const { queryByTestId } = render(); + expect(queryByTestId(`dashboardViewEmptyDefault`)).not.toBeInTheDocument(); + }); + + it('shows when No Read Permission', () => { + const { queryByTestId } = render( + + ); + + expect( + queryByTestId(`dashboardViewEmpty${DashboardViewPromptState.NoReadPermission}`) + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.tsx b/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.tsx new file mode 100644 index 0000000000000..7f0584ec0e882 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/status_prompt.tsx @@ -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 React from 'react'; +import { EuiPageTemplate } from '@elastic/eui'; +import type { DashboardViewPromptState } from '../hooks/use_dashboard_view_prompt_state'; +import { useDashboardViewPromptState } from '../hooks/use_dashboard_view_prompt_state'; + +const StatusPropmptComponent = ({ + currentState, +}: { + currentState: DashboardViewPromptState | null; +}) => { + const emptyPromptProps = useDashboardViewPromptState(currentState); + return emptyPromptProps && currentState ? ( + + + + ) : null; +}; +StatusPropmptComponent.displayName = 'StatusPropmptComponent'; +export const StatusPropmpt = React.memo(StatusPropmptComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.test.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.test.tsx new file mode 100644 index 0000000000000..decd108b6d6af --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.test.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useNavigation } from '../../common/lib/kibana'; +import { TestProviders } from '../../common/mock'; +import type { UseDashboardAppLinkProps } from './use_dashboard_app_link'; +import { useDashboardAppLink } from './use_dashboard_app_link'; + +jest.mock('../../common/lib/kibana', () => ({ + useNavigation: jest.fn(), +})); + +describe('useDashboardAppLink', () => { + const mockNavigateTo = jest.fn(); + const filters = [ + { + meta: { + index: 'security-solution-default', + type: 'phrase', + key: 'event.action', + params: { + query: 'host', + }, + disabled: false, + negate: false, + alias: null, + }, + query: { + match_phrase: { + 'event.action': 'host', + }, + }, + $state: { + store: 'appState', + }, + }, + ]; + const props = { + query: { + language: 'kuery', + query: '', + }, + filters: [], + timeRange: { + from: '2023-03-24T00:00:00.000Z', + fromStr: 'now/d', + to: '2023-03-24T23:59:59.999Z', + toStr: 'now/d', + }, + uiSettings: { + get: jest.fn(), + }, + savedObjectId: 'e2937420-c8ba-11ed-a7eb-3d08ee4d53cb', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ + getAppUrl: jest + .fn() + .mockReturnValue('/app/dashboards#/view/e2937420-c8ba-11ed-a7eb-3d08ee4d53cb'), + navigateTo: mockNavigateTo, + }); + }); + it('create links to Dashboard app - with filters', () => { + const testProps = { ...props, filters } as unknown as UseDashboardAppLinkProps; + + const { result } = renderHook(() => useDashboardAppLink(testProps), { + wrapper: TestProviders, + }); + expect(result.current.href).toMatchInlineSnapshot( + `"/app/dashboards#/view/e2937420-c8ba-11ed-a7eb-3d08ee4d53cb?_g=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:security-solution-default,key:event.action,negate:!f,params:(query:host),type:phrase),query:(match_phrase:(event.action:host)))),query:(language:kuery,query:''),time:(from:now%2Fd,to:now%2Fd))"` + ); + }); + + it('create links to Dashboard app - with query', () => { + const testProps = { + ...props, + query: { + language: 'kuery', + query: '@timestamp : *', + }, + } as unknown as UseDashboardAppLinkProps; + + const { result } = renderHook(() => useDashboardAppLink(testProps), { wrapper: TestProviders }); + expect(result.current.href).toMatchInlineSnapshot( + `"/app/dashboards#/view/e2937420-c8ba-11ed-a7eb-3d08ee4d53cb?_g=(filters:!(),query:(language:kuery,query:'@timestamp%20:%20*'),time:(from:now%2Fd,to:now%2Fd))"` + ); + }); + + it('create links to Dashboard app - with absolute time', () => { + const testProps = { + ...props, + timeRange: { + from: '2023-03-24T00:00:00.000Z', + to: '2023-03-24T23:59:59.999Z', + }, + } as unknown as UseDashboardAppLinkProps; + + const { result } = renderHook(() => useDashboardAppLink(testProps), { wrapper: TestProviders }); + expect(result.current.href).toMatchInlineSnapshot( + `"/app/dashboards#/view/e2937420-c8ba-11ed-a7eb-3d08ee4d53cb?_g=(filters:!(),query:(language:kuery,query:''),time:(from:'2023-03-24T00:00:00.000Z',to:'2023-03-24T23:59:59.999Z'))"` + ); + }); + + it('navigate to dashboard app with preserved states', () => { + const testProps = { + ...props, + timeRange: { + from: '2023-03-24T00:00:00.000Z', + to: '2023-03-24T23:59:59.999Z', + }, + } as unknown as UseDashboardAppLinkProps; + + const { result } = renderHook(() => useDashboardAppLink(testProps), { + wrapper: TestProviders, + }); + result.current.onClick({ + preventDefault: jest.fn(), + } as unknown as React.MouseEvent); + + expect(mockNavigateTo).toHaveBeenCalledWith( + expect.objectContaining({ + url: "/app/dashboards#/view/e2937420-c8ba-11ed-a7eb-3d08ee4d53cb?_g=(filters:!(),query:(language:kuery,query:''),time:(from:'2023-03-24T00:00:00.000Z',to:'2023-03-24T23:59:59.999Z'))", + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.tsx new file mode 100644 index 0000000000000..1bf8a16f1ce19 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_app_link.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 { createDashboardEditUrl, DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; +import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; +import type { IUiSettingsClient } from '@kbn/core/public'; +import { useMemo } from 'react'; +import type { Filter, Query } from '@kbn/es-query'; +import { useNavigation } from '../../common/lib/kibana'; + +const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export interface UseDashboardAppLinkProps { + query?: Query; + filters?: Filter[]; + timeRange: { + from: string; + to: string; + fromStr?: string | undefined; + toStr?: string | undefined; + }; + uiSettings: IUiSettingsClient; + savedObjectId: string | undefined; +} + +export const useDashboardAppLink = ({ + query, + filters, + timeRange: { from, fromStr, to, toStr }, + uiSettings, + savedObjectId, +}: UseDashboardAppLinkProps) => { + const { navigateTo, getAppUrl } = useNavigation(); + const useHash = uiSettings.get('state:storeInSessionStorage'); + + let editDashboardUrl = useMemo( + () => + getAppUrl({ + appId: DASHBOARD_APP_ID, + path: `#${createDashboardEditUrl(savedObjectId)}`, + }), + [getAppUrl, savedObjectId] + ); + + editDashboardUrl = setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + { + time: { from: fromStr ?? from, to: toStr ?? to }, + filters, + query, + }, + { useHash, storeInHashQuery: true }, + editDashboardUrl + ); + + const editDashboardLinkProps = useMemo( + () => ({ + onClick: (ev: React.MouseEvent) => { + ev.preventDefault(); + navigateTo({ url: editDashboardUrl }); + }, + href: editDashboardUrl, + }), + [editDashboardUrl, navigateTo] + ); + return editDashboardLinkProps; +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.test.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.test.tsx new file mode 100644 index 0000000000000..b309d0a646a58 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { EuiEmptyPromptProps } from '@elastic/eui'; +import { renderHook } from '@testing-library/react-hooks'; +import { + DashboardViewPromptState, + useDashboardViewPromptState, +} from './use_dashboard_view_prompt_state'; + +describe('useDashboardViewPromptState', () => { + it('returns empty state', () => { + const { result } = renderHook< + DashboardViewPromptState | null, + Partial | null + >(() => useDashboardViewPromptState(null)); + expect(result.current).toBeNull(); + }); + + it('returns NoReadPermission state', () => { + const { result } = renderHook< + DashboardViewPromptState | null, + Partial | null + >(() => useDashboardViewPromptState(DashboardViewPromptState.NoReadPermission)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "body":

+ Contact your administrator for help. +

, + "color": "danger", + "iconType": "error", + "title":

+ You have no permission to read the dashboard +

, + } + `); + }); + + it('returns IndicesNotFound state', () => { + const { result } = renderHook< + DashboardViewPromptState | null, + Partial | null + >(() => useDashboardViewPromptState(DashboardViewPromptState.IndicesNotFound)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "color": "danger", + "iconType": "error", + "title":

+ Indices not found +

, + } + `); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.tsx new file mode 100644 index 0000000000000..20c0be416bb3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_view_prompt_state.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import type { EuiEmptyPromptProps } from '@elastic/eui'; +import * as i18n from '../pages/details/translations'; + +export enum DashboardViewPromptState { + NoReadPermission = 'NoReadPermission', + IndicesNotFound = 'IndicesNotFound', +} + +const dashboardViewPromptState: Record> = { + [DashboardViewPromptState.NoReadPermission]: { + color: 'danger', + iconType: 'error', + title:

{i18n.DASHBOARD_NO_READ_PERMISSION_TITLE}

, + body:

{i18n.DASHBOARD_NO_READ_PERMISSION_DESCRIPTION}

, + }, + [DashboardViewPromptState.IndicesNotFound]: { + color: 'danger', + iconType: 'error', + title:

{i18n.DASHBOARD_INDICES_NOT_FOUND_TITLE}

, + }, +}; + +export const useDashboardViewPromptState = ( + currentState: DashboardViewPromptState | null +): Partial | null => { + return currentState ? dashboardViewPromptState[currentState] : null; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/page_route/index.tsx b/x-pack/plugins/security_solution/public/dashboards/index.ts similarity index 54% rename from x-pack/plugins/security_solution/public/common/components/page_route/index.tsx rename to x-pack/plugins/security_solution/public/dashboards/index.ts index c52227eba107a..1af6a985be8a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/page_route/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/index.ts @@ -4,5 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { SecuritySubPlugin } from '../app/types'; +import { routes } from './routes'; -export { PageRoute } from './pageroute'; +export class Dashboards { + public setup() {} + + public start(): SecuritySubPlugin { + return { + routes, + }; + } +} diff --git a/x-pack/plugins/security_solution/public/dashboards/jest.config.js b/x-pack/plugins/security_solution/public/dashboards/jest.config.js new file mode 100644 index 0000000000000..a2ff7b5c4a1af --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/jest.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/plugins/security_solution/public/dashboards'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/dashboards', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/security_solution/public/dashboards/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../../server/__mocks__/module_name_map'), +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/links.ts b/x-pack/plugins/security_solution/public/dashboards/links.ts new file mode 100644 index 0000000000000..452b03444988e --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/links.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { DASHBOARDS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; +import { DASHBOARDS } from '../app/translations'; +import type { LinkItem } from '../common/links/types'; +import { links as kubernetesLinks } from '../kubernetes/links'; +import { dashboardLinks as cloudSecurityPostureLinks } from '../cloud_security_posture/links'; +import { + ecsDataQualityDashboardLinks, + detectionResponseLinks, + entityAnalyticsLinks, + overviewLinks, +} from '../overview/links'; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboards, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavPosition: 1, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [ + overviewLinks, + detectionResponseLinks, + kubernetesLinks, + cloudSecurityPostureLinks, + entityAnalyticsLinks, + ecsDataQualityDashboardLinks, + ], + skipUrlState: false, +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx new file mode 100644 index 0000000000000..124ebb67339c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; +import type { DashboardContainer } from '@kbn/dashboard-plugin/public'; + +import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; +import { useParams } from 'react-router-dom'; + +import { isEmpty, pick } from 'lodash/fp'; +import { SecurityPageName } from '../../../../common/constants'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { useCapabilities } from '../../../common/lib/kibana'; +import { DashboardViewPromptState } from '../../hooks/use_dashboard_view_prompt_state'; +import { DashboardRenderer } from '../../../common/components/dashboards/dashboard_renderer'; +import { StatusPropmpt } from '../../components/status_prompt'; +import { SiemSearchBar } from '../../../common/components/search_bar'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; +import { FiltersGlobal } from '../../../common/components/filters_global'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { HeaderPage } from '../../../common/components/header_page'; +import { DASHBOARD_PAGE_TITLE } from '../translations'; +import { inputsSelectors } from '../../../common/store'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { EditDashboardButton } from '../../components/edit_dashboard_button'; + +type DashboardDetails = Record; + +const DashboardViewComponent: React.FC = () => { + const { fromStr, toStr, from, to } = useDeepEqualSelector((state) => + pick(['fromStr', 'toStr', 'from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) + ); + const timeRange = useMemo(() => ({ from, to, fromStr, toStr }), [from, fromStr, to, toStr]); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + const { indexPattern, indicesExist } = useSourcererDataView(); + + const { show: canReadDashboard, showWriteControls } = + useCapabilities(LEGACY_DASHBOARD_APP_ID); + const [currentState, setCurrentState] = useState( + canReadDashboard ? null : DashboardViewPromptState.NoReadPermission + ); + const [dashboardDetails, setDashboardDetails] = useState(); + const onDashboardContainerLoaded = useCallback((dashboardContainer: DashboardContainer) => { + const dashboardTitle = dashboardContainer.getTitle().trim(); + setDashboardDetails({ dashboardTitle }); + }, []); + const { detailName: savedObjectId } = useParams<{ detailName?: string }>(); + const dashboardExists = !isEmpty(dashboardDetails?.dashboardTitle); + + useEffect(() => { + if (!indicesExist) { + setCurrentState(DashboardViewPromptState.IndicesNotFound); + } + }, [indicesExist]); + + return ( + <> + {indicesExist && ( + + + + )} + + + {showWriteControls && dashboardExists && ( + + )} + + + {indicesExist && ( + + )} + + + + + + ); +}; +DashboardViewComponent.displayName = 'DashboardViewComponent'; +export const DashboardView = React.memo(DashboardViewComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts new file mode 100644 index 0000000000000..dfd25dcbe8512 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const DASHBOARD_NO_READ_PERMISSION_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.viewPorpmpt.noReadPermission.title', + { + defaultMessage: 'You have no permission to read the dashboard', + } +); + +export const DASHBOARD_NO_READ_PERMISSION_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.viewPorpmpt.noReadPermission.description', + { + defaultMessage: 'Contact your administrator for help.', + } +); + +export const DASHBOARD_INDICES_NOT_FOUND_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.viewPorpmpt.indicesNotFound.title', + { + defaultMessage: 'Indices not found', + } +); + +export const EDIT_DASHBOARD_BUTTON_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.editDashboardButtonTitle', + { + defaultMessage: `Edit`, + } +); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx new file mode 100644 index 0000000000000..bf837291a151e --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { Switch } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; + +import { DashboardsLandingPage } from './landing_page'; +import { DashboardView } from './details'; +import { DASHBOARDS_PATH } from '../../../common/constants'; + +const DashboardsContainerComponent = () => { + return ( + + + + + + + + + ); +}; +export const DashboardsContainer = React.memo(DashboardsContainerComponent); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx rename to x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx index 97eb89695bc98..bfc035a0add12 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx @@ -7,16 +7,16 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { SecurityPageName } from '../../app/types'; -import { TestProviders } from '../../common/mock'; -import { DashboardsLandingPage } from './dashboards'; -import type { NavLinkItem } from '../../common/components/navigation/types'; -import { useCapabilities } from '../../common/lib/kibana'; -import * as telemetry from '../../common/lib/telemetry'; - -jest.mock('../../common/lib/kibana'); -jest.mock('../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); -jest.mock('../../common/components/dashboards/dashboards_table', () => ({ +import { SecurityPageName } from '../../../app/types'; +import { TestProviders } from '../../../common/mock'; +import { DashboardsLandingPage } from '.'; +import type { NavLinkItem } from '../../../common/components/navigation/types'; +import { useCapabilities } from '../../../common/lib/kibana'; +import * as telemetry from '../../../common/lib/telemetry'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); +jest.mock('../../../common/components/dashboards/dashboards_table', () => ({ DashboardsTable: () => , })); @@ -29,7 +29,7 @@ const OVERVIEW_ITEM_LABEL = 'Overview'; const DETECTION_RESPONSE_ITEM_LABEL = 'Detection & Response'; const APP_DASHBOARD_LINKS: NavLinkItem = { - id: SecurityPageName.dashboardsLanding, + id: SecurityPageName.dashboards, title: 'Dashboards', links: [ { @@ -49,15 +49,15 @@ const APP_DASHBOARD_LINKS: NavLinkItem = { const URL = '/path/to/dashboards'; const mockAppManageLink = jest.fn(() => APP_DASHBOARD_LINKS); -jest.mock('../../common/components/navigation/nav_links', () => ({ +jest.mock('../../../common/components/navigation/nav_links', () => ({ useAppRootNavLink: () => mockAppManageLink(), })); const CREATE_DASHBOARD_LINK = { isLoading: false, url: URL }; const mockUseCreateSecurityDashboard = jest.fn(() => CREATE_DASHBOARD_LINK); -jest.mock('../../common/containers/dashboards/use_create_security_dashboard_link', () => { +jest.mock('../../../common/containers/dashboards/use_create_security_dashboard_link', () => { const actual = jest.requireActual( - '../../common/containers/dashboards/use_create_security_dashboard_link' + '../../../common/containers/dashboards/use_create_security_dashboard_link' ); return { ...actual, diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx similarity index 63% rename from x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx rename to x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index ee97955b659ab..d6d9be365fc30 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/dashboards.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -4,47 +4,41 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; -import { SecurityPageName } from '../../app/types'; -import { DashboardsTable } from '../../common/components/dashboards/dashboards_table'; -import { Title } from '../../common/components/header_page/title'; -import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; -import { useCreateSecurityDashboardLink } from '../../common/containers/dashboards/use_create_security_dashboard_link'; -import { useCapabilities, useNavigateTo } from '../../common/lib/kibana'; -import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { LandingImageCards } from '../components/landing_links_images'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { DashboardsTable } from '../../../common/components/dashboards/dashboards_table'; +import { LandingImageCards } from '../../../landing_pages/components/landing_links_images'; +import { SecurityPageName } from '../../../../common/constants'; +import { useCapabilities, useNavigateTo } from '../../../common/lib/kibana'; +import { useAppRootNavLink } from '../../../common/components/navigation/nav_links'; +import { useCreateSecurityDashboardLink } from '../../../common/containers/dashboards/use_create_security_dashboard_link'; +import { Title } from '../../../common/components/header_page/title'; +import { LinkButton } from '../../../common/components/links/helpers'; import * as i18n from './translations'; -import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../common/lib/telemetry'; +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../common/lib/telemetry'; +import { DASHBOARDS_PAGE_TITLE } from '../translations'; -/* eslint-disable @elastic/eui/href-or-on-click */ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard }) => { const { isLoading, url } = useCreateSecurityDashboardLink(); const { navigateTo } = useNavigateTo(); return ( - + <Title title={DASHBOARDS_PAGE_TITLE} /> </EuiFlexItem> {canCreateDashboard && ( <EuiFlexItem grow={false}> - <EuiButton + <LinkButton isDisabled={isLoading} color="primary" fill iconType="plusInCircle" href={url} - onClick={(ev) => { + onClick={(ev: React.MouseEvent<HTMLButtonElement>) => { ev.preventDefault(); track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.CREATE_DASHBOARD}`); navigateTo({ url }); @@ -52,7 +46,7 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard data-test-subj="createDashboardButton" > {i18n.DASHBOARDS_PAGE_CREATE_BUTTON} - </EuiButton> + </LinkButton> </EuiFlexItem> )} </EuiFlexGroup> @@ -60,7 +54,7 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard }; export const DashboardsLandingPage = () => { - const dashboardLinks = useAppRootNavLink(SecurityPageName.dashboardsLanding)?.links ?? []; + const dashboardLinks = useAppRootNavLink(SecurityPageName.dashboards)?.links ?? []; const { show: canReadDashboard, createNew: canCreateDashboard } = useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID); @@ -87,7 +81,7 @@ export const DashboardsLandingPage = () => { </> )} - <SpyRoute pageName={SecurityPageName.dashboardsLanding} /> + <SpyRoute pageName={SecurityPageName.dashboards} /> </SecuritySolutionPageWrapper> ); }; diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts new file mode 100644 index 0000000000000..71bafc6a80990 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const DASHBOARDS_PAGE_CREATE_BUTTON = i18n.translate( + 'xpack.securitySolution.dashboards.landing.createButton', + { + defaultMessage: 'Create Dashboard', + } +); + +export const DASHBOARDS_PAGE_SECTION_DEFAULT = i18n.translate( + 'xpack.securitySolution.dashboards.landing.section.default', + { + defaultMessage: 'DEFAULT', + } +); + +export const DASHBOARDS_PAGE_SECTION_CUSTOM = i18n.translate( + 'xpack.securitySolution.dashboards.landing.section.custom', + { + defaultMessage: 'CUSTOM', + } +); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/translations.ts new file mode 100644 index 0000000000000..716b5a6d3a81d --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const DASHBOARDS_PAGE_TITLE = i18n.translate('xpack.securitySolution.dashboards.pageTitle', { + defaultMessage: 'Dashboards', +}); + +export const DASHBOARD_PAGE_TITLE = i18n.translate('xpack.securitySolution.dashboard.pageTitle', { + defaultMessage: 'Dashboard', +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/utils.ts b/x-pack/plugins/security_solution/public/dashboards/pages/utils.ts new file mode 100644 index 0000000000000..8db389553c523 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/pages/utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ChromeBreadcrumb } from '@kbn/core/public'; +import { isEmpty } from 'lodash/fp'; +import type { RouteSpyState } from '../../common/utils/route/types'; + +export const getTrailingBreadcrumbs = (params: RouteSpyState): ChromeBreadcrumb[] => { + let breadcrumb: ChromeBreadcrumb[] = []; + + const dashboardTitle = params?.state?.dashboardTitle?.trim(); + if (params?.state?.dashboardTitle || params.detailName) { + breadcrumb = [ + ...breadcrumb, + { + text: !isEmpty(dashboardTitle) ? dashboardTitle : params.detailName, + }, + ]; + } + + return breadcrumb; +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/routes.tsx b/x-pack/plugins/security_solution/public/dashboards/routes.tsx new file mode 100644 index 0000000000000..b9e854dfdbbdf --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/routes.tsx @@ -0,0 +1,28 @@ +/* + * 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 { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; + +import { DASHBOARDS_PATH, SecurityPageName } from '../../common/constants'; +import type { SecuritySubPluginRoutes } from '../app/types'; +import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; +import { DashboardsContainer } from './pages'; + +export const DashboardRoutes = () => ( + <PluginTemplateWrapper> + <TrackApplicationView viewId={SecurityPageName.dashboards}> + <DashboardsContainer /> + </TrackApplicationView> + </PluginTemplateWrapper> +); + +export const routes: SecuritySubPluginRoutes = [ + { + path: DASHBOARDS_PATH, + component: DashboardRoutes, + }, +]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts index adbb37a59de5b..081576bb7212e 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/links.ts +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -6,45 +6,11 @@ */ import { i18n } from '@kbn/i18n'; -import { - DASHBOARDS_PATH, - EXPLORE_PATH, - SecurityPageName, - SERVER_APP_ID, -} from '../../common/constants'; -import { DASHBOARDS, EXPLORE } from '../app/translations'; +import { EXPLORE_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; +import { EXPLORE } from '../app/translations'; import type { LinkItem } from '../common/links/types'; -import { - ecsDataQualityDashboardLinks, - detectionResponseLinks, - entityAnalyticsLinks, - overviewLinks, -} from '../overview/links'; -import { exploreLinks } from '../explore/links'; -import { links as kubernetesLinks } from '../kubernetes/links'; -import { dashboardLinks as cloudSecurityPostureLinks } from '../cloud_security_posture/links'; -export const dashboardsLandingLinks: LinkItem = { - id: SecurityPageName.dashboardsLanding, - title: DASHBOARDS, - path: DASHBOARDS_PATH, - globalNavPosition: 1, - capabilities: [`${SERVER_APP_ID}.show`], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.dashboards', { - defaultMessage: 'Dashboards', - }), - ], - links: [ - overviewLinks, - detectionResponseLinks, - kubernetesLinks, - cloudSecurityPostureLinks, - entityAnalyticsLinks, - ecsDataQualityDashboardLinks, - ], - skipUrlState: true, -}; +import { exploreLinks } from '../explore/links'; export const threatHuntingLandingLinks: LinkItem = { id: SecurityPageName.exploreLanding, diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/translations.ts b/x-pack/plugins/security_solution/public/landing_pages/pages/translations.ts index f911ea393c4d5..ab5ded43f234a 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/translations.ts @@ -14,34 +14,6 @@ export const EXPLORE_PAGE_TITLE = i18n.translate( } ); -export const DASHBOARDS_PAGE_TITLE = i18n.translate( - 'xpack.securitySolution.landing.dashboards.pageTitle', - { - defaultMessage: 'Dashboards', - } -); - -export const DASHBOARDS_PAGE_CREATE_BUTTON = i18n.translate( - 'xpack.securitySolution.landing.dashboards.createButton', - { - defaultMessage: 'Create Dashboard', - } -); - -export const DASHBOARDS_PAGE_SECTION_DEFAULT = i18n.translate( - 'xpack.securitySolution.landing.dashboards.section.default', - { - defaultMessage: 'DEFAULT', - } -); - -export const DASHBOARDS_PAGE_SECTION_CUSTOM = i18n.translate( - 'xpack.securitySolution.landing.dashboards.section.custom', - { - defaultMessage: 'CUSTOM', - } -); - export const MANAGE_PAGE_TITLE = i18n.translate('xpack.securitySolution.landing.manage.pageTitle', { defaultMessage: 'Manage', }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx index a9e7d9ddb27cb..62a07bea29491 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx @@ -10,9 +10,8 @@ import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import type { SecuritySubPluginRoutes } from '../app/types'; import { SecurityPageName } from '../app/types'; -import { DASHBOARDS_PATH, MANAGE_PATH, EXPLORE_PATH } from '../../common/constants'; +import { MANAGE_PATH, EXPLORE_PATH } from '../../common/constants'; import { ExploreLandingPage } from './pages/explore'; -import { DashboardsLandingPage } from './pages/dashboards'; import { ManageLandingPage } from './pages/manage'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; @@ -24,14 +23,6 @@ export const ThreatHuntingRoutes = () => ( </PluginTemplateWrapper> ); -export const DashboardRoutes = () => ( - <PluginTemplateWrapper> - <TrackApplicationView viewId={SecurityPageName.dashboardsLanding}> - <DashboardsLandingPage /> - </TrackApplicationView> - </PluginTemplateWrapper> -); - export const ManageRoutes = () => ( <PluginTemplateWrapper> <TrackApplicationView viewId={SecurityPageName.administration}> @@ -45,10 +36,6 @@ export const routes: SecuritySubPluginRoutes = [ path: EXPLORE_PATH, component: ThreatHuntingRoutes, }, - { - path: DASHBOARDS_PATH, - component: DashboardRoutes, - }, { path: MANAGE_PATH, component: ManageRoutes, diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index 66aefc6db3e08..01cb1ff21bfe7 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -23,6 +23,7 @@ import { LandingPages } from './landing_pages'; import { CloudDefend } from './cloud_defend'; import { CloudSecurityPosture } from './cloud_security_posture'; import { ThreatIntelligence } from './threat_intelligence'; +import { Dashboards } from './dashboards'; /** * The classes used to instantiate the sub plugins. These are grouped into a single object for the sake of bundling them in a single dynamic import. @@ -38,6 +39,7 @@ const subPluginClasses = { Timelines, Management, LandingPages, + Dashboards, CloudDefend, CloudSecurityPosture, ThreatIntelligence, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 17ba2940e94d9..9553b2695f95c 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -399,6 +399,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S rules: new subPluginClasses.Rules(), exceptions: new subPluginClasses.Exceptions(), cases: new subPluginClasses.Cases(), + dashboards: new subPluginClasses.Dashboards(), explore: new subPluginClasses.Explore(), kubernetes: new subPluginClasses.Kubernetes(), overview: new subPluginClasses.Overview(), @@ -423,19 +424,20 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S ): Promise<StartedSubPlugins> { const subPlugins = await this.subPlugins(); return { - overview: subPlugins.overview.start(), alerts: subPlugins.alerts.start(storage), cases: subPlugins.cases.start(), - rules: subPlugins.rules.start(storage), + cloudDefend: subPlugins.cloudDefend.start(), + cloudSecurityPosture: subPlugins.cloudSecurityPosture.start(), + dashboards: subPlugins.dashboards.start(), exceptions: subPlugins.exceptions.start(storage), explore: subPlugins.explore.start(storage), - timelines: subPlugins.timelines.start(), kubernetes: subPlugins.kubernetes.start(), - management: subPlugins.management.start(core, plugins), landingPages: subPlugins.landingPages.start(), - cloudDefend: subPlugins.cloudDefend.start(), - cloudSecurityPosture: subPlugins.cloudSecurityPosture.start(), + management: subPlugins.management.start(core, plugins), + overview: subPlugins.overview.start(), + rules: subPlugins.rules.start(storage), threatIntelligence: subPlugins.threatIntelligence.start(), + timelines: subPlugins.timelines.start(), }; } /** diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index f1450738e8d36..a9566fabc6c1a 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -62,6 +62,8 @@ import type { ThreatIntelligence } from './threat_intelligence'; import type { SecuritySolutionTemplateWrapper } from './app/home/template_wrapper'; import type { Explore } from './explore'; import type { TelemetryClientStart } from './common/lib/telemetry'; +import type { Dashboards } from './dashboards'; + export interface SetupPlugins { home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; @@ -139,34 +141,36 @@ export type InspectResponse = Inspect & { response: string[] }; export const CASES_SUB_PLUGIN_KEY = 'cases'; export interface SubPlugins { + [CASES_SUB_PLUGIN_KEY]: Cases; alerts: Detections; - rules: Rules; + cloudDefend: CloudDefend; + cloudSecurityPosture: CloudSecurityPosture; + dashboards: Dashboards; exceptions: Exceptions; - [CASES_SUB_PLUGIN_KEY]: Cases; explore: Explore; kubernetes: Kubernetes; - overview: Overview; - timelines: Timelines; - management: Management; landingPages: LandingPages; - cloudDefend: CloudDefend; - cloudSecurityPosture: CloudSecurityPosture; + management: Management; + overview: Overview; + rules: Rules; threatIntelligence: ThreatIntelligence; + timelines: Timelines; } // TODO: find a better way to defined these types export interface StartedSubPlugins { + [CASES_SUB_PLUGIN_KEY]: ReturnType<Cases['start']>; alerts: ReturnType<Detections['start']>; - rules: ReturnType<Rules['start']>; + cloudDefend: ReturnType<CloudDefend['start']>; + cloudSecurityPosture: ReturnType<CloudSecurityPosture['start']>; + dashboards: ReturnType<Dashboards['start']>; exceptions: ReturnType<Exceptions['start']>; - [CASES_SUB_PLUGIN_KEY]: ReturnType<Cases['start']>; explore: ReturnType<Explore['start']>; kubernetes: ReturnType<Kubernetes['start']>; - overview: ReturnType<Overview['start']>; - timelines: ReturnType<Timelines['start']>; - management: ReturnType<Management['start']>; landingPages: ReturnType<LandingPages['start']>; - cloudDefend: ReturnType<CloudDefend['start']>; - cloudSecurityPosture: ReturnType<CloudSecurityPosture['start']>; + management: ReturnType<Management['start']>; + overview: ReturnType<Overview['start']>; + rules: ReturnType<Rules['start']>; threatIntelligence: ReturnType<ThreatIntelligence['start']>; + timelines: ReturnType<Timelines['start']>; } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c7e399e6ed9ca..ceb82974f0d56 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -31392,10 +31392,6 @@ "xpack.securitySolution.kubernetes.columnNode": "Nœud", "xpack.securitySolution.kubernetes.columnPod": "Pod", "xpack.securitySolution.kubernetes.columnSessionStart": "Date de connexion", - "xpack.securitySolution.landing.dashboards.createButton": "Créer un tableau de bord", - "xpack.securitySolution.landing.dashboards.pageTitle": "Tableaux de bord", - "xpack.securitySolution.landing.dashboards.section.custom": "PERSONNALISÉ", - "xpack.securitySolution.landing.dashboards.section.default": "PAR DÉFAUT", "xpack.securitySolution.landing.manage.pageTitle": "Gérer", "xpack.securitySolution.landing.threatHunting.hostsDescription": "Aperçu complet de tous les hôtes et événements de sécurité des hôtes.", "xpack.securitySolution.landing.threatHunting.pageTitle": "Explorer", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4e97d9a007e22..89a92f9c4ad21 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -31371,10 +31371,6 @@ "xpack.securitySolution.kubernetes.columnNode": "ノード", "xpack.securitySolution.kubernetes.columnPod": "ポッド", "xpack.securitySolution.kubernetes.columnSessionStart": "データが接続されました", - "xpack.securitySolution.landing.dashboards.createButton": "ダッシュボードを作成", - "xpack.securitySolution.landing.dashboards.pageTitle": "ダッシュボード", - "xpack.securitySolution.landing.dashboards.section.custom": "カスタム", - "xpack.securitySolution.landing.dashboards.section.default": "デフォルト", "xpack.securitySolution.landing.manage.pageTitle": "管理", "xpack.securitySolution.landing.threatHunting.hostsDescription": "すべてのホストとホスト関連のセキュリティイベントに関する包括的な概要。", "xpack.securitySolution.landing.threatHunting.pageTitle": "探索", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5ac1921894867..2f50f22e0ce3b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -31387,10 +31387,6 @@ "xpack.securitySolution.kubernetes.columnNode": "节点", "xpack.securitySolution.kubernetes.columnPod": "Pod", "xpack.securitySolution.kubernetes.columnSessionStart": "连接日期", - "xpack.securitySolution.landing.dashboards.createButton": "创建仪表板", - "xpack.securitySolution.landing.dashboards.pageTitle": "仪表板", - "xpack.securitySolution.landing.dashboards.section.custom": "定制", - "xpack.securitySolution.landing.dashboards.section.default": "默认", "xpack.securitySolution.landing.manage.pageTitle": "管理", "xpack.securitySolution.landing.threatHunting.hostsDescription": "所有主机和主机相关安全事件的全面概览。", "xpack.securitySolution.landing.threatHunting.pageTitle": "浏览",