diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts index d428ebd3e4909..64315400f21a7 100644 --- a/x-pack/plugins/infra/common/constants.ts +++ b/x-pack/plugins/infra/common/constants.ts @@ -21,3 +21,6 @@ export const TIEBREAKER_FIELD = '_doc'; export const HOST_FIELD = 'host.name'; export const CONTAINER_FIELD = 'container.id'; export const POD_FIELD = 'kubernetes.pod.uid'; + +export const DISCOVER_APP_TARGET = 'discover'; +export const LOGS_APP_TARGET = 'logs-ui'; diff --git a/x-pack/plugins/infra/public/apps/discover_app.tsx b/x-pack/plugins/infra/public/apps/discover_app.tsx index 807b64845cdc7..dd99a5e4dd625 100644 --- a/x-pack/plugins/infra/public/apps/discover_app.tsx +++ b/x-pack/plugins/infra/public/apps/discover_app.tsx @@ -4,95 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { interpret } from 'xstate'; -import type { DiscoverStart } from '@kbn/discover-plugin/public'; import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { AppMountParameters, CoreStart } from '@kbn/core/public'; -import type { InfraClientStartDeps, InfraClientStartExports } from '../types'; -import type { LogViewColumnConfiguration, ResolvedLogView } from '../../common/log_views'; -import { - createLogViewStateMachine, - DEFAULT_LOG_VIEW, - initializeFromUrl, -} from '../observability_logs/log_view_state'; -import { MESSAGE_FIELD, TIMESTAMP_FIELD } from '../../common/constants'; +import type { AppMountParameters, CoreStart } from '@kbn/core/public'; +import type { InfraClientStartExports } from '../types'; +import { getLogViewReferenceFromUrl } from '../observability_logs/log_view_state'; export const renderApp = ( core: CoreStart, - plugins: InfraClientStartDeps, pluginStart: InfraClientStartExports, params: AppMountParameters ) => { - const { discover } = plugins; - const { logViews } = pluginStart; - - const machine = createLogViewStateMachine({ - initialContext: { logViewReference: DEFAULT_LOG_VIEW }, - logViews: logViews.client, - initializeFromUrl: createInitializeFromUrl(core, params), - }); - - const service = interpret(machine) - .onTransition((state) => { - if ( - state.matches('checkingStatus') || - state.matches('resolvedPersistedLogView') || - state.matches('resolvedInlineLogView') - ) { - return redirectToDiscover(discover, state.context.resolvedLogView); - } else if ( - state.matches('loadingFailed') || - state.matches('resolutionFailed') || - state.matches('checkingStatusFailed') - ) { - return redirectToDiscover(discover); - } - }) - .start(); - - return () => { - // Stop machine interpreter after navigation - service.stop(); - }; -}; - -const redirectToDiscover = (discover: DiscoverStart, resolvedLogView?: ResolvedLogView) => { - const navigationOptions = { replace: true }; - - if (!resolvedLogView) { - return discover.locator?.navigate({}, navigationOptions); - } - - const columns = parseColumns(resolvedLogView.columns); - const dataViewSpec = resolvedLogView.dataViewReference.toSpec(); - - return discover.locator?.navigate( - { - columns, - dataViewId: dataViewSpec.id, - dataViewSpec, - }, - navigationOptions - ); -}; - -/** - * Helpers - */ - -const parseColumns = (columns: ResolvedLogView['columns']) => { - return columns.map(getColumnValue).filter(Boolean) as string[]; -}; - -const getColumnValue = (column: LogViewColumnConfiguration) => { - if ('messageColumn' in column) return MESSAGE_FIELD; - if ('timestampColumn' in column) return TIMESTAMP_FIELD; - if ('fieldColumn' in column) return column.fieldColumn.field; - - return null; -}; - -const createInitializeFromUrl = (core: CoreStart, params: AppMountParameters) => { const toastsService = core.notifications.toasts; const urlStateStorage = createKbnUrlStateStorage({ @@ -101,5 +22,9 @@ const createInitializeFromUrl = (core: CoreStart, params: AppMountParameters) => useHashQuery: false, }); - return initializeFromUrl({ toastsService, urlStateStorage }); + const logView = getLogViewReferenceFromUrl({ toastsService, urlStateStorage }); + + pluginStart.locators.logsLocator.navigate({ ...(logView ? { logView } : {}) }, { replace: true }); + + return () => true; }; diff --git a/x-pack/plugins/infra/public/locators/discover_logs_locator.ts b/x-pack/plugins/infra/public/locators/discover_logs_locator.ts new file mode 100644 index 0000000000000..8693c5e281078 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/discover_logs_locator.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { LogsLocatorDependencies, LogsLocatorParams } from './logs_locator'; + +const DISCOVER_LOGS_LOCATOR_ID = 'DISCOVER_LOGS_LOCATOR'; + +export type DiscoverLogsLocator = LocatorPublic; + +export class DiscoverLogsLocatorDefinition implements LocatorDefinition { + public readonly id = DISCOVER_LOGS_LOCATOR_ID; + + constructor(protected readonly deps: LogsLocatorDependencies) {} + + public readonly getLocation = async (params: LogsLocatorParams) => { + const { getLocationToDiscover } = await import('./helpers'); + + return getLocationToDiscover({ core: this.deps.core, ...params }); + }; +} diff --git a/x-pack/plugins/infra/public/locators/discover_node_logs_locator.ts b/x-pack/plugins/infra/public/locators/discover_node_logs_locator.ts new file mode 100644 index 0000000000000..727c7abb401a5 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/discover_node_logs_locator.ts @@ -0,0 +1,33 @@ +/* + * 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 { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { NodeLogsLocatorDependencies, NodeLogsLocatorParams } from './node_logs_locator'; + +const DISCOVER_NODE_LOGS_LOCATOR_ID = 'DISCOVER_NODE_LOGS_LOCATOR'; + +export type DiscoverNodeLogsLocator = LocatorPublic; + +export class DiscoverNodeLogsLocatorDefinition implements LocatorDefinition { + public readonly id = DISCOVER_NODE_LOGS_LOCATOR_ID; + + constructor(protected readonly deps: NodeLogsLocatorDependencies) {} + + public readonly getLocation = async (params: NodeLogsLocatorParams) => { + const { createNodeLogsQuery, getLocationToDiscover } = await import('./helpers'); + + const { timeRange, logView } = params; + const query = createNodeLogsQuery(params); + + return getLocationToDiscover({ + core: this.deps.core, + timeRange, + filter: query, + logView, + }); + }; +} diff --git a/x-pack/plugins/infra/public/locators/helpers.ts b/x-pack/plugins/infra/public/locators/helpers.ts new file mode 100644 index 0000000000000..4aceb41c8b4b8 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/helpers.ts @@ -0,0 +1,151 @@ +/* + * 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 { interpret } from 'xstate'; +import { waitFor } from 'xstate/lib/waitFor'; +import { flowRight } from 'lodash'; +import type { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; +import { findInventoryFields } from '../../common/inventory_models'; +import { MESSAGE_FIELD, TIMESTAMP_FIELD } from '../../common/constants'; +import { + createLogViewStateMachine, + DEFAULT_LOG_VIEW, + replaceLogViewInQueryString, +} from '../observability_logs/log_view_state'; +import { replaceLogFilterInQueryString } from '../observability_logs/log_stream_query_state'; +import { replaceLogPositionInQueryString } from '../observability_logs/log_stream_position_state/src/url_state_storage_service'; +import type { TimeRange } from '../../common/time'; +import type { LogsLocatorParams } from './logs_locator'; +import type { InfraClientCoreSetup } from '../types'; +import type { + LogViewColumnConfiguration, + LogViewReference, + ResolvedLogView, +} from '../../common/log_views'; +import type { NodeLogsLocatorParams } from './node_logs_locator'; + +interface LocationToDiscoverParams { + core: InfraClientCoreSetup; + timeRange?: TimeRange; + filter?: string; + logView?: LogViewReference; +} + +export const createNodeLogsQuery = (params: NodeLogsLocatorParams) => { + const { nodeType, nodeId, filter } = params; + + const nodeFilter = `${findInventoryFields(nodeType).id}: ${nodeId}`; + const query = filter ? `(${nodeFilter}) and (${filter})` : nodeFilter; + + return query; +}; + +export const createSearchString = ({ + time, + timeRange, + filter = '', + logView = DEFAULT_LOG_VIEW, +}: LogsLocatorParams) => { + return flowRight( + replaceLogFilterInQueryString({ language: 'kuery', query: filter }, time, timeRange), + replaceLogPositionInQueryString(time), + replaceLogViewInQueryString(logView) + )(''); +}; + +export const getLocationToDiscover = async ({ + core, + timeRange, + filter, + logView, +}: LocationToDiscoverParams) => { + const [, plugins, pluginStart] = await core.getStartServices(); + const { discover } = plugins; + const { logViews } = pluginStart; + + const machine = createLogViewStateMachine({ + initialContext: { logViewReference: logView || DEFAULT_LOG_VIEW }, + logViews: logViews.client, + }); + + const discoverParams: DiscoverAppLocatorParams = { + ...(timeRange ? { from: timeRange.startTime, to: timeRange.endTime } : {}), + ...(filter + ? { + query: { + language: 'kuery', + query: filter, + }, + } + : {}), + }; + + let discoverLocation; + + const service = interpret(machine).start(); + const doneState = await waitFor( + service, + (state) => + state.matches('checkingStatus') || + state.matches('resolvedPersistedLogView') || + state.matches('resolvedInlineLogView') || + state.matches('loadingFailed') || + state.matches('resolutionFailed') || + state.matches('checkingStatusFailed') + ); + + service.stop(); + + if ('resolvedLogView' in doneState.context) { + discoverLocation = await constructDiscoverLocation( + discover, + discoverParams, + doneState.context.resolvedLogView + ); + } else { + discoverLocation = await constructDiscoverLocation(discover, discoverParams); + } + + if (!discoverLocation) { + throw new Error('Discover location not found'); + } + + return discoverLocation; +}; + +const constructDiscoverLocation = async ( + discover: DiscoverStart, + discoverParams: DiscoverAppLocatorParams, + resolvedLogView?: ResolvedLogView +) => { + if (!resolvedLogView) { + return await discover.locator?.getLocation(discoverParams); + } + + const columns = parseColumns(resolvedLogView.columns); + const dataViewSpec = resolvedLogView.dataViewReference.toSpec(); + + return await discover.locator?.getLocation({ + ...discoverParams, + columns, + dataViewId: dataViewSpec.id, + dataViewSpec, + }); +}; + +const parseColumns = (columns: ResolvedLogView['columns']) => { + return columns.map(getColumnValue).filter(Boolean) as string[]; +}; + +const getColumnValue = (column: LogViewColumnConfiguration) => { + if ('messageColumn' in column) return MESSAGE_FIELD; + if ('timestampColumn' in column) return TIMESTAMP_FIELD; + if ('fieldColumn' in column) return column.fieldColumn.field; + + return null; +}; diff --git a/x-pack/plugins/infra/public/locators/index.ts b/x-pack/plugins/infra/public/locators/index.ts new file mode 100644 index 0000000000000..8749d88ae9b53 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiscoverLogsLocator } from './discover_logs_locator'; +import type { DiscoverNodeLogsLocator } from './discover_node_logs_locator'; +import type { LogsLocator } from './logs_locator'; +import type { NodeLogsLocator } from './node_logs_locator'; + +export * from './discover_logs_locator'; +export * from './discover_node_logs_locator'; +export * from './logs_locator'; +export * from './node_logs_locator'; + +export interface InfraLocators { + logsLocator: LogsLocator | DiscoverLogsLocator; + nodeLogsLocator: NodeLogsLocator | DiscoverNodeLogsLocator; +} diff --git a/x-pack/plugins/infra/public/locators/locators.mock.ts b/x-pack/plugins/infra/public/locators/locators.mock.ts new file mode 100644 index 0000000000000..ca2d5bba99d15 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/locators.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import type { InfraLocators } from '.'; + +export const createLocatorMock = (): jest.Mocked => ({ + logsLocator: sharePluginMock.createLocator(), + nodeLogsLocator: sharePluginMock.createLocator(), +}); diff --git a/x-pack/plugins/infra/public/locators/locators.test.ts b/x-pack/plugins/infra/public/locators/locators.test.ts new file mode 100644 index 0000000000000..5f19bb9f11cb9 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/locators.test.ts @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { LogsLocatorDefinition, LogsLocatorDependencies } from './logs_locator'; +import { NodeLogsLocatorDefinition } from './node_logs_locator'; +import type { LogsLocatorParams } from './logs_locator'; +import type { NodeLogsLocatorParams } from './node_logs_locator'; +import { coreMock } from '@kbn/core/public/mocks'; +import { findInventoryFields } from '../../common/inventory_models'; +import moment from 'moment'; +import { DEFAULT_LOG_VIEW } from '../observability_logs/log_view_state'; +import type { LogViewReference } from '../../common/log_views'; + +const setupLogsLocator = async () => { + const deps: LogsLocatorDependencies = { + core: coreMock.createSetup(), + }; + const logsLocator = new LogsLocatorDefinition(deps); + const nodeLogsLocator = new NodeLogsLocatorDefinition(deps); + + return { + logsLocator, + nodeLogsLocator, + }; +}; + +describe('Infra Locators', () => { + const APP_ID = 'logs'; + const nodeType = 'host'; + const FILTER_QUERY = 'trace.id:1234'; + const nodeId = uuidv4(); + const time = 1550671089404; + const from = 1676815089000; + const to = 1682351734323; + + describe('Logs Locator', () => { + it('should create a link to Logs with no state', async () => { + const params: LogsLocatorParams = { + time, + }; + const { logsLocator } = await setupLogsLocator(); + const { app, path, state } = await logsLocator.getLocation(params); + + expect(app).toBe(APP_ID); + expect(path).toBe(constructUrlSearchString(params)); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + + it('should allow specifying specific logPosition', async () => { + const params: LogsLocatorParams = { + time, + }; + const { logsLocator } = await setupLogsLocator(); + const { path } = await logsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific filter', async () => { + const params: LogsLocatorParams = { + time, + filter: FILTER_QUERY, + }; + const { logsLocator } = await setupLogsLocator(); + const { path } = await logsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific view id', async () => { + const params: LogsLocatorParams = { + time, + logView: DEFAULT_LOG_VIEW, + }; + const { logsLocator } = await setupLogsLocator(); + const { path } = await logsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific time range', async () => { + const params: LogsLocatorParams = { + time, + from, + to, + }; + const { logsLocator } = await setupLogsLocator(); + const { path } = await logsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should return correct structured url', async () => { + const params: LogsLocatorParams = { + logView: DEFAULT_LOG_VIEW, + filter: FILTER_QUERY, + time, + }; + const { logsLocator } = await setupLogsLocator(); + const { app, path, state } = await logsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + + expect(app).toBe(APP_ID); + expect(path).toBe(expected); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + }); + + describe('Node Logs Locator', () => { + it('should create a link to Node Logs with no state', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { app, path, state } = await nodeLogsLocator.getLocation(params); + + expect(app).toBe(APP_ID); + expect(path).toBe(constructUrlSearchString(params)); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + + it('should allow specifying specific logPosition', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { path } = await nodeLogsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific filter', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + filter: FILTER_QUERY, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { path } = await nodeLogsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific view id', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + logView: { ...DEFAULT_LOG_VIEW, logViewId: 'test' }, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { path } = await nodeLogsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should allow specifying specific time range', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + from, + to, + logView: DEFAULT_LOG_VIEW, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { path } = await nodeLogsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(path).toBe(expected); + }); + + it('should return correct structured url', async () => { + const params: NodeLogsLocatorParams = { + nodeId, + nodeType, + time, + logView: DEFAULT_LOG_VIEW, + filter: FILTER_QUERY, + }; + const { nodeLogsLocator } = await setupLogsLocator(); + const { app, path, state } = await nodeLogsLocator.getLocation(params); + + const expected = constructUrlSearchString(params); + expect(app).toBe(APP_ID); + expect(path).toBe(expected); + expect(state).toBeDefined(); + expect(Object.keys(state)).toHaveLength(0); + }); + }); +}); + +/** + * Helpers + */ + +export const constructUrlSearchString = (params: Partial) => { + const { time = 1550671089404, logView } = params; + + return `/stream?logView=${constructLogView(logView)}&logPosition=${constructLogPosition( + time + )}&logFilter=${constructLogFilter(params)}`; +}; + +const constructLogView = (logView?: LogViewReference) => { + const logViewId = + logView && 'logViewId' in logView ? logView.logViewId : DEFAULT_LOG_VIEW.logViewId; + + return `(logViewId:${logViewId},type:log-view-reference)`; +}; + +const constructLogPosition = (time: number = 1550671089404) => { + return `(position:(tiebreaker:0,time:${time}))`; +}; + +const constructLogFilter = ({ + nodeType, + nodeId, + filter, + timeRange, + time, +}: Partial) => { + let finalFilter = filter || ''; + + if (nodeId) { + const nodeFilter = `${findInventoryFields(nodeType!).id}: ${nodeId}`; + finalFilter = filter ? `(${nodeFilter}) and (${filter})` : nodeFilter; + } + + const query = encodeURI( + `(query:(language:kuery,query:'${finalFilter}'),refreshInterval:(pause:!t,value:5000)` + ); + + if (!time) return `${query})`; + + const fromDate = timeRange?.startTime + ? addHoursToTimestamp(timeRange.startTime, 0) + : addHoursToTimestamp(time, -1); + + const toDate = timeRange?.endTime + ? addHoursToTimestamp(timeRange.endTime, 0) + : addHoursToTimestamp(time, 1); + + return `${query},timeRange:(from:'${fromDate}',to:'${toDate}'))`; +}; + +const addHoursToTimestamp = (timestamp: number, hours: number): string => { + return moment(timestamp).add({ hours }).toISOString(); +}; diff --git a/x-pack/plugins/infra/public/locators/logs_locator.ts b/x-pack/plugins/infra/public/locators/logs_locator.ts new file mode 100644 index 0000000000000..fd78b9cce74fe --- /dev/null +++ b/x-pack/plugins/infra/public/locators/logs_locator.ts @@ -0,0 +1,49 @@ +/* + * 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 { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { LogViewReference } from '../../common/log_views'; +import type { TimeRange } from '../../common/time'; +import type { InfraClientCoreSetup } from '../types'; + +const LOGS_LOCATOR_ID = 'LOGS_LOCATOR'; + +export interface LogsLocatorParams extends SerializableRecord { + /** Defines log position */ + time?: number; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + filter?: string; + logView?: LogViewReference; +} + +export type LogsLocator = LocatorPublic; + +export interface LogsLocatorDependencies { + core: InfraClientCoreSetup; +} + +export class LogsLocatorDefinition implements LocatorDefinition { + public readonly id = LOGS_LOCATOR_ID; + + constructor(protected readonly deps: LogsLocatorDependencies) {} + + public readonly getLocation = async (params: LogsLocatorParams) => { + const { createSearchString } = await import('./helpers'); + + const searchString = createSearchString(params); + + return { + app: 'logs', + path: `/stream?${searchString}`, + state: {}, + }; + }; +} diff --git a/x-pack/plugins/infra/public/locators/node_logs_locator.ts b/x-pack/plugins/infra/public/locators/node_logs_locator.ts new file mode 100644 index 0000000000000..afa49882262c4 --- /dev/null +++ b/x-pack/plugins/infra/public/locators/node_logs_locator.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { InventoryItemType } from '../../common/inventory_models/types'; +import type { LogsLocatorDependencies, LogsLocatorParams } from './logs_locator'; + +const NODE_LOGS_LOCATOR_ID = 'NODE_LOGS_LOCATOR'; + +export interface NodeLogsLocatorParams extends LogsLocatorParams { + nodeId: string; + nodeType: InventoryItemType; +} + +export type NodeLogsLocator = LocatorPublic; + +export type NodeLogsLocatorDependencies = LogsLocatorDependencies; + +export class NodeLogsLocatorDefinition implements LocatorDefinition { + public readonly id = NODE_LOGS_LOCATOR_ID; + + constructor(protected readonly deps: NodeLogsLocatorDependencies) {} + + public readonly getLocation = async (params: NodeLogsLocatorParams) => { + const { createNodeLogsQuery, createSearchString } = await import('./helpers'); + + const query = createNodeLogsQuery(params); + + const searchString = createSearchString({ ...params, filter: query }); + + return { + app: 'logs', + path: `/stream?${searchString}`, + state: {}, + }; + }; +} diff --git a/x-pack/plugins/infra/public/mocks.tsx b/x-pack/plugins/infra/public/mocks.tsx index cd94e33b99f5e..8af0b5d1ab094 100644 --- a/x-pack/plugins/infra/public/mocks.tsx +++ b/x-pack/plugins/infra/public/mocks.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { createLocatorMock } from './locators/locators.mock'; import { createInventoryViewsServiceStartMock } from './services/inventory_views/inventory_views_service.mock'; import { createLogViewsServiceStartMock } from './services/log_views/log_views_service.mock'; import { createMetricsExplorerViewsServiceStartMock } from './services/metrics_explorer_views/metrics_explorer_views_service.mock'; @@ -17,6 +18,7 @@ export const createInfraPluginStartMock = () => ({ logViews: createLogViewsServiceStartMock(), metricsExplorerViews: createMetricsExplorerViewsServiceStartMock(), telemetry: createTelemetryServiceMock(), + locators: createLocatorMock(), ContainerMetricsTable: () =>
, HostMetricsTable: () =>
, PodMetricsTable: () =>
, diff --git a/x-pack/plugins/infra/public/observability_logs/log_stream_position_state/src/url_state_storage_service.ts b/x-pack/plugins/infra/public/observability_logs/log_stream_position_state/src/url_state_storage_service.ts index db81474594c48..5b8e3e56c989c 100644 --- a/x-pack/plugins/infra/public/observability_logs/log_stream_position_state/src/url_state_storage_service.ts +++ b/x-pack/plugins/infra/public/observability_logs/log_stream_position_state/src/url_state_storage_service.ts @@ -101,8 +101,8 @@ const decodePositionQueryValueFromUrl = (queryValueFromUrl: unknown) => { }; // Used by linkTo components -export const replaceLogPositionInQueryString = (time: number) => - Number.isNaN(time) +export const replaceLogPositionInQueryString = (time?: number) => + Number.isNaN(time) || time == null ? (value: string) => value : replaceStateKeyInQueryString(defaultPositionStateKey, { position: { diff --git a/x-pack/plugins/infra/public/observability_logs/log_stream_query_state/src/url_state_storage_service.ts b/x-pack/plugins/infra/public/observability_logs/log_stream_query_state/src/url_state_storage_service.ts index 5a6d20f4c9c12..e34725f699410 100644 --- a/x-pack/plugins/infra/public/observability_logs/log_stream_query_state/src/url_state_storage_service.ts +++ b/x-pack/plugins/infra/public/observability_logs/log_stream_query_state/src/url_state_storage_service.ts @@ -15,7 +15,7 @@ import * as rt from 'io-ts'; import { InvokeCreator } from 'xstate'; import { DurationInputObject } from 'moment'; import moment from 'moment'; -import { minimalTimeKeyRT } from '../../../../common/time'; +import { minimalTimeKeyRT, TimeRange } from '../../../../common/time'; import { datemathStringRT } from '../../../utils/datemath'; import { createPlainError, formatErrors } from '../../../../common/runtime_types'; import { replaceStateKeyInQueryString } from '../../../utils/url_state'; @@ -290,21 +290,33 @@ const decodePositionQueryValueFromUrl = (queryValueFromUrl: unknown) => { return legacyPositionStateInUrlRT.decode(queryValueFromUrl); }; -const ONE_HOUR = 3600000; -export const replaceLogFilterInQueryString = (query: Query, time?: number) => +export const replaceLogFilterInQueryString = (query: Query, time?: number, timeRange?: TimeRange) => replaceStateKeyInQueryString(defaultFilterStateKey, { query, - ...(time && !Number.isNaN(time) - ? { - timeRange: { - from: new Date(time - ONE_HOUR).toISOString(), - to: new Date(time + ONE_HOUR).toISOString(), - }, - } - : {}), + ...getTimeRange(time, timeRange), refreshInterval: DEFAULT_REFRESH_INTERVAL, }); +const getTimeRange = (time?: number, timeRange?: TimeRange) => { + if (timeRange) { + return { + timeRange: { + from: new Date(timeRange.startTime).toISOString(), + to: new Date(timeRange.endTime).toISOString(), + }, + }; + } else if (time) { + return { + timeRange: { + from: getTimeRangeStartFromTime(time), + to: getTimeRangeEndFromTime(time), + }, + }; + } else { + return {}; + } +}; + const defaultTimeRangeFromPositionOffset: DurationInputObject = { hours: 1 }; const getTimeRangeStartFromTime = (time: number): string => diff --git a/x-pack/plugins/infra/public/observability_logs/log_view_state/src/defaults.ts b/x-pack/plugins/infra/public/observability_logs/log_view_state/src/defaults.ts index eb31f91ed12fb..a5ce6f1fe5740 100644 --- a/x-pack/plugins/infra/public/observability_logs/log_view_state/src/defaults.ts +++ b/x-pack/plugins/infra/public/observability_logs/log_view_state/src/defaults.ts @@ -5,7 +5,7 @@ * 2.0. */ -const DEFAULT_LOG_VIEW_ID = 'default'; +export const DEFAULT_LOG_VIEW_ID = 'default'; export const DEFAULT_LOG_VIEW = { type: 'log-view-reference' as const, logViewId: DEFAULT_LOG_VIEW_ID, diff --git a/x-pack/plugins/infra/public/observability_logs/log_view_state/src/url_state_storage_service.ts b/x-pack/plugins/infra/public/observability_logs/log_view_state/src/url_state_storage_service.ts index 357c96905ccf3..ffe9743202284 100644 --- a/x-pack/plugins/infra/public/observability_logs/log_view_state/src/url_state_storage_service.ts +++ b/x-pack/plugins/infra/public/observability_logs/log_view_state/src/url_state_storage_service.ts @@ -52,44 +52,53 @@ export const initializeFromUrl = }: LogViewUrlStateDependencies): InvokeCreator => (_context, _event) => (send) => { - const logViewQueryValueFromUrl = urlStateStorage.get(logViewKey); - const logViewQueryE = decodeLogViewQueryValueFromUrl(logViewQueryValueFromUrl); - - const legacySourceIdQueryValueFromUrl = urlStateStorage.get(sourceIdKey); - const sourceIdQueryE = decodeSourceIdQueryValueFromUrl(legacySourceIdQueryValueFromUrl); - - if (Either.isLeft(logViewQueryE) || Either.isLeft(sourceIdQueryE)) { - withNotifyOnErrors(toastsService).onGetError( - createPlainError( - formatErrors([ - ...(Either.isLeft(logViewQueryE) ? logViewQueryE.left : []), - ...(Either.isLeft(sourceIdQueryE) ? sourceIdQueryE.left : []), - ]) - ) - ); + const logViewReference = getLogViewReferenceFromUrl({ + logViewKey, + sourceIdKey, + toastsService, + urlStateStorage, + }); - send({ - type: 'INITIALIZED_FROM_URL', - logViewReference: null, - }); - } else { - send({ - type: 'INITIALIZED_FROM_URL', - logViewReference: pipe( - // Via the legacy sourceId key - pipe( - sourceIdQueryE.right, - Either.fromNullable(null), - Either.map(convertSourceIdToReference) - ), - // Via the logView key - Either.alt(() => pipe(logViewQueryE.right, Either.fromNullable(null))), - Either.fold(identity, identity) - ), - }); - } + send({ + type: 'INITIALIZED_FROM_URL', + logViewReference, + }); }; +export const getLogViewReferenceFromUrl = ({ + logViewKey, + sourceIdKey, + toastsService, + urlStateStorage, +}: LogViewUrlStateDependencies): LogViewReference | null => { + const logViewQueryValueFromUrl = urlStateStorage.get(logViewKey!); + const logViewQueryE = decodeLogViewQueryValueFromUrl(logViewQueryValueFromUrl); + + const legacySourceIdQueryValueFromUrl = urlStateStorage.get(sourceIdKey!); + const sourceIdQueryE = decodeSourceIdQueryValueFromUrl(legacySourceIdQueryValueFromUrl); + + if (Either.isLeft(logViewQueryE) || Either.isLeft(sourceIdQueryE)) { + withNotifyOnErrors(toastsService).onGetError( + createPlainError( + formatErrors([ + ...(Either.isLeft(logViewQueryE) ? logViewQueryE.left : []), + ...(Either.isLeft(sourceIdQueryE) ? sourceIdQueryE.left : []), + ]) + ) + ); + + return null; + } else { + return pipe( + // Via the legacy sourceId key + pipe(sourceIdQueryE.right, Either.fromNullable(null), Either.map(convertSourceIdToReference)), + // Via the logView key + Either.alt(() => pipe(logViewQueryE.right, Either.fromNullable(null))), + Either.fold(identity, identity) + ); + } +}; + // NOTE: Certain navigations within the Logs solution will remove the logView URL key, // we want to ensure the logView key is present in the URL at all times by monitoring for it's removal. export const listenForUrlChanges = diff --git a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx deleted file mode 100644 index 2ee56c425e680..0000000000000 --- a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.test.tsx +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render, waitFor } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { Router, Switch } from 'react-router-dom'; -import { Route } from '@kbn/shared-ux-router'; -import { httpServiceMock } from '@kbn/core/public/mocks'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; -import { useLogView } from '../../hooks/use_log_view'; -import { - createLoadedUseLogViewMock, - createLoadingUseLogViewMock, -} from '../../hooks/use_log_view.mock'; -import { LinkToLogsPage } from './link_to_logs'; - -jest.mock('../../hooks/use_log_view'); -const useLogViewMock = useLogView as jest.MockedFunction; -const LOG_VIEW_REFERENCE = '(logViewId:default,type:log-view-reference)'; -const OTHER_LOG_VIEW_REFERENCE = '(logViewId:OTHER_SOURCE,type:log-view-reference)'; - -const renderRoutes = (routes: React.ReactElement) => { - const history = createMemoryHistory(); - const services = { - http: httpServiceMock.createStartContract(), - logViews: { - client: {}, - }, - observabilityShared: { - navigation: { - PageTemplate: KibanaPageTemplate, - }, - }, - }; - const renderResult = render( - - {routes} - - ); - - return { - ...renderResult, - history, - services, - }; -}; - -describe('LinkToLogsPage component', () => { - beforeEach(async () => { - useLogViewMock.mockImplementation(await createLoadedUseLogViewMock()); - }); - - afterEach(() => { - useLogViewMock.mockRestore(); - }); - - describe('default route', () => { - it('redirects to the stream at a given time filtered for a user-defined criterion', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'FILTER_FIELD:FILTER_VALUE'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"` - ); - expect(searchParams.get('logPosition')).toMatchInlineSnapshot( - `"(position:(tiebreaker:0,time:1550671089404))"` - ); - }); - - it('redirects to the stream using a specific source id', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/OTHER_SOURCE'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(OTHER_LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:''),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - }); - - describe('logs route', () => { - it('redirects to the stream at a given time filtered for a user-defined criterion', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/logs?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'FILTER_FIELD:FILTER_VALUE'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"` - ); - expect(searchParams.get('logPosition')).toMatchInlineSnapshot( - `"(position:(tiebreaker:0,time:1550671089404))"` - ); - }); - - it('redirects to the stream using a specific source id', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/OTHER_SOURCE/logs'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(OTHER_LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:''),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - }); - - describe('host-logs route', () => { - it('redirects to the stream filtered for a host', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/host-logs/HOST_NAME'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'host.name: HOST_NAME'),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - - it('redirects to the stream at a given time filtered for a host and a user-defined criterion', () => { - const { history } = renderRoutes( - - - - ); - - history.push( - '/link-to/host-logs/HOST_NAME?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE' - ); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'(host.name: HOST_NAME) and (FILTER_FIELD:FILTER_VALUE)'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"` - ); - expect(searchParams.get('logPosition')).toMatchInlineSnapshot( - `"(position:(tiebreaker:0,time:1550671089404))"` - ); - }); - - it('redirects to the stream filtered for a host using a specific source id', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/OTHER_SOURCE/host-logs/HOST_NAME'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(OTHER_LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'host.name: HOST_NAME'),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - - it('renders a loading page while loading the source configuration', async () => { - useLogViewMock.mockImplementation(createLoadingUseLogViewMock()); - - const { history, queryByTestId } = renderRoutes( - - - - ); - - history.push('/link-to/host-logs/HOST_NAME'); - await waitFor(() => { - expect(queryByTestId('nodeLoadingPage-host')).not.toBeEmptyDOMElement(); - }); - }); - }); - - describe('container-logs route', () => { - it('redirects to the stream filtered for a container', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/container-logs/CONTAINER_ID'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'container.id: CONTAINER_ID'),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - - it('redirects to the stream at a given time filtered for a container and a user-defined criterion', () => { - const { history } = renderRoutes( - - - - ); - - history.push( - '/link-to/container-logs/CONTAINER_ID?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE' - ); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'(container.id: CONTAINER_ID) and (FILTER_FIELD:FILTER_VALUE)'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"` - ); - expect(searchParams.get('logPosition')).toMatchInlineSnapshot( - `"(position:(tiebreaker:0,time:1550671089404))"` - ); - }); - - it('renders a loading page while loading the source configuration', () => { - useLogViewMock.mockImplementation(createLoadingUseLogViewMock()); - - const { history, queryByTestId } = renderRoutes( - - - - ); - - history.push('/link-to/container-logs/CONTAINER_ID'); - - expect(queryByTestId('nodeLoadingPage-container')).not.toBeEmptyDOMElement(); - }); - }); - - describe('pod-logs route', () => { - it('redirects to the stream filtered for a pod', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/pod-logs/POD_UID'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'kubernetes.pod.uid: POD_UID'),refreshInterval:(pause:!t,value:5000))"` - ); - expect(searchParams.get('logPosition')).toEqual(null); - }); - - it('redirects to the stream at a given time filtered for a pod and a user-defined criterion', () => { - const { history } = renderRoutes( - - - - ); - - history.push('/link-to/pod-logs/POD_UID?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE'); - - expect(history.location.pathname).toEqual('/stream'); - - const searchParams = new URLSearchParams(history.location.search); - expect(searchParams.get('logView')).toEqual(LOG_VIEW_REFERENCE); - expect(searchParams.get('logFilter')).toMatchInlineSnapshot( - `"(query:(language:kuery,query:'(kubernetes.pod.uid: POD_UID) and (FILTER_FIELD:FILTER_VALUE)'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"` - ); - expect(searchParams.get('logPosition')).toMatchInlineSnapshot( - `"(position:(tiebreaker:0,time:1550671089404))"` - ); - }); - - it('renders a loading page while loading the source configuration', () => { - useLogViewMock.mockImplementation(createLoadingUseLogViewMock()); - - const { history, queryByTestId } = renderRoutes( - - - - ); - - history.push('/link-to/pod-logs/POD_UID'); - - expect(queryByTestId('nodeLoadingPage-pod')).not.toBeEmptyDOMElement(); - }); - }); -}); diff --git a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.tsx index 60afe9c98bd54..aa93f16fadf61 100644 --- a/x-pack/plugins/infra/public/pages/link_to/link_to_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/link_to_logs.tsx @@ -22,6 +22,11 @@ interface LinkToPageProps { const ITEM_TYPES = inventoryModels.map((m) => m.id).join('|'); +/** + * @deprecated Link-to routes shouldn't be used anymore + * Instead please use locators registered for the infra plugin + * LogsLocator & NodeLogsLocator + */ export const LinkToLogsPage: React.FC = (props) => { return ( diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx deleted file mode 100644 index c5c3e4d507582..0000000000000 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createLocation } from 'history'; -import React from 'react'; -import { matchPath } from 'react-router-dom'; -import { shallow } from 'enzyme'; - -import { RedirectToLogs } from './redirect_to_logs'; - -describe('RedirectToLogs component', () => { - it('renders a redirect with the correct position', () => { - const component = shallow( - - ); - - expect(component).toMatchInlineSnapshot(` - - `); - }); - - it('renders a redirect with the correct user-defined filter', () => { - const component = shallow( - - ); - - expect(component).toMatchInlineSnapshot(` - - `); - }); - - it('renders a redirect with the correct custom source id', () => { - const component = shallow( - - ); - - expect(component).toMatchInlineSnapshot(` - - `); - }); -}); - -const createRouteComponentProps = (path: string) => { - const location = createLocation(path); - return { - match: matchPath(location.pathname, { path: '/:sourceId?/logs' }) as any, - history: null as any, - location, - }; -}; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx index 4d4533596fd57..80b9ff09180e0 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx @@ -5,31 +5,33 @@ * 2.0. */ -import React from 'react'; -import { match as RouteMatch, Redirect, RouteComponentProps } from 'react-router-dom'; -import { flowRight } from 'lodash'; -import { replaceLogPositionInQueryString } from '../../observability_logs/log_stream_position_state/src/url_state_storage_service'; -import { replaceLogFilterInQueryString } from '../../observability_logs/log_stream_query_state'; +import { useEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; -import { replaceLogViewInQueryString } from '../../observability_logs/log_view_state'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import { DEFAULT_LOG_VIEW } from '../../observability_logs/log_view_state'; -type RedirectToLogsType = RouteComponentProps<{}>; +export const RedirectToLogs = () => { + const { logViewId } = useParams<{ logViewId?: string }>(); + const location = useLocation(); -interface RedirectToLogsProps extends RedirectToLogsType { - match: RouteMatch<{ - logViewId?: string; - }>; -} + const { + services: { locators }, + } = useKibanaContextForPlugin(); -export const RedirectToLogs = ({ location, match }: RedirectToLogsProps) => { - const logViewId = match.params.logViewId || 'default'; const filter = getFilterFromLocation(location); const time = getTimeFromLocation(location); - const searchString = flowRight( - replaceLogFilterInQueryString({ language: 'kuery', query: filter }, time), - replaceLogPositionInQueryString(time), - replaceLogViewInQueryString({ type: 'log-view-reference', logViewId }) - )(''); - return ; + useEffect(() => { + locators.logsLocator.navigate( + { + time, + filter, + logView: { ...DEFAULT_LOG_VIEW, logViewId: logViewId || DEFAULT_LOG_VIEW.logViewId }, + }, + { replace: true } + ); + }, [filter, locators.logsLocator, logViewId, time]); + + return null; }; 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 9ac23a635dcd7..483e8ccf01e78 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 @@ -5,21 +5,13 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { LinkDescriptor } from '@kbn/observability-shared-plugin/public'; -import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; -import useMount from 'react-use/lib/useMount'; -import { flowRight } from 'lodash'; -import { findInventoryFields } from '../../../common/inventory_models'; +import { LinkDescriptor } from '@kbn/observability-plugin/public'; +import { useEffect } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import { InventoryItemType } from '../../../common/inventory_models/types'; -import { LoadingPage } from '../../components/loading_page'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; -import { useLogView } from '../../hooks/use_log_view'; -import { replaceLogFilterInQueryString } from '../../observability_logs/log_stream_query_state'; +import { DEFAULT_LOG_VIEW_ID } from '../../observability_logs/log_view_state'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; -import { replaceLogPositionInQueryString } from '../../observability_logs/log_stream_position_state/src/url_state_storage_service'; -import { replaceLogViewInQueryString } from '../../observability_logs/log_view_state'; type RedirectToNodeLogsType = RouteComponentProps<{ nodeId: string; @@ -29,46 +21,31 @@ type RedirectToNodeLogsType = RouteComponentProps<{ export const RedirectToNodeLogs = ({ match: { - params: { nodeId, nodeType, logViewId = 'default' }, + params: { nodeId, nodeType, logViewId = DEFAULT_LOG_VIEW_ID }, }, location, }: RedirectToNodeLogsType) => { - const { services } = useKibanaContextForPlugin(); - const { isLoading, load } = useLogView({ - initialLogViewReference: { type: 'log-view-reference', logViewId }, - logViews: services.logViews.client, - }); + const { + services: { locators }, + } = useKibanaContextForPlugin(); - useMount(() => { - load(); - }); - - if (isLoading) { - return ( - - ); - } - - const nodeFilter = `${findInventoryFields(nodeType).id}: ${nodeId}`; - const userFilter = getFilterFromLocation(location); - const filter = userFilter ? `(${nodeFilter}) and (${userFilter})` : nodeFilter; + const filter = getFilterFromLocation(location); const time = getTimeFromLocation(location); - const searchString = flowRight( - replaceLogFilterInQueryString({ language: 'kuery', query: filter }, time), - replaceLogPositionInQueryString(time), - replaceLogViewInQueryString({ type: 'log-view-reference', logViewId }) - )(''); + useEffect(() => { + locators.nodeLogsLocator.navigate( + { + nodeId, + nodeType, + time, + filter, + logView: { type: 'log-view-reference', logViewId }, + }, + { replace: true } + ); + }, [filter, locators.nodeLogsLocator, logViewId, nodeId, nodeType, time]); - return ; + return null; }; export const getNodeLogsUrl = ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx index 618d511b93cac..f5c2101317f01 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx @@ -5,47 +5,32 @@ * 2.0. */ import React from 'react'; -import { stringify } from 'querystring'; -import { encode } from '@kbn/rison'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; interface LogsLinkToStreamProps { - startTimestamp: number; - endTimestamp: number; + startTime: number; + endTime: number; query: string; } -export const LogsLinkToStream = ({ - startTimestamp, - endTimestamp, - query, -}: LogsLinkToStreamProps) => { +export const LogsLinkToStream = ({ startTime, endTime, query }: LogsLinkToStreamProps) => { const { services } = useKibanaContextForPlugin(); - const { http } = services; - - const queryString = new URLSearchParams( - stringify({ - logPosition: encode({ - start: new Date(startTimestamp), - end: new Date(endTimestamp), - streamLive: false, - }), - logFilter: encode({ - kind: 'kuery', - expression: query, - }), - }) - ); - - const viewInLogsUrl = http.basePath.prepend(`/app/logs/stream?${queryString}`); + const { locators } = services; return ( { - + diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 1c2c9ec90dd7c..899e632961c85 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -18,6 +18,7 @@ import { enableInfrastructureHostsView } from '@kbn/observability-plugin/public' import { ObservabilityTriggerId } from '@kbn/observability-shared-plugin/common'; import { BehaviorSubject, combineLatest, from } from 'rxjs'; import { map } from 'rxjs/operators'; +import { DISCOVER_APP_TARGET, LOGS_APP_TARGET } from '../common/constants'; import { defaultLogViewsStaticConfig } from '../common/log_views'; import { InfraPublicConfig } from '../common/plugin_config_types'; import { createInventoryMetricRuleType } from './alerting/inventory'; @@ -28,6 +29,13 @@ import { createLazyHostMetricsTable } from './components/infrastructure_node_met import { createLazyPodMetricsTable } from './components/infrastructure_node_metrics_tables/pod/create_lazy_pod_metrics_table'; import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; +import { + DiscoverLogsLocatorDefinition, + DiscoverNodeLogsLocatorDefinition, + InfraLocators, + LogsLocatorDefinition, + NodeLogsLocatorDefinition, +} from './locators'; import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; import { registerFeatures } from './register_feature'; import { InventoryViewsService } from './services/inventory_views'; @@ -51,6 +59,8 @@ export class Plugin implements InfraClientPluginClass { private logViews: LogViewsService; private metricsExplorerViews: MetricsExplorerViewsService; private telemetry: TelemetryService; + private locators?: InfraLocators; + private appTarget: string; private readonly appUpdater$ = new BehaviorSubject(() => ({})); constructor(context: PluginInitializerContext) { @@ -62,6 +72,7 @@ export class Plugin implements InfraClientPluginClass { }); this.metricsExplorerViews = new MetricsExplorerViewsService(); this.telemetry = new TelemetryService(); + this.appTarget = this.config.logs.app_target; } setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { @@ -148,7 +159,21 @@ export class Plugin implements InfraClientPluginClass { new LogStreamEmbeddableFactoryDefinition(core.getStartServices) ); - if (this.config.logs.app_target === 'discover') { + // Register Locators + let logsLocator = pluginsSetup.share.url.locators.create(new LogsLocatorDefinition({ core })); + let nodeLogsLocator = pluginsSetup.share.url.locators.create( + new NodeLogsLocatorDefinition({ core }) + ); + + if (this.appTarget === DISCOVER_APP_TARGET) { + // Register Locators + logsLocator = pluginsSetup.share.url.locators.create( + new DiscoverLogsLocatorDefinition({ core }) + ); + nodeLogsLocator = pluginsSetup.share.url.locators.create( + new DiscoverNodeLogsLocatorDefinition({ core }) + ); + core.application.register({ id: 'logs-to-discover', title: '', @@ -156,16 +181,15 @@ export class Plugin implements InfraClientPluginClass { appRoute: '/app/logs', mount: async (params: AppMountParameters) => { // mount callback should not use setup dependencies, get start dependencies instead - const [coreStart, plugins, pluginStart] = await core.getStartServices(); - + const [coreStart, , pluginStart] = await core.getStartServices(); const { renderApp } = await import('./apps/discover_app'); - return renderApp(coreStart, plugins, pluginStart, params); + return renderApp(coreStart, pluginStart, params); }, }); } - if (this.config.logs.app_target === 'logs-ui') { + if (this.appTarget === LOGS_APP_TARGET) { core.application.register({ id: 'logs', title: i18n.translate('xpack.infra.logs.pluginTitle', { @@ -291,6 +315,15 @@ export class Plugin implements InfraClientPluginClass { // Setup telemetry events this.telemetry.setup({ analytics: core.analytics }); + + this.locators = { + logsLocator, + nodeLogsLocator, + }; + + return { + locators: this.locators, + }; } start(core: InfraClientCoreStart, plugins: InfraClientStartDeps) { @@ -317,6 +350,7 @@ export class Plugin implements InfraClientPluginClass { logViews, metricsExplorerViews, telemetry, + locators: this.locators!, ContainerMetricsTable: createLazyContainerMetricsTable(getStartServices), HostMetricsTable: createLazyHostMetricsTable(getStartServices), PodMetricsTable: createLazyPodMetricsTable(getStartServices), diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 843eb1ed85551..d48d58fd10642 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -48,15 +48,19 @@ import { InventoryViewsServiceStart } from './services/inventory_views'; import { LogViewsServiceStart } from './services/log_views'; import { MetricsExplorerViewsServiceStart } from './services/metrics_explorer_views'; import { ITelemetryClient } from './services/telemetry'; +import { InfraLocators } from './locators'; // Our own setup and start contract values -export type InfraClientSetupExports = void; +export interface InfraClientSetupExports { + locators: InfraLocators; +} export interface InfraClientStartExports { inventoryViews: InventoryViewsServiceStart; logViews: LogViewsServiceStart; metricsExplorerViews: MetricsExplorerViewsServiceStart; telemetry: ITelemetryClient; + locators: InfraLocators; ContainerMetricsTable: ( props: UseNodeMetricsTableOptions & Partial ) => JSX.Element; diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index eb8777665895a..d1613147365fc 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -17,7 +17,12 @@ import { handleEsError } from '@kbn/es-ui-shared-plugin/server'; import { i18n } from '@kbn/i18n'; import { Logger } from '@kbn/logging'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { LOGS_FEATURE_ID, METRICS_FEATURE_ID } from '../common/constants'; +import { + DISCOVER_APP_TARGET, + LOGS_APP_TARGET, + LOGS_FEATURE_ID, + METRICS_FEATURE_ID, +} from '../common/constants'; import { defaultLogViewsStaticConfig } from '../common/log_views'; import { publicConfigKeys } from '../common/plugin_config_types'; import { configDeprecations, getInfraDeprecationsFactory } from './deprecations'; @@ -63,9 +68,12 @@ import { UsageCollector } from './usage/usage_collector'; export const config: PluginConfigDescriptor = { schema: schema.object({ logs: schema.object({ - app_target: schema.oneOf([schema.literal('logs-ui'), schema.literal('discover')], { - defaultValue: 'logs-ui', - }), + app_target: schema.oneOf( + [schema.literal(LOGS_APP_TARGET), schema.literal(DISCOVER_APP_TARGET)], + { + defaultValue: LOGS_APP_TARGET, + } + ), }), alerting: schema.object({ inventory_threshold: schema.object({ diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f2d39db78cea3..0110ce7066048 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -16977,7 +16977,6 @@ "xpack.infra.nodeContextMenu.viewUptimeLink": "{inventoryName} en disponibilité", "xpack.infra.nodeDetails.tabs.metadata.seeMore": "+{count} en plus", "xpack.infra.parseInterval.errorMessage": "{value} n'est pas une chaîne d'intervalle", - "xpack.infra.redirectToNodeLogs.loadingNodeLogsMessage": "Chargement de logs {nodeType}", "xpack.infra.snapshot.missingSnapshotMetricError": "L'agrégation de {metric} pour {nodeType} n'est pas disponible.", "xpack.infra.sourceConfiguration.logIndicesRecommendedValue": "La valeur recommandée est {defaultValue}", "xpack.infra.sourceConfiguration.metricIndicesRecommendedValue": "La valeur recommandée est {defaultValue}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c36cf65aa236c..ec8b4d8eb9ee6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16976,7 +16976,6 @@ "xpack.infra.nodeContextMenu.viewUptimeLink": "アップタイムの{inventoryName}", "xpack.infra.nodeDetails.tabs.metadata.seeMore": "+ 追加の{count}", "xpack.infra.parseInterval.errorMessage": "{value}は間隔文字列ではありません", - "xpack.infra.redirectToNodeLogs.loadingNodeLogsMessage": "{nodeType} ログを読み込み中", "xpack.infra.snapshot.missingSnapshotMetricError": "{nodeType}の{metric}のアグリゲーションを利用できません。", "xpack.infra.sourceConfiguration.logIndicesRecommendedValue": "推奨値は {defaultValue} です", "xpack.infra.sourceConfiguration.metricIndicesRecommendedValue": "推奨値は {defaultValue} です", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bd4cdc9eef4a5..9e23df1f33567 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16978,7 +16978,6 @@ "xpack.infra.nodeContextMenu.viewUptimeLink": "Uptime 中的 {inventoryName}", "xpack.infra.nodeDetails.tabs.metadata.seeMore": "+ 另外 {count} 个", "xpack.infra.parseInterval.errorMessage": "{value} 不是时间间隔字符串", - "xpack.infra.redirectToNodeLogs.loadingNodeLogsMessage": "正在加载 {nodeType} 日志", "xpack.infra.snapshot.missingSnapshotMetricError": "{nodeType} 的 {metric} 聚合不可用。", "xpack.infra.sourceConfiguration.logIndicesRecommendedValue": "推荐值为 {defaultValue}", "xpack.infra.sourceConfiguration.metricIndicesRecommendedValue": "推荐值为 {defaultValue}", diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index 6598e0e03d6bc..7ad37696f5a46 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -10,7 +10,8 @@ import { URL } from 'url'; import { FtrProviderContext } from '../../ftr_provider_context'; const ONE_HOUR = 60 * 60 * 1000; -const LOG_VIEW_REFERENCE = '(logViewId:default,type:log-view-reference)'; +const LOG_VIEW_ID = 'testView'; +const LOG_VIEW_REFERENCE = `(logViewId:${LOG_VIEW_ID},type:log-view-reference)`; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common']); @@ -24,36 +25,73 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const traceId = '433b4651687e18be2c6c8e3b11f53d09'; describe('link-to Logs', function () { - it('redirects to the logs app and parses URL search params correctly', async () => { - const location = { - hash: '', - pathname: '/link-to', - search: `time=${timestamp}&filter=trace.id:${traceId}`, - state: undefined, - }; - - await pageObjects.common.navigateToUrlWithBrowserHistory( - 'infraLogs', - location.pathname, - location.search, - { - ensureCurrentUrl: false, - } - ); - await retry.tryForTime(5000, async () => { - const currentUrl = await browser.getCurrentUrl(); - const parsedUrl = new URL(currentUrl); - const documentTitle = await browser.getTitle(); - - expect(parsedUrl.pathname).to.be('/app/logs/stream'); - expect(parsedUrl.searchParams.get('logFilter')).to.be( - `(query:(language:kuery,query:\'trace.id:${traceId}'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'${startDate}',to:'${endDate}'))` + describe('Redirect to Logs', function () { + it('redirects to the logs app and parses URL search params correctly', async () => { + const location = { + hash: '', + pathname: `/link-to/${LOG_VIEW_ID}`, + search: `time=${timestamp}&filter=trace.id:${traceId}`, + state: undefined, + }; + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + location.pathname, + location.search, + { + ensureCurrentUrl: false, + } ); - expect(parsedUrl.searchParams.get('logPosition')).to.be( - `(position:(tiebreaker:0,time:${timestamp}))` + return await retry.tryForTime(5000, async () => { + const currentUrl = await browser.getCurrentUrl(); + const parsedUrl = new URL(currentUrl); + const documentTitle = await browser.getTitle(); + + expect(parsedUrl.pathname).to.be('/app/logs/stream'); + expect(parsedUrl.searchParams.get('logFilter')).to.be( + `(query:(language:kuery,query:\'trace.id:${traceId}'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'${startDate}',to:'${endDate}'))` + ); + expect(parsedUrl.searchParams.get('logPosition')).to.be( + `(position:(tiebreaker:0,time:${timestamp}))` + ); + expect(parsedUrl.searchParams.get('logView')).to.be(LOG_VIEW_REFERENCE); + expect(documentTitle).to.contain('Stream - Logs - Observability - Elastic'); + }); + }); + }); + describe('Redirect to Node Logs', function () { + it('redirects to the logs app and parses URL search params correctly', async () => { + const nodeId = 1234; + const location = { + hash: '', + pathname: `/link-to/${LOG_VIEW_ID}/pod-logs/${nodeId}`, + search: `time=${timestamp}&filter=trace.id:${traceId}`, + state: undefined, + }; + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + location.pathname, + location.search, + { + ensureCurrentUrl: false, + } ); - expect(parsedUrl.searchParams.get('logView')).to.be(LOG_VIEW_REFERENCE); - expect(documentTitle).to.contain('Stream - Logs - Observability - Elastic'); + await retry.tryForTime(5000, async () => { + const currentUrl = await browser.getCurrentUrl(); + const parsedUrl = new URL(currentUrl); + const documentTitle = await browser.getTitle(); + + expect(parsedUrl.pathname).to.be('/app/logs/stream'); + expect(parsedUrl.searchParams.get('logFilter')).to.be( + `(query:(language:kuery,query:\'(kubernetes.pod.uid: 1234) and (trace.id:${traceId})\'),refreshInterval:(pause:!t,value:5000),timeRange:(from:'${startDate}',to:'${endDate}'))` + ); + expect(parsedUrl.searchParams.get('logPosition')).to.be( + `(position:(tiebreaker:0,time:${timestamp}))` + ); + expect(parsedUrl.searchParams.get('logView')).to.be(LOG_VIEW_REFERENCE); + expect(documentTitle).to.contain('Stream - Logs - Observability - Elastic'); + }); }); }); });