From 9ca30e8e7788620efbf07cdf682732cbb7b867a3 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Wed, 30 Aug 2023 09:30:51 +0200 Subject: [PATCH] [Infra UI] Change breadcrumbs and add return button (#164726) closes: https://github.com/elastic/kibana/issues/148956 ## Summary This PR adds a return button to the Node Details page, both old and new versions. When returning to the previous page, the page has to load with its previous state. **This doesn't work for the Inventory and Metrics UI.** The Return button will not show when: the URL is copied from the address bar and opened in a new tab/browser. Clicking on the a link to open the page in a new tab will show the return button. **Return to Host View** https://github.com/elastic/kibana/assets/2767137/06e218fc-5f35-4e67-a4f9-3f04b3f79dee **Return to Inventory UI** https://github.com/elastic/kibana/assets/2767137/906012ce-e5ca-49dd-a19b-2b3d16e9e4f6 _Returning state in Inventory UI doesn't work. There are 2 main problems: The hierarchy of the WaffleOptionContext in the page and the Saved View load, which overrides the URL state._ **Return to Metrics UI** https://github.com/elastic/kibana/assets/2767137/de2b19f4-c5e2-4368-8e00-b50ae9ba357b _Returning state in Metrics UI doesn't work because the Saved View overrides the URL state._ **Return to APM** https://github.com/elastic/kibana/assets/2767137/704ce2f0-17eb-4fa9-a791-f0cf9b29c43a **Return button remains after page refresh** https://github.com/elastic/kibana/assets/2767137/58600504-1863-4254-83ea-a2d488e2b38a **Here it's possible to see that the Inventory UI doesn't return to its previous state** ### How to test this PR. - Setup a new Kibana instance - Follow the steps from the video above --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../asset_details/hooks/use_page_header.tsx | 72 +++++++++++++++++-- .../links/link_to_node_details.tsx | 11 +-- .../asset_details/template/page.tsx | 3 +- .../public/components/asset_details/types.ts | 7 ++ .../container_metrics_table.test.tsx | 10 +++ .../host/host_metrics_table.test.tsx | 10 +++ .../pod/pod_metrics_table.test.tsx | 10 +++ .../components/metrics_node_details_link.tsx | 9 ++- .../infra/public/pages/link_to/index.ts | 3 +- .../public/pages/link_to/query_params.ts | 5 ++ .../pages/link_to/redirect_to_node_detail.tsx | 72 ++++++++----------- .../use_node_details_redirect.test.tsx | 50 +++++++++++++ .../link_to/use_node_details_redirect.ts | 71 ++++++++++++++++++ .../hosts/components/table/entry_title.tsx | 23 +++--- .../metrics/hosts/hooks/use_hosts_table.tsx | 6 +- .../inventory_view/components/layout.tsx | 7 +- .../components/node_details/overlay.tsx | 11 +-- .../components/waffle/node_context_menu.tsx | 10 ++- .../components/node_details_page.tsx | 3 + .../pages/metrics/metric_detail/index.tsx | 9 ++- .../metric_detail/metric_detail_page.tsx | 13 +--- .../components/chart_context_menu.test.tsx | 25 +++---- .../components/chart_context_menu.tsx | 28 ++++---- 23 files changed, 347 insertions(+), 121 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.test.tsx create mode 100644 x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.ts diff --git a/x-pack/plugins/infra/public/components/asset_details/hooks/use_page_header.tsx b/x-pack/plugins/infra/public/components/asset_details/hooks/use_page_header.tsx index 07baa3c5f589b..6e0cbf9ad2150 100644 --- a/x-pack/plugins/infra/public/components/asset_details/hooks/use_page_header.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/hooks/use_page_header.tsx @@ -4,26 +4,88 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { useEuiTheme, EuiIcon, type EuiPageHeaderProps } from '@elastic/eui'; +import { + useEuiTheme, + EuiIcon, + type EuiPageHeaderProps, + type EuiBreadcrumbsProps, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { css } from '@emotion/react'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; import React, { useCallback, useMemo } from 'react'; import { capitalize } from 'lodash'; +import { useHistory, useLocation } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { APM_HOST_FILTER_FIELD } from '../constants'; import { LinkToAlertsRule, LinkToApmServices, LinkToNodeDetails } from '../links'; -import { FlyoutTabIds, type LinkOptions, type Tab, type TabIds } from '../types'; +import { FlyoutTabIds, type RouteState, type LinkOptions, type Tab, type TabIds } from '../types'; import { useAssetDetailsRenderPropsContext } from './use_asset_details_render_props'; import { useDateRangeProviderContext } from './use_date_range'; import { useTabSwitcherContext } from './use_tab_switcher'; type TabItem = NonNullable['tabs']>[number]; -export const usePageHeader = (tabs: Tab[], links?: LinkOptions[]) => { +export const usePageHeader = (tabs: Tab[] = [], links: LinkOptions[] = []) => { const { rightSideItems } = useRightSideItems(links); const { tabEntries } = useTabs(tabs); + const { breadcrumbs } = useTemplateHeaderBreadcrumbs(); + + return { rightSideItems, tabEntries, breadcrumbs }; +}; + +export const useTemplateHeaderBreadcrumbs = () => { + const history = useHistory(); + const location = useLocation(); + const { + services: { + application: { navigateToApp }, + }, + } = useKibanaContextForPlugin(); + + const onClick = (e: React.MouseEvent) => { + if (location.state) { + navigateToApp(location.state.originAppId, { + replace: true, + path: `${location.state.originPathname}${location.state.originSearch}`, + }); + } else { + history.goBack(); + } + e.preventDefault(); + }; + + const breadcrumbs: EuiBreadcrumbsProps['breadcrumbs'] = + // If there is a state object in location, it's persisted in case the page is opened in a new tab or after page refresh + // With that, we can show the return button. Otherwise, it will be hidden (ex: the user opened a shared URL or opened the page from their bookmarks) + location.state || history.length > 1 + ? [ + { + text: ( + + + + + + + + + ), + color: 'primary', + 'aria-current': false, + 'data-test-subj': 'infraAssetDetailsReturnButton', + href: '#', + onClick, + }, + ] + : []; - return { rightSideItems, tabEntries }; + return { breadcrumbs }; }; const useRightSideItems = (links?: LinkOptions[]) => { diff --git a/x-pack/plugins/infra/public/components/asset_details/links/link_to_node_details.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_node_details.tsx index 4b0e8a52d400c..87297b247bb35 100644 --- a/x-pack/plugins/infra/public/components/asset_details/links/link_to_node_details.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_node_details.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonEmpty } from '@elastic/eui'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; -import { getNodeDetailUrl } from '../../../pages/link_to'; +import { useNodeDetailsRedirect } from '../../../pages/link_to'; import type { InventoryItemType } from '../../../../common/inventory_models/types'; export interface LinkToNodeDetailsProps { @@ -22,13 +22,16 @@ export const LinkToNodeDetails = ({ assetType, dateRangeTimestamp, }: LinkToNodeDetailsProps) => { + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const nodeDetailMenuItemLinkProps = useLinkProps({ ...getNodeDetailUrl({ nodeType: assetType, nodeId: asset.id, - from: dateRangeTimestamp.from, - to: dateRangeTimestamp.to, - assetName: asset.name, + search: { + from: dateRangeTimestamp.from, + to: dateRangeTimestamp.to, + assetName: asset.name, + }, }), }); diff --git a/x-pack/plugins/infra/public/components/asset_details/template/page.tsx b/x-pack/plugins/infra/public/components/asset_details/template/page.tsx index bbf23196bf7ee..b08458731d813 100644 --- a/x-pack/plugins/infra/public/components/asset_details/template/page.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/template/page.tsx @@ -18,7 +18,7 @@ import type { ContentTemplateProps } from '../types'; export const Page = ({ header: { tabs = [], links = [] } }: ContentTemplateProps) => { const { asset, loading } = useAssetDetailsRenderPropsContext(); - const { rightSideItems, tabEntries } = usePageHeader(tabs, links); + const { rightSideItems, tabEntries, breadcrumbs } = usePageHeader(tabs, links); const { headerHeight } = useKibanaHeader(); return loading ? ( @@ -49,6 +49,7 @@ export const Page = ({ header: { tabs = [], links = [] } }: ContentTemplateProps pageTitle={asset.name} tabs={tabEntries} rightSideItems={rightSideItems} + breadcrumbs={breadcrumbs} /> diff --git a/x-pack/plugins/infra/public/components/asset_details/types.ts b/x-pack/plugins/infra/public/components/asset_details/types.ts index b17c2c988eb27..548068d93e661 100644 --- a/x-pack/plugins/infra/public/components/asset_details/types.ts +++ b/x-pack/plugins/infra/public/components/asset_details/types.ts @@ -6,6 +6,7 @@ */ import { TimeRange } from '@kbn/es-query'; +import { Search } from 'history'; import type { InventoryItemType } from '../../../common/inventory_models/types'; export interface Asset { @@ -79,4 +80,10 @@ export interface ContentTemplateProps { header: Pick; } +export interface RouteState { + originAppId: string; + originPathname?: string; + originSearch?: Search; +} + export type DataViewOrigin = 'logs' | 'metrics'; diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx index 2395e3bca195a..0698aa9d651da 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/container/container_metrics_table.test.tsx @@ -21,6 +21,16 @@ import { createLazyContainerMetricsTable } from './create_lazy_container_metrics import IntegratedContainerMetricsTable from './integrated_container_metrics_table'; import { metricByField } from './use_container_metrics_table'; +jest.mock('../../../pages/link_to', () => ({ + useNodeDetailsRedirect: jest.fn(() => ({ + getNodeDetailUrl: jest.fn(() => ({ + app: 'metrics', + pathname: 'link-to/container-detail/example-01', + search: { from: '1546340400000', to: '1546344000000' }, + })), + })), +})); + describe('ContainerMetricsTable', () => { const timerange = { from: 'now-15m', diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx index 970ec38707b16..c076038d50249 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/host/host_metrics_table.test.tsx @@ -21,6 +21,16 @@ import { HostMetricsTable } from './host_metrics_table'; import IntegratedHostMetricsTable from './integrated_host_metrics_table'; import { metricByField } from './use_host_metrics_table'; +jest.mock('../../../pages/link_to', () => ({ + useNodeDetailsRedirect: jest.fn(() => ({ + getNodeDetailUrl: jest.fn(() => ({ + app: 'metrics', + pathname: 'link-to/host-detail/example-01', + search: { from: '1546340400000', to: '1546344000000' }, + })), + })), +})); + describe('HostMetricsTable', () => { const timerange = { from: 'now-15m', diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx index 10487aa5aae06..27f60b1906003 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/pod/pod_metrics_table.test.tsx @@ -21,6 +21,16 @@ import IntegratedPodMetricsTable from './integrated_pod_metrics_table'; import { PodMetricsTable } from './pod_metrics_table'; import { metricByField } from './use_pod_metrics_table'; +jest.mock('../../../pages/link_to', () => ({ + useNodeDetailsRedirect: jest.fn(() => ({ + getNodeDetailUrl: jest.fn(() => ({ + app: 'metrics', + pathname: 'link-to/pod-detail/example-01', + search: { from: '1546340400000', to: '1546344000000' }, + })), + })), +})); + describe('PodMetricsTable', () => { const timerange = { from: 'now-15m', diff --git a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx index d70db66756ffa..fd8868f0218af 100644 --- a/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx +++ b/x-pack/plugins/infra/public/components/infrastructure_node_metrics_tables/shared/components/metrics_node_details_link.tsx @@ -10,7 +10,7 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; import type { InventoryItemType } from '../../../../../common/inventory_models/types'; -import { getNodeDetailUrl } from '../../../../pages/link_to'; +import { useNodeDetailsRedirect } from '../../../../pages/link_to'; import type { MetricsExplorerTimeOptions } from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; type ExtractStrict = Extract; @@ -28,12 +28,15 @@ export const MetricsNodeDetailsLink = ({ nodeType, timerange, }: MetricsNodeDetailsLinkProps) => { + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const linkProps = useLinkProps( getNodeDetailUrl({ nodeType, nodeId: id, - from: parse(timerange.from)?.valueOf(), - to: parse(timerange.to)?.valueOf(), + search: { + from: parse(timerange.from)?.valueOf(), + to: parse(timerange.to)?.valueOf(), + }, }) ); diff --git a/x-pack/plugins/infra/public/pages/link_to/index.ts b/x-pack/plugins/infra/public/pages/link_to/index.ts index 0991c6dba1936..aae22218a4f83 100644 --- a/x-pack/plugins/infra/public/pages/link_to/index.ts +++ b/x-pack/plugins/infra/public/pages/link_to/index.ts @@ -8,4 +8,5 @@ export { LinkToLogsPage } from './link_to_logs'; export { LinkToMetricsPage } from './link_to_metrics'; export { RedirectToNodeLogs } from './redirect_to_node_logs'; -export { getNodeDetailUrl, RedirectToNodeDetail } from './redirect_to_node_detail'; +export { RedirectToNodeDetail } from './redirect_to_node_detail'; +export { useNodeDetailsRedirect } from './use_node_details_redirect'; diff --git a/x-pack/plugins/infra/public/pages/link_to/query_params.ts b/x-pack/plugins/infra/public/pages/link_to/query_params.ts index e071ec0a82e34..a80f163993588 100644 --- a/x-pack/plugins/infra/public/pages/link_to/query_params.ts +++ b/x-pack/plugins/infra/public/pages/link_to/query_params.ts @@ -33,3 +33,8 @@ export const getNodeNameFromLocation = (location: Location) => { const nameParam = getParamFromQueryString(getQueryStringFromLocation(location), 'assetName'); return nameParam; }; + +export const getStateFromLocation = (location: Location) => { + const nameParam = getParamFromQueryString(getQueryStringFromLocation(location), 'state'); + return nameParam; +}; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx index 8045c95fe78d6..1bd8aa3b793c5 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx @@ -6,24 +6,24 @@ */ import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; - -import { LinkDescriptor } from '@kbn/observability-shared-plugin/public'; +import { Redirect, useLocation, useRouteMatch } from 'react-router-dom'; import { replaceMetricTimeInQueryString } from '../metrics/metric_detail/hooks/use_metrics_time'; -import { getFromFromLocation, getToFromLocation, getNodeNameFromLocation } from './query_params'; +import { + getFromFromLocation, + getToFromLocation, + getNodeNameFromLocation, + getStateFromLocation, +} from './query_params'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { RouteState } from '../../components/asset_details/types'; + +export const RedirectToNodeDetail = () => { + const { + params: { nodeType, nodeId }, + } = useRouteMatch<{ nodeType: InventoryItemType; nodeId: string }>(); -type RedirectToNodeDetailProps = RouteComponentProps<{ - nodeId: string; - nodeType: InventoryItemType; -}>; + const location = useLocation(); -export const RedirectToNodeDetail = ({ - match: { - params: { nodeId, nodeType }, - }, - location, -}: RedirectToNodeDetailProps) => { const searchString = replaceMetricTimeInQueryString( getFromFromLocation(location), getToFromLocation(location) @@ -38,33 +38,21 @@ export const RedirectToNodeDetail = ({ } } - return ; -}; + let state: RouteState | undefined; + try { + const stateFromLocation = getStateFromLocation(location); + state = stateFromLocation ? JSON.parse(stateFromLocation) : undefined; + } catch (err) { + state = undefined; + } -export const getNodeDetailUrl = ({ - nodeType, - nodeId, - to, - from, - assetName, -}: { - nodeType: InventoryItemType; - nodeId: string; - to?: number; - from?: number; - assetName?: string; -}): LinkDescriptor => { - return { - app: 'metrics', - pathname: `link-to/${nodeType}-detail/${nodeId}`, - search: { - ...(assetName ? { assetName } : undefined), - ...(to && from - ? { - to: `${to}`, - from: `${from}`, - } - : undefined), - }, - }; + return ( + + ); }; diff --git a/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.test.tsx b/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.test.tsx new file mode 100644 index 0000000000000..81b13a652b7eb --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useNodeDetailsRedirect } from './use_node_details_redirect'; +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +const coreStartMock = coreMock.createStart(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(() => ({ + pathname: '', + search: '', + })), +})); + +const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => ( + {children} +); + +describe('useNodeDetailsRedirect', () => { + it('should return the LinkProperties', () => { + const { result } = renderHook(() => useNodeDetailsRedirect(), { wrapper }); + + const fromDateStrig = '2019-01-01T11:00:00Z'; + const toDateStrig = '2019-01-01T12:00:00Z'; + + expect( + result.current.getNodeDetailUrl({ + nodeType: 'host', + nodeId: 'example-01', + search: { + from: new Date(fromDateStrig).getTime(), + to: new Date(toDateStrig).getTime(), + }, + }) + ).toStrictEqual({ + app: 'metrics', + pathname: 'link-to/host-detail/example-01', + search: { from: '1546340400000', to: '1546344000000' }, + }); + }); +}); diff --git a/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.ts b/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.ts new file mode 100644 index 0000000000000..ea4726b2e6816 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/use_node_details_redirect.ts @@ -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 { useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { LinkDescriptor } from '@kbn/observability-shared-plugin/public'; +import useObservable from 'react-use/lib/useObservable'; +import type { InventoryItemType } from '../../../common/inventory_models/types'; +import type { RouteState } from '../../components/asset_details/types'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; + +interface QueryParams { + from?: number; + to?: number; + assetName?: string; +} + +export const useNodeDetailsRedirect = () => { + const location = useLocation(); + const { + services: { + application: { currentAppId$ }, + }, + } = useKibanaContextForPlugin(); + + const appId = useObservable(currentAppId$); + const getNodeDetailUrl = useCallback( + ({ + nodeType, + nodeId, + search, + }: { + nodeType: InventoryItemType; + nodeId: string; + search: QueryParams; + }): LinkDescriptor => { + const { to, from, ...rest } = search; + + return { + app: 'metrics', + pathname: `link-to/${nodeType}-detail/${nodeId}`, + search: { + ...rest, + ...(to && from + ? { + to: `${to}`, + from: `${from}`, + } + : undefined), + // While we don't have a shared state between all page in infra, this makes it possible to restore a page state when returning to the previous route + ...(location.search || location.pathname + ? { + state: JSON.stringify({ + originAppId: appId, + originSearch: location.search, + originPathname: location.pathname, + } as RouteState), + } + : undefined), + }, + }; + }, + [location.pathname, appId, location.search] + ); + + return { getNodeDetailUrl }; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/table/entry_title.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/table/entry_title.tsx index b853ca3c8f9b1..2019d2efa1c4e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/table/entry_title.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/table/entry_title.tsx @@ -6,9 +6,8 @@ */ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip, IconType } from '@elastic/eui'; -import { TimeRange } from '@kbn/es-query'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; -import { encode } from '@kbn/rison'; +import { useNodeDetailsRedirect } from '../../../../link_to'; import type { CloudProvider, HostNodeRow } from '../../hooks/use_hosts_table'; const cloudIcons: Record = { @@ -20,20 +19,24 @@ const cloudIcons: Record = { interface EntryTitleProps { onClick: () => void; - time: TimeRange; + dateRangeTs: { from: number; to: number }; title: HostNodeRow['title']; } -export const EntryTitle = ({ onClick, time, title }: EntryTitleProps) => { +export const EntryTitle = ({ onClick, dateRangeTs, title }: EntryTitleProps) => { const { name, cloudProvider } = title; + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const link = useLinkProps({ - app: 'metrics', - pathname: `/detail/host/${name}`, - search: { - _a: encode({ time: { ...time, interval: '>=1m' } }), - assetName: name, - }, + ...getNodeDetailUrl({ + nodeId: name, + nodeType: 'host', + search: { + from: dateRangeTs.from, + to: dateRangeTs.to, + assetName: name, + }, + }), }); const iconType = (cloudProvider && cloudIcons[cloudProvider]) || cloudIcons.unknownProvider; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx index 19ba258e91b11..f70718fa855a6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx @@ -127,7 +127,7 @@ const sortTableData = export const useHostsTable = () => { const [selectedItems, setSelectedItems] = useState([]); const { hostNodes } = useHostsViewContext(); - const { parsedDateRange } = useUnifiedSearchContext(); + const { getDateRangeAsTimestamp } = useUnifiedSearchContext(); const [{ detailsItemId, pagination, sorting }, setProperties] = useHostsTableUrlState(); const { services: { @@ -236,7 +236,7 @@ export const useHostsTable = () => { render: (title: HostNodeRow['title']) => ( reportHostEntryClick(title)} /> ), @@ -343,7 +343,7 @@ export const useHostsTable = () => { width: '120px', }, ], - [detailsItemId, parsedDateRange, reportHostEntryClick, setProperties] + [detailsItemId, getDateRangeAsTimestamp, reportHostEntryClick, setProperties] ); const selection: EuiTableSelectionType = { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 14d0ab3c55939..4cca6263362ae 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -99,8 +99,11 @@ export const Layout = React.memo(({ currentView, reload, interval, nodes, loadin const dataBounds = calculateBoundsFromNodes(nodes); const bounds = autoBounds ? dataBounds : boundsOverride; - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]); + + const formatter = useCallback( + (val: string | number) => createInventoryMetricFormatter(options.metric)(val), + [options.metric] + ); const { onViewChange } = useWaffleViewState(); useEffect(() => { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index 74fd472629198..ed2f3bc7cba1d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -23,7 +23,7 @@ import { PropertiesTab } from './tabs/properties'; import { AnomaliesTab } from './tabs/anomalies/anomalies'; import { OsqueryTab } from './tabs/osquery'; import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN } from './tabs/shared'; -import { getNodeDetailUrl } from '../../../../link_to'; +import { useNodeDetailsRedirect } from '../../../../link_to'; import { findInventoryModel } from '../../../../../../common/inventory_models'; import { navigateToUptime } from '../../lib/navigate_to_uptime'; import { InfraClientCoreStart, InfraClientStartDeps } from '../../../../../types'; @@ -51,6 +51,7 @@ export const NodeContextPopover = ({ const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; const { application, share } = useKibana().services; + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const uiCapabilities = application?.capabilities; const canCreateAlerts = useMemo( () => Boolean(uiCapabilities?.infrastructure?.save), @@ -81,9 +82,11 @@ export const NodeContextPopover = ({ ...getNodeDetailUrl({ nodeType, nodeId: node.id, - from: nodeDetailFrom, - to: currentTime, - assetName: node.name, + search: { + from: nodeDetailFrom, + to: currentTime, + assetName: node.name, + }, }), }); const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 43de39a14c665..fec97c5fc0720 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -24,7 +24,7 @@ import { import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; -import { getNodeDetailUrl } from '../../../../link_to'; +import { useNodeDetailsRedirect } from '../../../../link_to'; import { findInventoryModel, findInventoryFields } from '../../../../../../common/inventory_models'; import { InventoryItemType } from '../../../../../../common/inventory_models/types'; import { navigateToUptime } from '../../lib/navigate_to_uptime'; @@ -38,6 +38,7 @@ interface Props { export const NodeContextMenu: React.FC = withTheme( ({ options, currentTime, node, nodeType }) => { + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const [flyoutVisible, setFlyoutVisible] = useState(false); const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; @@ -79,8 +80,11 @@ export const NodeContextMenu: React.FC = withTheme ...getNodeDetailUrl({ nodeType, nodeId: node.id, - from: nodeDetailFrom, - to: currentTime, + search: { + from: nodeDetailFrom, + to: currentTime, + assetName: node.name, + }, }), }); const apmTracesMenuItemLinkProps = useLinkProps({ diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx index 339374678ab7f..0ef201b612f2f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/node_details_page.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import dateMath from '@kbn/datemath'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useTemplateHeaderBreadcrumbs } from '../../../../components/asset_details/hooks/use_page_header'; import { useSourceContext } from '../../../../containers/metrics_source'; import { InventoryMetric, InventoryItemType } from '../../../../../common/inventory_models/types'; import { useNodeDetails } from '../hooks/use_node_details'; @@ -54,6 +55,7 @@ const parseRange = (range: MetricsTimeInput) => { export const NodeDetailsPage = (props: Props) => { const { metricIndicesExist } = useSourceContext(); + const { breadcrumbs } = useTemplateHeaderBreadcrumbs(); const [parsedTimeRange, setParsedTimeRange] = useState(parseRange(props.timeRange)); const { metrics, loading, makeRequest, error } = useNodeDetails( props.requiredMetrics, @@ -96,6 +98,7 @@ export const NodeDetailsPage = (props: Props) => { onRefresh={refetch} />, ], + breadcrumbs, }} > diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index 36a7d3d853eb7..f1290cdc83cb3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -8,6 +8,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useRouteMatch } from 'react-router-dom'; +import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; import type { InventoryItemType } from '../../../../common/inventory_models/types'; import { AssetDetailPage } from './asset_detail_page'; import { MetricsTimeProvider } from './hooks/use_metrics_time'; @@ -15,11 +16,17 @@ import { MetricDetailPage } from './metric_detail_page'; export const MetricDetail = () => { const { - params: { type: nodeType }, + params: { type: nodeType, node: nodeName }, } = useRouteMatch<{ type: InventoryItemType; node: string }>(); const PageContent = () => (nodeType === 'host' ? : ); + useMetricsBreadcrumbs([ + { + text: nodeName, + }, + ]); + return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/metric_detail_page.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/metric_detail_page.tsx index 6823147c8b6b0..1f049814a23d3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/metric_detail_page.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/metric_detail_page.tsx @@ -7,10 +7,9 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { useLinkProps } from '@kbn/observability-shared-plugin/public'; import { useRouteMatch } from 'react-router-dom'; -import { useMetadata } from '../../../components/asset_details/hooks/use_metadata'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; +import { useMetadata } from '../../../components/asset_details/hooks/use_metadata'; import { useSourceContext } from '../../../containers/metrics_source'; import { InfraLoadingPanel } from '../../../components/loading'; import { findInventoryModel } from '../../../../common/inventory_models'; @@ -19,7 +18,6 @@ import { NodeDetailsPage } from './components/node_details_page'; import type { InventoryItemType } from '../../../../common/inventory_models/types'; import { useMetricsTimeContext } from './hooks/use_metrics_time'; import { MetricsPageTemplate } from '../page_template'; -import { inventoryTitle } from '../../../translations'; export const MetricDetailPage = () => { const { @@ -57,16 +55,7 @@ export const MetricDetailPage = () => { [sideNav] ); - const inventoryLinkProps = useLinkProps({ - app: 'metrics', - pathname: '/inventory', - }); - useMetricsBreadcrumbs([ - { - ...inventoryLinkProps, - text: inventoryTitle, - }, { text: name, }, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.test.tsx index 8f9106ed04539..248be6093585a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { MetricsExplorerChartContextMenu, createNodeDetailLink, Props } from './chart_context_menu'; +import { MetricsExplorerChartContextMenu, Props } from './chart_context_menu'; import { ReactWrapper, mount } from 'enzyme'; import { options, @@ -40,6 +40,16 @@ const mountComponentWithProviders = (props: Props): ReactWrapper => { ); }; +jest.mock('../../../link_to', () => ({ + useNodeDetailsRedirect: jest.fn(() => ({ + getNodeDetailUrl: jest.fn(() => ({ + app: 'metrics', + pathname: 'link-to/pod-detail/example-01', + search: { from: '1546340400000', to: '1546344000000' }, + })), + })), +})); + describe('MetricsExplorerChartContextMenu', () => { describe('component', () => { it('should just work', async () => { @@ -153,17 +163,4 @@ describe('MetricsExplorerChartContextMenu', () => { expect(component.find('button').length).toBe(1); }); }); - - describe('helpers', () => { - test('createNodeDetailLink()', () => { - const fromDateStrig = '2019-01-01T11:00:00Z'; - const toDateStrig = '2019-01-01T12:00:00Z'; - const link = createNodeDetailLink('host', 'example-01', fromDateStrig, toDateStrig); - expect(link).toStrictEqual({ - app: 'metrics', - pathname: 'link-to/host-detail/example-01', - search: { from: '1546340400000', to: '1546344000000' }, - }); - }); - }); }); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index ac91f16ff040c..0888424c8f2db 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -26,7 +26,7 @@ import { MetricsExplorerChartOptions, } from '../hooks/use_metrics_explorer_options'; import { createTSVBLink } from './helpers/create_tsvb_link'; -import { getNodeDetailUrl } from '../../../link_to/redirect_to_node_detail'; +import { useNodeDetailsRedirect } from '../../../link_to'; import { InventoryItemType } from '../../../../../common/inventory_models/types'; import { HOST_FIELD, POD_FIELD, CONTAINER_FIELD } from '../../../../../common/constants'; @@ -62,20 +62,6 @@ const dateMathExpressionToEpoch = (dateMathExpression: string, roundUp = false): return dateObj.valueOf(); }; -export const createNodeDetailLink = ( - nodeType: InventoryItemType, - nodeId: string, - from: string, - to: string -) => { - return getNodeDetailUrl({ - nodeType, - nodeId, - from: dateMathExpressionToEpoch(from), - to: dateMathExpressionToEpoch(to, true), - }); -}; - export const MetricsExplorerChartContextMenu: React.FC = ({ onFilter, options, @@ -85,6 +71,7 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ uiCapabilities, chartOptions, }: Props) => { + const { getNodeDetailUrl } = useNodeDetailsRedirect(); const [isPopoverOpen, setPopoverState] = useState(false); const [flyoutVisible, setFlyoutVisible] = useState(false); const supportFiltering = options.groupBy != null && onFilter != null; @@ -120,7 +107,16 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ const nodeType = source && options.groupBy && fieldToNodeType(source, options.groupBy); const nodeDetailLinkProps = useLinkProps({ app: 'metrics', - ...(nodeType ? createNodeDetailLink(nodeType, series.id, timeRange.from, timeRange.to) : {}), + ...(nodeType + ? getNodeDetailUrl({ + nodeType, + nodeId: series.id, + search: { + from: dateMathExpressionToEpoch(timeRange.from), + to: dateMathExpressionToEpoch(timeRange.to, true), + }, + }) + : {}), }); const tsvbLinkProps = useLinkProps({ ...createTSVBLink(source, options, series, timeRange, chartOptions),