diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx index 6b946898f6330..7d523faafdb3c 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx @@ -8,22 +8,19 @@ import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { encode } from 'rison-node'; -import url from 'url'; -import { stringify } from 'query-string'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; -import { url as urlUtils } from '../../../../../../../src/plugins/kibana_utils/public'; +import { useLinkProps, LinkDescriptor } from '../../../hooks/use_link_props'; export const AnalyzeInMlButton: React.FunctionComponent<{ jobId: string; partition?: string; timeRange: TimeRange; }> = ({ jobId, partition, timeRange }) => { - const prependBasePath = useKibana().services.http?.basePath?.prepend; - if (!prependBasePath) { - return null; - } - const pathname = prependBasePath('/app/ml'); + const linkProps = useLinkProps( + typeof partition === 'string' + ? getPartitionSpecificSingleMetricViewerLinkDescriptor(jobId, partition, timeRange) + : getOverallAnomalyExplorerLinkDescriptor(jobId, timeRange) + ); const buttonLabel = ( ); return typeof partition === 'string' ? ( - + {buttonLabel} ) : ( - + {buttonLabel} ); }; -const getOverallAnomalyExplorerLink = (pathname: string, jobId: string, timeRange: TimeRange) => { +const getOverallAnomalyExplorerLinkDescriptor = ( + jobId: string, + timeRange: TimeRange +): LinkDescriptor => { const { from, to } = convertTimeRangeToParams(timeRange); const _g = encode({ @@ -62,20 +54,18 @@ const getOverallAnomalyExplorerLink = (pathname: string, jobId: string, timeRang }, }); - const hash = `/explorer?${stringify(urlUtils.encodeQuery({ _g }), { encode: false })}`; - - return url.format({ - pathname, - hash, - }); + return { + app: 'ml', + hash: '/explorer', + search: { _g }, + }; }; -const getPartitionSpecificSingleMetricViewerLink = ( - pathname: string, +const getPartitionSpecificSingleMetricViewerLinkDescriptor = ( jobId: string, partition: string, timeRange: TimeRange -) => { +): LinkDescriptor => { const { from, to } = convertTimeRangeToParams(timeRange); const _g = encode({ @@ -95,15 +85,11 @@ const getPartitionSpecificSingleMetricViewerLink = ( }, }); - const hash = `/timeseriesexplorer?${stringify(urlUtils.encodeQuery({ _g, _a }), { - sort: false, - encode: false, - })}`; - - return url.format({ - pathname, - hash, - }); + return { + app: 'ml', + hash: '/timeseriesexplorer', + search: { _g, _a }, + }; }; const convertTimeRangeToParams = (timeRange: TimeRange): { from: string; to: string } => { diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx index 9a2bbd3dabffc..e045e78471513 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx @@ -7,12 +7,19 @@ import { EuiButton, EuiButtonProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; +import { useLinkProps } from '../../../hooks/use_link_props'; -export const UserManagementLink: React.FunctionComponent = props => ( - - - -); +export const UserManagementLink: React.FunctionComponent = props => { + const linkProps = useLinkProps({ + app: 'kibana', + hash: '/management/security/users', + }); + return ( + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx index 16a91e3727c98..b4fa6b8800fba 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.test.tsx @@ -10,22 +10,35 @@ import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { coreMock } from 'src/core/public/mocks'; + +const coreStartMock = coreMock.createStart(); +coreStartMock.application.getUrlForApp.mockImplementation((app, options) => { + return `/test-basepath/s/test-space/app/${app}${options?.path}`; +}); + +const ProviderWrapper: React.FC = ({ children }) => { + return {children}; +}; describe('LogEntryActionsMenu component', () => { describe('uptime link', () => { it('renders with a host ip filter when present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -38,22 +51,24 @@ describe('LogEntryActionsMenu component', () => { expect( elementWrapper.find(`a${testSubject('~uptimeLogEntryActionsMenuItem')}`).prop('href') - ).toMatchInlineSnapshot(`"/app/uptime#/?search=(host.ip:HOST_IP)"`); + ).toBe('/test-basepath/s/test-space/app/uptime#/?search=host.ip:HOST_IP'); }); it('renders with a container id filter when present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -66,22 +81,24 @@ describe('LogEntryActionsMenu component', () => { expect( elementWrapper.find(`a${testSubject('~uptimeLogEntryActionsMenuItem')}`).prop('href') - ).toMatchInlineSnapshot(`"/app/uptime#/?search=(container.id:CONTAINER_ID)"`); + ).toBe('/test-basepath/s/test-space/app/uptime#/?search=container.id:CONTAINER_ID'); }); it('renders with a pod uid filter when present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -94,26 +111,28 @@ describe('LogEntryActionsMenu component', () => { expect( elementWrapper.find(`a${testSubject('~uptimeLogEntryActionsMenuItem')}`).prop('href') - ).toMatchInlineSnapshot(`"/app/uptime#/?search=(kubernetes.pod.uid:POD_UID)"`); + ).toBe('/test-basepath/s/test-space/app/uptime#/?search=kubernetes.pod.uid:POD_UID'); }); it('renders with a disjunction of filters when multiple present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -126,24 +145,26 @@ describe('LogEntryActionsMenu component', () => { expect( elementWrapper.find(`a${testSubject('~uptimeLogEntryActionsMenuItem')}`).prop('href') - ).toMatchInlineSnapshot( - `"/app/uptime#/?search=(container.id:CONTAINER_ID OR host.ip:HOST_IP OR kubernetes.pod.uid:POD_UID)"` + ).toBe( + '/test-basepath/s/test-space/app/uptime#/?search=container.id:CONTAINER_ID%20or%20host.ip:HOST_IP%20or%20kubernetes.pod.uid:POD_UID' ); }); it('renders as disabled when no supported field is present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -165,17 +186,19 @@ describe('LogEntryActionsMenu component', () => { describe('apm link', () => { it('renders with a trace id filter when present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { @@ -194,20 +217,22 @@ describe('LogEntryActionsMenu component', () => { it('renders with a trace id filter and timestamp when present in log entry', () => { const timestamp = '2019-06-27T17:44:08.693Z'; const elementWrapper = mount( - + + + ); act(() => { @@ -225,17 +250,19 @@ describe('LogEntryActionsMenu component', () => { it('renders as disabled when no supported field is present in log entry', () => { const elementWrapper = mount( - + + + ); act(() => { diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx index 60e50f486d22a..206e9821190fb 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx @@ -4,41 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; -import url from 'url'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useVisibilityState } from '../../../utils/use_visibility_state'; import { getTraceUrl } from '../../../../../../legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks'; import { LogEntriesItem } from '../../../../common/http_api'; +import { useLinkProps, LinkDescriptor } from '../../../hooks/use_link_props'; +import { decodeOrThrow } from '../../../../common/runtime_types'; const UPTIME_FIELDS = ['container.id', 'host.ip', 'kubernetes.pod.uid']; export const LogEntryActionsMenu: React.FunctionComponent<{ logItem: LogEntriesItem; }> = ({ logItem }) => { - const prependBasePath = useKibana().services.http?.basePath?.prepend; const { hide, isVisible, show } = useVisibilityState(false); - const uptimeLink = useMemo(() => { - const link = getUptimeLink(logItem); - return prependBasePath && link ? prependBasePath(link) : link; - }, [logItem, prependBasePath]); + const apmLinkDescriptor = useMemo(() => getAPMLink(logItem), [logItem]); + const uptimeLinkDescriptor = useMemo(() => getUptimeLink(logItem), [logItem]); - const apmLink = useMemo(() => { - const link = getAPMLink(logItem); - return prependBasePath && link ? prependBasePath(link) : link; - }, [logItem, prependBasePath]); + const uptimeLinkProps = useLinkProps({ + app: 'uptime', + ...(uptimeLinkDescriptor ? uptimeLinkDescriptor : {}), + }); + + const apmLinkProps = useLinkProps({ + app: 'apm', + ...(apmLinkDescriptor ? apmLinkDescriptor : {}), + }); const menuItems = useMemo( () => [ , , ], - [apmLink, uptimeLink] + [uptimeLinkDescriptor, apmLinkDescriptor, apmLinkProps, uptimeLinkProps] ); const hasMenuItems = useMemo(() => menuItems.length > 0, [menuItems]); @@ -89,22 +92,32 @@ export const LogEntryActionsMenu: React.FunctionComponent<{ ); }; -const getUptimeLink = (logItem: LogEntriesItem) => { +const getUptimeLink = (logItem: LogEntriesItem): LinkDescriptor | undefined => { const searchExpressions = logItem.fields .filter(({ field, value }) => value != null && UPTIME_FIELDS.includes(field)) - .map(({ field, value }) => `${field}:${value}`); + .reduce((acc, fieldItem) => { + const { field, value } = fieldItem; + try { + const parsedValue = decodeOrThrow(rt.array(rt.string))(JSON.parse(value)); + return acc.concat(parsedValue.map(val => `${field}:${val}`)); + } catch (e) { + return acc.concat([`${field}:${value}`]); + } + }, []); if (searchExpressions.length === 0) { return undefined; } - - return url.format({ - pathname: '/app/uptime', - hash: `/?search=(${searchExpressions.join(' OR ')})`, - }); + return { + app: 'uptime', + hash: '/', + search: { + search: `${searchExpressions.join(' or ')}`, + }, + }; }; -const getAPMLink = (logItem: LogEntriesItem) => { +const getAPMLink = (logItem: LogEntriesItem): LinkDescriptor | undefined => { const traceIdEntry = logItem.fields.find( ({ field, value }) => value != null && field === 'trace.id' ); @@ -127,8 +140,8 @@ const getAPMLink = (logItem: LogEntriesItem) => { })() : { rangeFrom: 'now-1y', rangeTo: 'now' }; - return url.format({ - pathname: '/app/apm', + return { + app: 'apm', hash: getTraceUrl({ traceId: traceIdEntry.value, rangeFrom, rangeTo }), - }); + }; }; diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx index 9c3319d467ae2..a23a2739a8e23 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { MetricsExplorerChartContextMenu, createNodeDetailLink, Props } from './chart_context_menu'; import { ReactWrapper, mount } from 'enzyme'; import { options, source, timeRange, chartOptions } from '../../utils/fixtures/metrics_explorer'; -import DateMath from '@elastic/datemath'; import { Capabilities } from 'src/core/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { coreMock } from 'src/core/public/mocks'; +const coreStartMock = coreMock.createStart(); const series = { id: 'exmaple-01', rows: [], columns: [] }; const uiCapabilities: Capabilities = { navLinks: { show: false }, @@ -25,17 +26,8 @@ const getTestSubject = (component: ReactWrapper, name: string) => { }; const mountComponentWithProviders = (props: Props): ReactWrapper => { - const services = { - http: { - fetch: jest.fn(), - }, - application: { - getUrlForApp: jest.fn(), - }, - }; - return mount( - + ); @@ -159,10 +151,12 @@ describe('MetricsExplorerChartContextMenu', () => { test('createNodeDetailLink()', () => { const fromDateStrig = '2019-01-01T11:00:00Z'; const toDateStrig = '2019-01-01T12:00:00Z'; - const to = DateMath.parse(toDateStrig, { roundUp: true })!; - const from = DateMath.parse(fromDateStrig)!; const link = createNodeDetailLink('host', 'example-01', fromDateStrig, toDateStrig); - expect(link).toBe(`link-to/host-detail/example-01?to=${to.valueOf()}&from=${from.valueOf()}`); + 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/components/metrics_explorer/chart_context_menu.tsx b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx index f7c97033f8d50..c50550f1de56f 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx @@ -24,7 +24,7 @@ import { createTSVBLink } from './helpers/create_tsvb_link'; import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail'; import { SourceConfiguration } from '../../utils/source_configuration'; import { InventoryItemType } from '../../../common/inventory_models/types'; -import { usePrefixPathWithBasepath } from '../../hooks/use_prefix_path_with_basepath'; +import { useLinkProps } from '../../hooks/use_link_props'; export interface Props { options: MetricsExplorerOptions; @@ -80,7 +80,6 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ uiCapabilities, chartOptions, }: Props) => { - const urlPrefixer = usePrefixPathWithBasepath(); const [isPopoverOpen, setPopoverState] = useState(false); const supportFiltering = options.groupBy != null && onFilter != null; const handleFilter = useCallback(() => { @@ -92,8 +91,6 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ setPopoverState(false); }, [supportFiltering, options.groupBy, series.id, onFilter]); - const tsvbUrl = createTSVBLink(source, options, series, timeRange, chartOptions); - // Only display the "Add Filter" option if it's supported const filterByItem = supportFiltering ? [ @@ -109,6 +106,13 @@ 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) : {}), + }); + const tsvbLinkProps = useLinkProps({ + ...createTSVBLink(source, options, series, timeRange, chartOptions), + }); const viewNodeDetail = nodeType ? [ { @@ -117,10 +121,7 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ values: { name: nodeType }, }), icon: 'metricsApp', - href: urlPrefixer( - 'metrics', - createNodeDetailLink(nodeType, series.id, timeRange.from, timeRange.to) - ), + ...(nodeType ? nodeDetailLinkProps : {}), 'data-test-subj': 'metricsExplorerAction-ViewNodeMetrics', }, ] @@ -132,7 +133,7 @@ export const MetricsExplorerChartContextMenu: React.FC = ({ name: i18n.translate('xpack.infra.metricsExplorer.openInTSVB', { defaultMessage: 'Open in Visualize', }), - href: tsvbUrl, + ...tsvbLinkProps, icon: 'visualizeApp', disabled: options.metrics.length === 0, 'data-test-subj': 'metricsExplorerAction-OpenInTSVB', diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.test.ts b/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.test.ts index 111f6678081f7..05637642b8dd9 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.test.ts +++ b/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.test.ts @@ -22,9 +22,16 @@ const series = { id: 'example-01', rows: [], columns: [] }; describe('createTSVBLink()', () => { it('should just work', () => { const link = createTSVBLink(source, options, series, timeRange, chartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should work with rates', () => { @@ -33,16 +40,30 @@ describe('createTSVBLink()', () => { metrics: [{ aggregation: 'rate', field: 'system.network.out.bytes' }], }; const link = createTSVBLink(source, customOptions, series, timeRange, chartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:bytes,id:test-id,label:'rate(system.network.out.bytes)',line_width:2,metrics:!((field:system.network.out.bytes,id:test-id,type:max),(field:test-id,id:test-id,type:derivative,unit:'1s'),(field:test-id,id:test-id,type:positive_only)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}}/s)),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:bytes,id:test-id,label:'rate(system.network.out.bytes)',line_width:2,metrics:!((field:system.network.out.bytes,id:test-id,type:max),(field:test-id,id:test-id,type:derivative,unit:'1s'),(field:test-id,id:test-id,type:positive_only)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}}/s)),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should work with time range', () => { const customTimeRange = { ...timeRange, from: 'now-10m', to: 'now' }; const link = createTSVBLink(source, options, series, customTimeRange, chartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-10m,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-10m,to:now))', + type: 'metrics', + }, + }); }); it('should work with source', () => { const customSource = { @@ -51,9 +72,16 @@ describe('createTSVBLink()', () => { fields: { ...source.fields, timestamp: 'time' }, }; const link = createTSVBLink(customSource, options, series, timeRange, chartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'my-beats-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'my-beats-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'my-beats-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'my-beats-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should work with filterQuery', () => { const customSource = { @@ -63,25 +91,46 @@ describe('createTSVBLink()', () => { }; const customOptions = { ...options, filterQuery: 'system.network.name:lo*' }; const link = createTSVBLink(customSource, customOptions, series, timeRange, chartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'my-beats-*',filter:(language:kuery,query:'system.network.name:lo* and host.name : \"example-01\"'),id:test-id,index_pattern:'my-beats-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'my-beats-*',filter:(language:kuery,query:'system.network.name:lo* and host.name : \"example-01\"'),id:test-id,index_pattern:'my-beats-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should remove axis_min from link', () => { const customChartOptions = { ...chartOptions, yAxisMode: MetricsExplorerYAxisMode.auto }; const link = createTSVBLink(source, options, series, timeRange, customChartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should change series to area', () => { const customChartOptions = { ...chartOptions, type: MetricsExplorerChartType.area }; const link = createTSVBLink(source, options, series, timeRange, customChartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0.5,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0.5,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); it('should change series to area and stacked', () => { @@ -91,9 +140,16 @@ describe('createTSVBLink()', () => { stack: true, }; const link = createTSVBLink(source, options, series, timeRange, customChartOptions); - expect(link).toBe( - "../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%236092C0,fill:0.5,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:stacked,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))" - ); + expect(link).toStrictEqual({ + app: 'kibana', + hash: '/visualize/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0.5,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:stacked,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); }); test('createFilterFromOptions()', () => { diff --git a/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.ts index cb23a96b9c163..20706f563ec63 100644 --- a/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/components/metrics_explorer/helpers/create_tsvb_link.ts @@ -21,6 +21,7 @@ import { metricToFormat } from './metric_to_format'; import { InfraFormatterType } from '../../../lib/lib'; import { SourceQuery } from '../../../graphql/types'; import { createMetricLabel } from './create_metric_label'; +import { LinkDescriptor } from '../../../hooks/use_link_props'; export const metricsExplorerMetricToTSVBMetric = (metric: MetricsExplorerOptionsMetric) => { if (metric.aggregation === 'rate') { @@ -64,10 +65,9 @@ const mapMetricToSeries = (chartOptions: MetricsExplorerChartOptions) => ( label: createMetricLabel(metric), axis_position: 'right', chart_type: 'line', - color: encodeURIComponent( + color: (metric.color && colorTransformer(metric.color)) || - colorTransformer(MetricsExplorerColor.color0) - ), + colorTransformer(MetricsExplorerColor.color0), fill: chartOptions.type === MetricsExplorerChartType.area ? 0.5 : 0, formatter: format === InfraFormatterType.bits ? InfraFormatterType.bytes : format, value_template: 'rate' === metric.aggregation ? '{{value}}/s' : '{{value}}', @@ -102,7 +102,7 @@ export const createTSVBLink = ( series: MetricsExplorerSeries, timeRange: MetricsExplorerTimeOptions, chartOptions: MetricsExplorerChartOptions -) => { +): LinkDescriptor => { const appState = { filters: [], linked: false, @@ -139,7 +139,13 @@ export const createTSVBLink = ( time: { from: timeRange.from, to: timeRange.to }, }; - return `../app/kibana#/visualize/create?type=metrics&_g=${encode(globalState)}&_a=${encode( - appState as any - )}`; + return { + app: 'kibana', + hash: '/visualize/create', + search: { + type: 'metrics', + _g: encode(globalState), + _a: encode(appState as any), + }, + }; }; diff --git a/x-pack/plugins/infra/public/components/navigation/routed_tabs.tsx b/x-pack/plugins/infra/public/components/navigation/routed_tabs.tsx index 2838ac6cda6dd..d9ea44e2f1f6a 100644 --- a/x-pack/plugins/infra/public/components/navigation/routed_tabs.tsx +++ b/x-pack/plugins/infra/public/components/navigation/routed_tabs.tsx @@ -9,55 +9,51 @@ import React from 'react'; import { Route } from 'react-router-dom'; import { euiStyled } from '../../../../observability/public'; +import { useLinkProps } from '../../hooks/use_link_props'; +import { LinkDescriptor } from '../../hooks/use_link_props'; -interface TabConfiguration { +interface TabConfig { title: string | React.ReactNode; - path: string; } +type TabConfiguration = TabConfig & LinkDescriptor; + interface RoutedTabsProps { tabs: TabConfiguration[]; } const noop = () => {}; -export class RoutedTabs extends React.Component { - public render() { - return {this.renderTabs()}; - } - - private renderTabs() { - return this.props.tabs.map(tab => { - return ( - { - return ( - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - ) => { - e.preventDefault(); - history.push(tab.path); - }} - > - - {tab.title} - - - - ); - }} - /> - ); - }); - } -} +export const RoutedTabs = ({ tabs }: RoutedTabsProps) => { + return ( + + {tabs.map(tab => { + return ; + })} + + ); +}; + +const Tab = ({ title, pathname, app }: TabConfiguration) => { + const linkProps = useLinkProps({ app, pathname }); + return ( + { + return ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + {title} + + + + ); + }} + /> + ); +}; const TabContainer = euiStyled.div` .euiLink { diff --git a/x-pack/plugins/infra/public/components/source_configuration/view_source_configuration_button.tsx b/x-pack/plugins/infra/public/components/source_configuration/view_source_configuration_button.tsx index 9c3a40fb7ecf0..93cec8a1c7242 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/view_source_configuration_button.tsx +++ b/x-pack/plugins/infra/public/components/source_configuration/view_source_configuration_button.tsx @@ -6,28 +6,23 @@ import { EuiButton } from '@elastic/eui'; import React from 'react'; -import { Route } from 'react-router-dom'; +import { useLinkProps } from '../../hooks/use_link_props'; interface ViewSourceConfigurationButtonProps { 'data-test-subj'?: string; children: React.ReactNode; + app: 'logs' | 'metrics'; } export const ViewSourceConfigurationButton = ({ 'data-test-subj': dataTestSubj, + app, children, }: ViewSourceConfigurationButtonProps) => { - const href = '/settings'; - + const linkProps = useLinkProps({ app, pathname: '/settings' }); return ( - ( - history.push(href)}> - {children} - - )} - /> + + {children} + ); }; diff --git a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts index fb9791fae9b5e..18e5838a15b56 100644 --- a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts +++ b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts @@ -46,7 +46,11 @@ describe('createUptimeLink()', () => { avg: 0.6, }, }; - expect(createUptimeLink(options, 'host', node)).toBe('#/?search=host.ip:"10.0.1.2"'); + expect(createUptimeLink(options, 'host', node)).toStrictEqual({ + app: 'uptime', + hash: '/', + search: { search: 'host.ip:"10.0.1.2"' }, + }); }); it('should work for hosts without ip', () => { @@ -62,7 +66,11 @@ describe('createUptimeLink()', () => { avg: 0.6, }, }; - expect(createUptimeLink(options, 'host', node)).toBe('#/?search=host.name:"host-01"'); + expect(createUptimeLink(options, 'host', node)).toStrictEqual({ + app: 'uptime', + hash: '/', + search: { search: 'host.name:"host-01"' }, + }); }); it('should work for pods', () => { @@ -78,9 +86,11 @@ describe('createUptimeLink()', () => { avg: 0.6, }, }; - expect(createUptimeLink(options, 'pod', node)).toBe( - '#/?search=kubernetes.pod.uid:"29193-pod-02939"' - ); + expect(createUptimeLink(options, 'pod', node)).toStrictEqual({ + app: 'uptime', + hash: '/', + search: { search: 'kubernetes.pod.uid:"29193-pod-02939"' }, + }); }); it('should work for container', () => { @@ -96,8 +106,10 @@ describe('createUptimeLink()', () => { avg: 0.6, }, }; - expect(createUptimeLink(options, 'container', node)).toBe( - '#/?search=container.id:"docker-1234"' - ); + expect(createUptimeLink(options, 'container', node)).toStrictEqual({ + app: 'uptime', + hash: '/', + search: { search: 'container.id:"docker-1234"' }, + }); }); }); diff --git a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.ts b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.ts index 023a1a4b6ecef..72b46f4fb5c7b 100644 --- a/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.ts +++ b/x-pack/plugins/infra/public/components/waffle/lib/create_uptime_link.ts @@ -7,15 +7,28 @@ import { get } from 'lodash'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../lib/lib'; import { InventoryItemType } from '../../../../common/inventory_models/types'; +import { LinkDescriptor } from '../../../hooks/use_link_props'; export const createUptimeLink = ( options: InfraWaffleMapOptions, nodeType: InventoryItemType, node: InfraWaffleMapNode -) => { +): LinkDescriptor => { if (nodeType === 'host' && node.ip) { - return `#/?search=host.ip:"${node.ip}"`; + return { + app: 'uptime', + hash: '/', + search: { + search: `host.ip:"${node.ip}"`, + }, + }; } const field = get(options, ['fields', nodeType], ''); - return `#/?search=${field ? field + ':' : ''}"${node.id}"`; + return { + app: 'uptime', + hash: '/', + search: { + search: `${field ? field + ':' : ''}"${node.id}"`, + }, + }; }; diff --git a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx index 43d27bb8259b3..cc6a94c8a41a2 100644 --- a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -24,7 +24,7 @@ import { SectionLinks, SectionLink, } from '../../../../observability/public'; -import { usePrefixPathWithBasepath } from '../../hooks/use_prefix_path_with_basepath'; +import { useLinkProps } from '../../hooks/use_link_props'; interface Props { options: InfraWaffleMapOptions; @@ -46,10 +46,9 @@ export const NodeContextMenu: React.FC = ({ nodeType, popoverPosition, }) => { - const urlPrefixer = usePrefixPathWithBasepath(); - const uiCapabilities = useKibana().services.application?.capabilities; const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; + const uiCapabilities = useKibana().services.application?.capabilities; // Due to the changing nature of the fields between APM and this UI, // We need to have some exceptions until 7.0 & ECS is finalized. Reference // #26620 for the details for these fields. @@ -81,19 +80,37 @@ export const NodeContextMenu: React.FC = ({ return { label: '', value: '' }; }, [nodeType, node.ip, node.id, options.fields]); + const nodeLogsMenuItemLinkProps = useLinkProps({ + app: 'logs', + ...getNodeLogsUrl({ + nodeType, + nodeId: node.id, + time: currentTime, + }), + }); + const nodeDetailMenuItemLinkProps = useLinkProps({ + ...getNodeDetailUrl({ + nodeType, + nodeId: node.id, + from: nodeDetailFrom, + to: currentTime, + }), + }); + const apmTracesMenuItemLinkProps = useLinkProps({ + app: 'apm', + hash: 'traces', + search: { + kuery: `${apmField}:"${node.id}"`, + }, + }); + const uptimeMenuItemLinkProps = useLinkProps(createUptimeLink(options, nodeType, node)); + const nodeLogsMenuItem: SectionLinkProps = { label: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', { defaultMessage: '{inventoryName} logs', values: { inventoryName: inventoryModel.singularDisplayName }, }), - href: urlPrefixer( - 'logs', - getNodeLogsUrl({ - nodeType, - nodeId: node.id, - time: currentTime, - }) - ), + ...nodeLogsMenuItemLinkProps, 'data-test-subj': 'viewLogsContextMenuItem', isDisabled: !showLogsLink, }; @@ -103,15 +120,7 @@ export const NodeContextMenu: React.FC = ({ defaultMessage: '{inventoryName} metrics', values: { inventoryName: inventoryModel.singularDisplayName }, }), - href: urlPrefixer( - 'metrics', - getNodeDetailUrl({ - nodeType, - nodeId: node.id, - from: nodeDetailFrom, - to: currentTime, - }) - ), + ...nodeDetailMenuItemLinkProps, isDisabled: !showDetail, }; @@ -120,7 +129,7 @@ export const NodeContextMenu: React.FC = ({ defaultMessage: '{inventoryName} APM traces', values: { inventoryName: inventoryModel.singularDisplayName }, }), - href: urlPrefixer('apm', `#traces?_g=()&kuery=${apmField}:"${node.id}"`), + ...apmTracesMenuItemLinkProps, 'data-test-subj': 'viewApmTracesContextMenuItem', isDisabled: !showAPMTraceLink, }; @@ -130,7 +139,7 @@ export const NodeContextMenu: React.FC = ({ defaultMessage: '{inventoryName} in Uptime', values: { inventoryName: inventoryModel.singularDisplayName }, }), - href: urlPrefixer('uptime', createUptimeLink(options, nodeType, node)), + ...uptimeMenuItemLinkProps, isDisabled: !showUptimeLink, }; @@ -163,28 +172,10 @@ export const NodeContextMenu: React.FC = ({ )} - - - - + + + + diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx new file mode 100644 index 0000000000000..13e054de2dcf7 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { encode } from 'rison-node'; +import { createMemoryHistory, LocationDescriptorObject } from 'history'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { HistoryContext } from '../utils/history_context'; +import { coreMock } from 'src/core/public/mocks'; +import { useLinkProps, LinkDescriptor } from './use_link_props'; + +const PREFIX = '/test-basepath/s/test-space/app/'; + +const coreStartMock = coreMock.createStart(); + +coreStartMock.application.getUrlForApp.mockImplementation((app, options) => { + return `${PREFIX}${app}${options?.path}`; +}); + +const INTERNAL_APP = 'metrics'; + +// Note: Memory history doesn't support basename, +// we'll work around this by re-assigning 'createHref' so that +// it includes a basename, this then acts as our browserHistory instance would. +const history = createMemoryHistory(); +const originalCreateHref = history.createHref; +history.createHref = (location: LocationDescriptorObject): string => { + return `${PREFIX}${INTERNAL_APP}${originalCreateHref.call(history, location)}`; +}; + +const ProviderWrapper: React.FC = ({ children }) => { + return ( + + {children}; + + ); +}; + +const renderUseLinkPropsHook = (props?: Partial) => { + return renderHook(() => useLinkProps({ app: INTERNAL_APP, ...props }), { + wrapper: ProviderWrapper, + }); +}; +describe('useLinkProps hook', () => { + describe('Handles internal linking', () => { + it('Provides the correct baseline props', () => { + const { result } = renderUseLinkPropsHook({ pathname: '/' }); + expect(result.current.href).toBe('/test-basepath/s/test-space/app/metrics/'); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with options', () => { + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with more complex encoding', () => { + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host + host', + name: 'this name has spaces and ** and %', + id: 'some-id', + count: '12345', + animals: ['dog', 'cat', 'bear'], + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' + ); + expect(result.current.onClick).toBeDefined(); + }); + + it('Provides the correct props with a consumer using Rison encoding for search', () => { + const state = { + refreshInterval: { pause: true, value: 0 }, + time: { from: 12345, to: 54321 }, + }; + const { result } = renderUseLinkPropsHook({ + pathname: '/inventory', + search: { + type: 'host + host', + state: encode(state), + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/metrics/inventory?type=host%20%2B%20host&state=(refreshInterval:(pause:!t,value:0),time:(from:12345,to:54321))' + ); + expect(result.current.onClick).toBeDefined(); + }); + }); + + describe('Handles external linking', () => { + it('Provides the correct baseline props', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/', + }); + expect(result.current.href).toBe('/test-basepath/s/test-space/app/ml/'); + expect(result.current.onClick).not.toBeDefined(); + }); + + it('Provides the correct props with pathname options', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + pathname: '/explorer', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml/explorer?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).not.toBeDefined(); + }); + + it('Provides the correct props with hash options', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + hash: '/explorer', + search: { + type: 'host', + id: 'some-id', + count: '12345', + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml#/explorer?type=host&id=some-id&count=12345' + ); + expect(result.current.onClick).not.toBeDefined(); + }); + + it('Provides the correct props with more complex encoding', () => { + const { result } = renderUseLinkPropsHook({ + app: 'ml', + hash: '/explorer', + search: { + type: 'host + host', + name: 'this name has spaces and ** and %', + id: 'some-id', + count: '12345', + animals: ['dog', 'cat', 'bear'], + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/ml#/explorer?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' + ); + expect(result.current.onClick).not.toBeDefined(); + }); + + it('Provides the correct props with a consumer using Rison encoding for search', () => { + const state = { + refreshInterval: { pause: true, value: 0 }, + time: { from: 12345, to: 54321 }, + }; + const { result } = renderUseLinkPropsHook({ + app: 'rison-app', + hash: 'rison-route', + search: { + type: 'host + host', + state: encode(state), + }, + }); + expect(result.current.href).toBe( + '/test-basepath/s/test-space/app/rison-app#rison-route?type=host%20%2B%20host&state=(refreshInterval:(pause:!t,value:0),time:(from:12345,to:54321))' + ); + expect(result.current.onClick).not.toBeDefined(); + }); + }); +}); diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.tsx new file mode 100644 index 0000000000000..e60ab32046832 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_link_props.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; +import { stringify } from 'query-string'; +import url from 'url'; +import { url as urlUtils } from '../../../../../src/plugins/kibana_utils/public'; +import { usePrefixPathWithBasepath } from './use_prefix_path_with_basepath'; +import { useHistory } from '../utils/history_context'; + +type Search = Record; + +export interface LinkDescriptor { + app: string; + pathname?: string; + hash?: string; + search?: Search; +} + +interface LinkProps { + href?: string; + onClick?: (e: React.MouseEvent | React.MouseEvent) => void; +} + +export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): LinkProps => { + validateParams({ app, pathname, hash, search }); + + const history = useHistory(); + const prefixer = usePrefixPathWithBasepath(); + + const encodedSearch = useMemo(() => { + return search ? encodeSearch(search) : undefined; + }, [search]); + + const internalLinkResult = useMemo(() => { + // When the logs / metrics apps are first mounted a history instance is setup with a 'basename' equal to the + // 'appBasePath' received from Core's 'AppMountParams', e.g. /BASE_PATH/s/SPACE_ID/app/APP_ID. With internal + // linking we are using 'createHref' and 'push' on top of this history instance. So a pathname of /inventory used within + // the metrics app will ultimatey end up as /BASE_PATH/s/SPACE_ID/app/metrics/inventory. React-router responds to this + // as it is instantiated with the same history instance. + return history?.createHref({ + pathname: pathname ? formatPathname(pathname) : undefined, + search: encodedSearch, + }); + }, [history, pathname, encodedSearch]); + + const externalLinkResult = useMemo(() => { + // The URI spec defines that the query should appear before the fragment + // https://tools.ietf.org/html/rfc3986#section-3 (e.g. url.format()). However, in Kibana, apps that use + // hash based routing expect the query to be part of the hash. This will handle that. + const mergedHash = hash && encodedSearch ? `${hash}?${encodedSearch}` : hash; + + const link = url.format({ + pathname, + hash: mergedHash, + search: !hash ? encodedSearch : undefined, + }); + + return prefixer(app, link); + }, [hash, encodedSearch, pathname, prefixer, app]); + + const onClick = useMemo(() => { + // If these results are equal we know we're trying to navigate within the same application + // that the current history instance is representing + if (internalLinkResult && linksAreEquivalent(externalLinkResult, internalLinkResult)) { + return (e: React.MouseEvent | React.MouseEvent) => { + e.preventDefault(); + if (history) { + history.push({ + pathname: pathname ? formatPathname(pathname) : undefined, + search: encodedSearch, + }); + } + }; + } else { + return undefined; + } + }, [internalLinkResult, externalLinkResult, history, pathname, encodedSearch]); + + return { + href: externalLinkResult, + onClick, + }; +}; + +const encodeSearch = (search: Search) => { + return stringify(urlUtils.encodeQuery(search), { sort: false, encode: false }); +}; + +const formatPathname = (pathname: string) => { + return pathname[0] === '/' ? pathname : `/${pathname}`; +}; + +const validateParams = ({ app, pathname, hash, search }: LinkDescriptor) => { + if (!app && hash) { + throw new Error( + 'The metrics and logs apps use browserHistory. Please provide a pathname rather than a hash.' + ); + } +}; + +const linksAreEquivalent = (externalLink: string, internalLink: string): boolean => { + // Compares with trailing slashes removed. This handles the case where the pathname is '/' + // and 'createHref' will include the '/' but Kibana's 'getUrlForApp' will remove it. + return externalLink.replace(/\/$/, '') === internalLink.replace(/\/$/, ''); +}; diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index 2271147c9d088..b4ff7aeff696c 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -62,22 +62,25 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { diff --git a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx index ba0e9b436e4e7..dbb8b2d8e2952 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/snapshot/index.tsx @@ -6,7 +6,6 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { identity } from 'fp-ts/lib/function'; import React, { useContext } from 'react'; import { SnapshotPageContent } from './page_content'; @@ -25,6 +24,7 @@ import { WithWaffleOptionsUrlState } from '../../../containers/waffle/with_waffl import { WithWaffleTimeUrlState } from '../../../containers/waffle/with_waffle_time'; import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useLinkProps } from '../../../hooks/use_link_props'; export const SnapshotPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -39,7 +39,10 @@ export const SnapshotPage = () => { useTrackPageview({ app: 'infra_metrics', path: 'inventory' }); useTrackPageview({ app: 'infra_metrics', path: 'inventory', delay: 15000 }); - const prependBasePath = useKibana().services.http?.basePath.prepend ?? identity; + const tutorialLinkProps = useLinkProps({ + app: 'kibana', + hash: '/home/tutorial_directory/metrics', + }); return ( @@ -77,7 +80,7 @@ export const SnapshotPage = () => { { {uiCapabilities?.infrastructure?.configureSource ? ( - + {i18n.translate('xpack.infra.configureSourceActionLabel', { defaultMessage: 'Change source configuration', })} 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 55dd15158b96f..9eae632756a3f 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 @@ -10,6 +10,7 @@ import { Redirect, RouteComponentProps } from 'react-router-dom'; import { replaceMetricTimeInQueryString } from '../metrics/containers/with_metrics_time'; import { getFromFromLocation, getToFromLocation } from './query_params'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { LinkDescriptor } from '../../hooks/use_link_props'; type RedirectToNodeDetailProps = RouteComponentProps<{ nodeId: string; @@ -40,7 +41,16 @@ export const getNodeDetailUrl = ({ nodeId: string; to?: number; from?: number; -}) => { - const args = to && from ? `?to=${to}&from=${from}` : ''; - return `link-to/${nodeType}-detail/${nodeId}${args}`; +}): LinkDescriptor => { + return { + app: 'metrics', + pathname: `link-to/${nodeType}-detail/${nodeId}`, + search: + to && from + ? { + to: `${to}`, + from: `${from}`, + } + : undefined, + }; }; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 9c998085400ca..d9aaa2da7bbc8 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -19,6 +19,7 @@ import { getFilterFromLocation, getTimeFromLocation } from './query_params'; import { useSource } from '../../containers/source/source'; import { findInventoryFields } from '../../../common/inventory_models'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { LinkDescriptor } from '../../hooks/use_link_props'; type RedirectToNodeLogsType = RouteComponentProps<{ nodeId: string; @@ -81,6 +82,14 @@ export const getNodeLogsUrl = ({ nodeId: string; nodeType: InventoryItemType; time?: number; -}) => { - return [`link-to/${nodeType}-logs/`, nodeId, ...(time ? [`?time=${time}`] : [])].join(''); +}): LinkDescriptor => { + return { + app: 'logs', + pathname: `link-to/${nodeType}-logs/${nodeId}`, + search: time + ? { + time: `${time}`, + } + : undefined, + }; }; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 48ead15b2a232..3ead026b10065 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -31,23 +31,27 @@ export const LogsPageContent: React.FunctionComponent = () => { const logAnalysisCapabilities = useLogAnalysisCapabilitiesContext(); const streamTab = { + app: 'logs', title: streamTabTitle, - path: '/stream', + pathname: '/stream', }; const logRateTab = { + app: 'logs', title: logRateTabTitle, - path: '/log-rate', + pathname: '/log-rate', }; const logCategoriesTab = { + app: 'logs', title: logCategoriesTabTitle, - path: '/log-categories', + pathname: '/log-categories', }; const settingsTab = { + app: 'logs', title: settingsTabTitle, - path: '/settings', + pathname: '/settings', }; return ( @@ -85,11 +89,11 @@ export const LogsPageContent: React.FunctionComponent = () => { - - - - - + + + + + )} diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx index 739bad5689a96..7a84e67e8fc3d 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx @@ -6,20 +6,24 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { identity } from 'fp-ts/lib/function'; import React from 'react'; import { NoIndices } from '../../../components/empty_states/no_indices'; import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useLinkProps } from '../../../hooks/use_link_props'; export const LogsPageNoIndicesContent = () => { const { - services: { application, http }, + services: { application }, } = useKibana<{}>(); const canConfigureSource = application?.capabilities?.logs?.configureSource ? true : false; - const prependBasePath = http?.basePath.prepend ?? identity; + + const tutorialLinkProps = useLinkProps({ + app: 'kibana', + hash: '/home/tutorial_directory/logging', + }); return ( { { {canConfigureSource ? ( - + {i18n.translate('xpack.infra.configureSourceActionLabel', { defaultMessage: 'Change source configuration', })} diff --git a/x-pack/plugins/infra/public/pages/metrics/components/invalid_node.tsx b/x-pack/plugins/infra/public/pages/metrics/components/invalid_node.tsx index 43f684cd5a585..b089e2237c2e5 100644 --- a/x-pack/plugins/infra/public/pages/metrics/components/invalid_node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/components/invalid_node.tsx @@ -6,19 +6,20 @@ import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { identity } from 'fp-ts/lib/function'; import React from 'react'; - import { euiStyled } from '../../../../../observability/public'; import { ViewSourceConfigurationButton } from '../../../components/source_configuration'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useLinkProps } from '../../../hooks/use_link_props'; interface InvalidNodeErrorProps { nodeName: string; } export const InvalidNodeError: React.FunctionComponent = ({ nodeName }) => { - const prependBasePath = useKibana().services.http?.basePath.prepend ?? identity; + const tutorialLinkProps = useLinkProps({ + app: 'kibana', + hash: '/home/tutorial_directory/metrics', + }); return ( = actions={ - + = - +