Skip to content

Commit

Permalink
[Logs UI] Register Logs UI Locators (#155156)
Browse files Browse the repository at this point in the history
## Summary

Closes #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.
  • Loading branch information
mohamedhamed-ahmed authored May 17, 2023
1 parent 0eb1d0f commit 5d96ef9
Show file tree
Hide file tree
Showing 29 changed files with 872 additions and 667 deletions.
3 changes: 3 additions & 0 deletions x-pack/plugins/infra/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
91 changes: 8 additions & 83 deletions x-pack/plugins/infra/public/apps/discover_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
};
25 changes: 25 additions & 0 deletions x-pack/plugins/infra/public/locators/discover_logs_locator.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
}
33 changes: 33 additions & 0 deletions x-pack/plugins/infra/public/locators/discover_node_logs_locator.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
}
151 changes: 151 additions & 0 deletions x-pack/plugins/infra/public/locators/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
};
21 changes: 21 additions & 0 deletions x-pack/plugins/infra/public/locators/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions x-pack/plugins/infra/public/locators/locators.mock.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
Loading

0 comments on commit 5d96ef9

Please sign in to comment.