[Logs UI] Register Logs UI Locators (#155156)

## Summary

Closes https://github.com/elastic/kibana/issues/104855

This PR creates 2 registered locators:

1. Logs Locator
2. Node Logs Locator

With these 2 locators, we now have a typed navigation to the logs UI
which also redirects to Discover in serverless mode.

## Testing

### Normal behaviour

When Kibana is used as always then on any navigation to the logs UI, the
user will be redirected to the stream UI.
All links to `link-to` routes should still behave as before.
- Launch the Kibana dev environment with `yarn start`
- Navigate to Hosts UI
- Click the logs tab
- Add a filter text in the search bar
- Click on the Open in Logs link
- Verify that navigation to the Stream view and the state is maintained



https://user-images.githubusercontent.com/11225826/234514430-ddc1ffaa-0cb2-4f2a-84e9-6c6230937d9f.mov



### Serverless behaviour

When Kibana is used in serverless mode, we want to redirect any user
landing to Logs UI to the Discover page
- Launch the Kibana dev environment with `yarn serverless-oblt`
- Navigate to Hosts UI
- Click the logs tab
- Add a filter text in the search bar
- Click on the Open in Logs link
- Verify to be redirected to Discover and that the state is maintained



https://user-images.githubusercontent.com/11225826/234514454-dfb2774e-d6f1-4f4c-ba10-77815dc1ae9d.mov


### Next Steps
A separate PR will be created to fulfill the below AC

- All usages of link-to routes in other apps are replaced with usage of
the appropriate locator.
This commit is contained in:
mohamedhamed-ahmed 2023-05-17 13:19:26 +01:00 committed by GitHub
parent 0eb1d0fc39
commit 5d96ef99d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 871 additions and 666 deletions

View file

@ -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';

View file

@ -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;
};

View file

@ -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<LogsLocatorParams>;
export class DiscoverLogsLocatorDefinition implements LocatorDefinition<LogsLocatorParams> {
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 });
};
}

View file

@ -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<NodeLogsLocatorParams>;
export class DiscoverNodeLogsLocatorDefinition implements LocatorDefinition<NodeLogsLocatorParams> {
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,
});
};
}

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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<InfraLocators> => ({
logsLocator: sharePluginMock.createLocator(),
nodeLogsLocator: sharePluginMock.createLocator(),
});

View file

@ -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<NodeLogsLocatorParams>) => {
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<NodeLogsLocatorParams>) => {
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();
};

View file

@ -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<LogsLocatorParams>;
export interface LogsLocatorDependencies {
core: InfraClientCoreSetup;
}
export class LogsLocatorDefinition implements LocatorDefinition<LogsLocatorParams> {
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: {},
};
};
}

View file

@ -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<NodeLogsLocatorParams>;
export type NodeLogsLocatorDependencies = LogsLocatorDependencies;
export class NodeLogsLocatorDefinition implements LocatorDefinition<NodeLogsLocatorParams> {
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: {},
};
};
}

View file

@ -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: () => <div />,
HostMetricsTable: () => <div />,
PodMetricsTable: () => <div />,

View file

@ -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<PositionStateInUrl>(defaultPositionStateKey, {
position: {

View file

@ -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<FilterStateInUrl>(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 =>

View file

@ -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,

View file

@ -52,44 +52,53 @@ export const initializeFromUrl =
}: LogViewUrlStateDependencies): InvokeCreator<LogViewContext, LogViewEvent> =>
(_context, _event) =>
(send) => {
const logViewQueryValueFromUrl = urlStateStorage.get(logViewKey);
const logViewQueryE = decodeLogViewQueryValueFromUrl(logViewQueryValueFromUrl);
const logViewReference = getLogViewReferenceFromUrl({
logViewKey,
sourceIdKey,
toastsService,
urlStateStorage,
});
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 : []),
])
)
);
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 =

View file

@ -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<typeof useLogView>;
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(
<KibanaContextProvider services={services}>
<Router history={history}>{routes}</Router>
</KibanaContextProvider>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
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(
<Switch>
<Route path="/link-to" component={LinkToLogsPage} />
</Switch>
);
history.push('/link-to/pod-logs/POD_UID');
expect(queryByTestId('nodeLoadingPage-pod')).not.toBeEmptyDOMElement();
});
});
});

View file

@ -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<LinkToPageProps> = (props) => {
return (
<Switch>

View file

@ -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(
<RedirectToLogs {...createRouteComponentProps('/logs?time=1550671089404')} />
);
expect(component).toMatchInlineSnapshot(`
<Redirect
to="/stream?logView=(logViewId:default,type:log-view-reference)&logPosition=(position:(tiebreaker:0,time:1550671089404))&logFilter=(query:(language:kuery,query:''),refreshInterval:(pause:!t,value:5000),timeRange:(from:'2019-02-20T12:58:09.404Z',to:'2019-02-20T14:58:09.404Z'))"
/>
`);
});
it('renders a redirect with the correct user-defined filter', () => {
const component = shallow(
<RedirectToLogs
{...createRouteComponentProps('/logs?time=1550671089404&filter=FILTER_FIELD:FILTER_VALUE')}
/>
);
expect(component).toMatchInlineSnapshot(`
<Redirect
to="/stream?logView=(logViewId:default,type:log-view-reference)&logPosition=(position:(tiebreaker:0,time:1550671089404))&logFilter=(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'))"
/>
`);
});
it('renders a redirect with the correct custom source id', () => {
const component = shallow(
<RedirectToLogs {...createRouteComponentProps('/SOME-OTHER-SOURCE/logs')} />
);
expect(component).toMatchInlineSnapshot(`
<Redirect
to="/stream?logView=(logViewId:default,type:log-view-reference)&logFilter=(query:(language:kuery,query:''),refreshInterval:(pause:!t,value:5000))"
/>
`);
});
});
const createRouteComponentProps = (path: string) => {
const location = createLocation(path);
return {
match: matchPath(location.pathname, { path: '/:sourceId?/logs' }) as any,
history: null as any,
location,
};
};

View file

@ -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 <Redirect to={`/stream?${searchString}`} />;
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;
};

View file

@ -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 (
<LoadingPage
data-test-subj={`nodeLoadingPage-${nodeType}`}
message={i18n.translate('xpack.infra.redirectToNodeLogs.loadingNodeLogsMessage', {
defaultMessage: 'Loading {nodeType} logs',
values: {
nodeType,
},
})}
/>
);
}
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 <Redirect to={`/stream?${searchString}`} />;
return null;
};
export const getNodeLogsUrl = ({

View file

@ -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 (
<RedirectAppLinks coreStart={services}>
<EuiButtonEmpty
href={viewInLogsUrl}
href={locators.logsLocator?.getRedirectUrl({
time: endTime,
timeRange: {
startTime,
endTime,
},
filter: query,
})}
data-test-subj="hostsView-logs-link-to-stream-button"
iconType="popout"
flush="both"

View file

@ -64,7 +64,7 @@ export const LogsTabContent = () => {
<LogsSearchBar />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogsLinkToStream startTimestamp={from} endTimestamp={to} query={logsLinkToStreamQuery} />
<LogsLinkToStream startTime={from} endTime={to} query={logsLinkToStreamQuery} />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -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<AppUpdater>(() => ({}));
constructor(context: PluginInitializerContext<InfraPublicConfig>) {
@ -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),

View file

@ -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<SourceProviderProps>
) => JSX.Element;

View file

@ -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<InfraConfig> = {
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({

View file

@ -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}",

View file

@ -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} です",

View file

@ -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}",

View file

@ -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,
};
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,
}
);
await retry.tryForTime(5000, async () => {
const currentUrl = await browser.getCurrentUrl();
const parsedUrl = new URL(currentUrl);
const documentTitle = await browser.getTitle();
await pageObjects.common.navigateToUrlWithBrowserHistory(
'infraLogs',
location.pathname,
location.search,
{
ensureCurrentUrl: false,
}
);
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.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('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');
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');
});
});
});
});