[Logs+] Add Log Explorer profile deep link (#161939)

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: weltenwort <stuermer@weltenwort.de>
This commit is contained in:
Marco Antonio Ghiani 2023-07-24 21:23:58 +02:00 committed by GitHub
parent d19a295d38
commit 9bae853586
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 535 additions and 219 deletions

View file

@ -10,6 +10,7 @@ xpack.serverless.observability.enabled: true
## Configure plugins
xpack.infra.logs.app_target: discover
xpack.discoverLogExplorer.featureFlags.deepLinkVisible: true
## Set the home route
uiSettings.overrides.defaultRoute: /app/observability/landing

View file

@ -74,163 +74,165 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
isOptionsOpen = false;
};
discover.customize('customization-examples', async ({ customizations, stateContainer }) => {
customizations.set({
id: 'top_nav',
defaultMenu: {
newItem: { disabled: true },
openItem: { disabled: true },
shareItem: { order: 200 },
alertsItem: { disabled: true },
inspectItem: { disabled: true },
saveItem: { order: 400 },
},
getMenuItems: () => [
{
data: {
id: 'options',
label: 'Options',
iconType: 'arrowDown',
iconSide: 'right',
testId: 'customOptionsButton',
run: (anchorElement: HTMLElement) => {
if (isOptionsOpen) {
closeOptionsPopover();
return;
}
isOptionsOpen = true;
document.body.appendChild(optionsContainer);
const element = (
<EuiWrappingPopover
ownFocus
button={anchorElement}
isOpen={true}
panelPaddingSize="s"
closePopover={closeOptionsPopover}
>
<EuiContextMenu
size="s"
initialPanelId={0}
panels={[
{
id: 0,
items: [
{
name: 'Create new',
icon: 'plusInCircle',
onClick: () => alert('Create new clicked'),
},
{
name: 'Make a copy',
icon: 'copy',
onClick: () => alert('Make a copy clicked'),
},
{
name: 'Manage saved searches',
icon: 'gear',
onClick: () => alert('Manage saved searches clicked'),
},
],
},
]}
data-test-subj="customOptionsPopover"
/>
</EuiWrappingPopover>
);
ReactDOM.render(element, optionsContainer);
},
},
order: 100,
discover.registerCustomizationProfile('customization-examples', {
customize: async ({ customizations, stateContainer }) => {
customizations.set({
id: 'top_nav',
defaultMenu: {
newItem: { disabled: true },
openItem: { disabled: true },
shareItem: { order: 200 },
alertsItem: { disabled: true },
inspectItem: { disabled: true },
saveItem: { order: 400 },
},
{
data: {
id: 'documentExplorer',
label: 'Document explorer',
iconType: 'discoverApp',
testId: 'documentExplorerButton',
run: () => {
discover.locator?.navigate({});
getMenuItems: () => [
{
data: {
id: 'options',
label: 'Options',
iconType: 'arrowDown',
iconSide: 'right',
testId: 'customOptionsButton',
run: (anchorElement: HTMLElement) => {
if (isOptionsOpen) {
closeOptionsPopover();
return;
}
isOptionsOpen = true;
document.body.appendChild(optionsContainer);
const element = (
<EuiWrappingPopover
ownFocus
button={anchorElement}
isOpen={true}
panelPaddingSize="s"
closePopover={closeOptionsPopover}
>
<EuiContextMenu
size="s"
initialPanelId={0}
panels={[
{
id: 0,
items: [
{
name: 'Create new',
icon: 'plusInCircle',
onClick: () => alert('Create new clicked'),
},
{
name: 'Make a copy',
icon: 'copy',
onClick: () => alert('Make a copy clicked'),
},
{
name: 'Manage saved searches',
icon: 'gear',
onClick: () => alert('Manage saved searches clicked'),
},
],
},
]}
data-test-subj="customOptionsPopover"
/>
</EuiWrappingPopover>
);
ReactDOM.render(element, optionsContainer);
},
},
order: 100,
},
order: 300,
{
data: {
id: 'documentExplorer',
label: 'Document explorer',
iconType: 'discoverApp',
testId: 'documentExplorerButton',
run: () => {
discover.locator?.navigate({});
},
},
order: 300,
},
],
});
customizations.set({
id: 'search_bar',
CustomDataViewPicker: () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = () => setIsPopoverOpen((open) => !open);
const closePopover = () => setIsPopoverOpen(false);
const [savedSearches, setSavedSearches] = useState<
Array<SimpleSavedObject<{ title: string }>>
>([]);
useEffect(() => {
core.savedObjects.client
.find<{ title: string }>({ type: 'search' })
.then((response) => {
setSavedSearches(response.savedObjects);
});
}, []);
const currentSavedSearch = useObservable(
stateContainer.savedSearchState.getCurrent$(),
stateContainer.savedSearchState.getState()
);
return (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
fullWidth
onClick={togglePopover}
data-test-subj="logsViewSelectorButton"
>
{currentSavedSearch.title ?? 'None selected'}
</EuiButton>
}
anchorClassName="eui-fullWidth"
isOpen={isPopoverOpen}
panelPaddingSize="none"
closePopover={closePopover}
>
<EuiContextMenu
size="s"
initialPanelId={0}
panels={[
{
id: 0,
title: 'Saved logs views',
items: savedSearches.map((savedSearch) => ({
name: savedSearch.get('title'),
onClick: () => stateContainer.actions.onOpenSavedSearch(savedSearch.id),
icon: savedSearch.id === currentSavedSearch.id ? 'check' : 'empty',
'data-test-subj': `logsViewSelectorOption-${savedSearch.attributes.title.replace(
/[^a-zA-Z0-9]/g,
''
)}`,
})),
},
]}
/>
</EuiPopover>
</EuiFlexItem>
);
},
],
});
});
customizations.set({
id: 'search_bar',
CustomDataViewPicker: () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = () => setIsPopoverOpen((open) => !open);
const closePopover = () => setIsPopoverOpen(false);
const [savedSearches, setSavedSearches] = useState<
Array<SimpleSavedObject<{ title: string }>>
>([]);
useEffect(() => {
core.savedObjects.client
.find<{ title: string }>({ type: 'search' })
.then((response) => {
setSavedSearches(response.savedObjects);
});
}, []);
const currentSavedSearch = useObservable(
stateContainer.savedSearchState.getCurrent$(),
stateContainer.savedSearchState.getState()
);
return (
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
fullWidth
onClick={togglePopover}
data-test-subj="logsViewSelectorButton"
>
{currentSavedSearch.title ?? 'None selected'}
</EuiButton>
}
anchorClassName="eui-fullWidth"
isOpen={isPopoverOpen}
panelPaddingSize="none"
closePopover={closePopover}
>
<EuiContextMenu
size="s"
initialPanelId={0}
panels={[
{
id: 0,
title: 'Saved logs views',
items: savedSearches.map((savedSearch) => ({
name: savedSearch.get('title'),
onClick: () => stateContainer.actions.onOpenSavedSearch(savedSearch.id),
icon: savedSearch.id === currentSavedSearch.id ? 'check' : 'empty',
'data-test-subj': `logsViewSelectorOption-${savedSearch.attributes.title.replace(
/[^a-zA-Z0-9]/g,
''
)}`,
})),
},
]}
/>
</EuiPopover>
</EuiFlexItem>
);
},
});
return () => {
// eslint-disable-next-line no-console
console.log('Cleaning up Logs explorer customizations');
};
return () => {
// eslint-disable-next-line no-console
console.log('Cleaning up Logs explorer customizations');
};
},
});
}
}

View file

@ -297,6 +297,12 @@ export type AppDeepLink<Id extends string = string> = {
navLinkStatus?: AppNavLinkStatus;
/** Optional flag to determine if the link is searchable in the global search. Defaulting to `true` if `navLinkStatus` is `visible` or omitted */
searchable?: boolean;
/**
* Optional category to use instead of the parent app category.
* This property is added to customize the way a deep link is rendered in the global search.
* Any other feature that consumes the deep links (navigation tree, etc.) will not be affected by this addition.
*/
category?: AppCategory;
} & AppNavOptions &
(
| {

View file

@ -5,6 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics';
import {
LOGS_APP_ID,
@ -27,6 +28,8 @@ export type AppId =
| ApmApp
| MetricsApp;
export type DiscoverLogExplorerId = `${typeof DISCOVER_APP_ID}:log-explorer`;
export type LogsLinkId = 'log-categories' | 'settings' | 'anomalies' | 'stream';
export type ObservabilityOverviewLinkId =
@ -52,6 +55,7 @@ export type LinkId = LogsLinkId | ObservabilityOverviewLinkId | MetricsLinkId |
export type DeepLinkId =
| AppId
| DiscoverLogExplorerId
| `${LogsApp}:${LogsLinkId}`
| `${ObservabilityOverviewApp}:${ObservabilityOverviewLinkId}`
| `${MetricsApp}:${MetricsLinkId}`

View file

@ -15,5 +15,7 @@
"exclude": [
"target/**/*"
],
"kbn_references": []
"kbn_references": [
"@kbn/deeplinks-analytics",
]
}

View file

@ -130,12 +130,12 @@ const profileRegistry = createProfileRegistry();
const callbacks = [jest.fn()];
profileRegistry.set({
name: 'default',
id: 'default',
customizationCallbacks: callbacks,
});
profileRegistry.set({
name: 'test',
id: 'test',
customizationCallbacks: callbacks,
});

View file

@ -6,25 +6,27 @@
* Side Public License, v 1.
*/
import { createCustomizeFunction, createProfileRegistry } from './profile_registry';
import { App, AppDeepLink, AppUpdater } from '@kbn/core/public';
import { BehaviorSubject, combineLatest, map, take } from 'rxjs';
import { createRegisterCustomizationProfile, createProfileRegistry } from './profile_registry';
describe('createProfileRegistry', () => {
it('should allow registering profiles', () => {
const registry = createProfileRegistry();
registry.set({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
registry.set({
name: 'test2',
id: 'test2',
customizationCallbacks: [],
});
expect(registry.get('test')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
expect(registry.get('test2')).toEqual({
name: 'test2',
id: 'test2',
customizationCallbacks: [],
});
});
@ -32,20 +34,20 @@ describe('createProfileRegistry', () => {
it('should allow overriding profiles', () => {
const registry = createProfileRegistry();
registry.set({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
expect(registry.get('test')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
const callback = jest.fn();
registry.set({
name: 'test',
id: 'test',
customizationCallbacks: [callback],
});
expect(registry.get('test')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [callback],
});
});
@ -53,31 +55,80 @@ describe('createProfileRegistry', () => {
it('should be case insensitive', () => {
const registry = createProfileRegistry();
registry.set({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
expect(registry.get('tEsT')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [],
});
});
});
describe('createCustomizeFunction', () => {
describe('createRegisterCustomizationProfile', () => {
test('should add a customization callback to the registry', () => {
const registry = createProfileRegistry();
const customize = createCustomizeFunction(registry);
const registerCustomizationProfile = createRegisterCustomizationProfile(registry);
const callback = jest.fn();
customize('test', callback);
registerCustomizationProfile('test', { customize: callback });
expect(registry.get('test')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [callback],
deepLinks: [],
});
const callback2 = jest.fn();
customize('test', callback2);
registerCustomizationProfile('test', { customize: callback2 });
expect(registry.get('test')).toEqual({
name: 'test',
id: 'test',
customizationCallbacks: [callback, callback2],
deepLinks: [],
});
});
});
describe('profile.getContributedAppState$ observable', () => {
test('should notify subscribers with new app updates when a profile is registered', (done) => {
const registry = createProfileRegistry();
const callback = jest.fn();
const appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
const mockDeepLink: AppDeepLink = {
id: 'test-deepLink',
title: 'Test deep link',
path: '/test-deep-link',
};
let mockApp: App = { id: 'test-app', title: 'Test App', mount: () => () => {} };
const expectedApp: App = { ...mockApp, deepLinks: [mockDeepLink] };
const appStateUpdater$ = combineLatest([appUpdater$, registry.getContributedAppState$()]).pipe(
map(
([appUpdater, registryContributor]): AppUpdater =>
(app) => ({ ...appUpdater(app), ...registryContributor(app) })
),
take(3)
);
appStateUpdater$.subscribe({
next: (updater) => {
mockApp = { ...mockApp, ...updater(mockApp) };
},
complete: () => {
expect(mockApp).toEqual(expectedApp);
done();
},
});
// First update, no deepLinks set
registry.set({
id: 'test',
customizationCallbacks: [callback],
});
// Second update, deepLinks set to update app
registry.set({
id: 'test',
customizationCallbacks: [],
deepLinks: [mockDeepLink],
});
});
});

View file

@ -6,34 +6,68 @@
* Side Public License, v 1.
*/
import type { CustomizationCallback } from './types';
export interface DiscoverProfile {
name: string;
customizationCallbacks: CustomizationCallback[];
}
import type { AppDeepLink, AppUpdater } from '@kbn/core/public';
import { map, Observable, BehaviorSubject } from 'rxjs';
import type { RegisterCustomizationProfile, DiscoverProfile, DiscoverProfileId } from './types';
export interface DiscoverProfileRegistry {
get(name: string): DiscoverProfile | undefined;
get(id: DiscoverProfileId): DiscoverProfile | undefined;
set(profile: DiscoverProfile): void;
getContributedAppState$: () => Observable<AppUpdater>;
}
export const createProfileRegistry = (): DiscoverProfileRegistry => {
const profiles = new Map<string, DiscoverProfile>();
const profiles = new Map<string, DiscoverProfile>([['default', createProfile('default')]]);
const profiles$ = new BehaviorSubject<DiscoverProfile[]>([...profiles.values()]);
return {
get: (name) => profiles.get(name.toLowerCase()),
set: (profile) => profiles.set(profile.name.toLowerCase(), profile),
get: (id) => profiles.get(id.toLowerCase()),
set: (profile) => {
profiles.set(profile.id.toLowerCase(), profile);
profiles$.next([...profiles.values()]);
},
getContributedAppState$() {
return profiles$.pipe(
map((profilesList) => profilesList.flatMap((profile) => profile.deepLinks ?? [])),
map((profilesDeepLinks) => (app) => ({
deepLinks: getUniqueDeepLinks([...(app.deepLinks ?? []), ...profilesDeepLinks]),
}))
);
},
};
};
export const createCustomizeFunction =
(profileRegistry: DiscoverProfileRegistry) =>
(profileName: string, callback: CustomizationCallback) => {
const profile = profileRegistry.get(profileName) ?? {
name: profileName,
customizationCallbacks: [],
};
profile.customizationCallbacks.push(callback);
export const createRegisterCustomizationProfile =
(profileRegistry: DiscoverProfileRegistry): RegisterCustomizationProfile =>
(id, options) => {
const profile = profileRegistry.get(id) ?? createProfile(id);
const { customize, deepLinks } = options;
profile.customizationCallbacks.push(customize);
if (Array.isArray(deepLinks) && profile.deepLinks) {
profile.deepLinks = getUniqueDeepLinks([...profile.deepLinks, ...deepLinks]);
} else if (Array.isArray(deepLinks)) {
profile.deepLinks = getUniqueDeepLinks(deepLinks);
}
profileRegistry.set(profile);
};
/**
* Utils
*/
const createProfile = (id: DiscoverProfileId): DiscoverProfile => ({
id,
customizationCallbacks: [],
deepLinks: [],
});
const getUniqueDeepLinks = (deepLinks: AppDeepLink[]): AppDeepLink[] => {
const mapValues = deepLinks
.reduce((deepLinksMap, deepLink) => deepLinksMap.set(deepLink.id, deepLink), new Map())
.values();
return Array.from(mapValues);
};

View file

@ -6,14 +6,33 @@
* Side Public License, v 1.
*/
import type { AppDeepLink } from '@kbn/core/public';
import type { DiscoverStateContainer } from '../application/main/services/discover_state';
import type { DiscoverCustomizationService } from './customization_service';
export type DiscoverProfileId = string;
export interface DiscoverProfile {
id: DiscoverProfileId;
customizationCallbacks: CustomizationCallback[];
deepLinks?: AppDeepLink[];
}
export interface CustomizationCallbackContext {
customizations: DiscoverCustomizationService;
stateContainer: DiscoverStateContainer;
}
export interface DiscoverProfileOptions {
customize: CustomizationCallback;
deepLinks?: AppDeepLink[];
}
export type RegisterCustomizationProfile = (
id: DiscoverProfileId,
options: DiscoverProfileOptions
) => void;
export type CustomizationCallback = (
options: CustomizationCallbackContext
) => void | (() => void) | Promise<void | (() => void)>;

View file

@ -16,6 +16,11 @@ export function plugin(initializerContext: PluginInitializerContext) {
export type { ISearchEmbeddable, SearchInput } from './embeddable';
export type { DiscoverStateContainer } from './application/main/services/discover_state';
export type { CustomizationCallback } from './customizations';
export type {
CustomizationCallback,
DiscoverProfileId,
DiscoverProfileOptions,
RegisterCustomizationProfile,
} from './customizations';
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './embeddable';
export { loadSharingDataHelpers } from './utils';

View file

@ -25,7 +25,7 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => {
const startContract: Start = {
locator: sharePluginMock.createLocator(),
customize: jest.fn(),
registerCustomizationProfile: jest.fn(),
};
return startContract;
};

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import {
AppMountParameters,
AppUpdater,
@ -72,8 +72,11 @@ import {
DiscoverSingleDocLocatorDefinition,
} from './application/doc/locator';
import { DiscoverAppLocator, DiscoverAppLocatorDefinition } from '../common';
import type { CustomizationCallback } from './customizations';
import { createCustomizeFunction, createProfileRegistry } from './customizations/profile_registry';
import type { RegisterCustomizationProfile } from './customizations';
import {
createRegisterCustomizationProfile,
createProfileRegistry,
} from './customizations/profile_registry';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER } from './embeddable/constants';
const DocViewerLegacyTable = React.lazy(
@ -159,7 +162,7 @@ export interface DiscoverStart {
* ```
*/
readonly locator: undefined | DiscoverAppLocator;
readonly customize: (profileName: string, callback: CustomizationCallback) => void;
readonly registerCustomizationProfile: RegisterCustomizationProfile;
}
/**
@ -305,10 +308,23 @@ export class DiscoverPlugin
stopUrlTracker();
};
const appStateUpdater$ = combineLatest([
this.appStateUpdater,
this.profileRegistry.getContributedAppState$(),
]).pipe(
map(
([urlAppStateUpdater, profileAppStateUpdater]): AppUpdater =>
(app) => ({
...urlAppStateUpdater(app),
...profileAppStateUpdater(app),
})
)
);
core.application.register({
id: PLUGIN_ID,
title: 'Discover',
updater$: this.appStateUpdater.asObservable(),
updater$: appStateUpdater$,
order: 1000,
euiIconType: 'logoKibana',
defaultPath: '#/',
@ -416,7 +432,7 @@ export class DiscoverPlugin
return {
locator: this.locator,
customize: createCustomizeFunction(this.profileRegistry),
registerCustomizationProfile: createRegisterCustomizationProfile(this.profileRegistry),
};
}

View file

@ -286,6 +286,10 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)',
'xpack.observability.unsafe.thresholdRule.enabled (boolean)',
'xpack.observability_onboarding.ui.enabled (boolean)',
/**
* xpack.discoverLogExplorer.featureFlags is conditional and will never resolve if used in non-serverless environment
*/
'xpack.discoverLogExplorer.featureFlags.deepLinkVisible (any)',
];
// We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large
// arrays are hard to grok. Instead, we take the difference between the two arrays and assert them separately, that way it's

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export interface DiscoverLogExplorerConfig {
featureFlags: {
deepLinkVisible: boolean;
};
}

View file

@ -5,9 +5,9 @@
"description": "This plugin exposes and registers Logs+ features.",
"plugin": {
"id": "discoverLogExplorer",
"server": false,
"server": true,
"browser": true,
"configPath": ["xpack", "discover_log_explorer"],
"configPath": ["xpack", "discoverLogExplorer"],
"requiredPlugins": ["discover", "fleet", "kibanaReact", "kibanaUtils"],
"optionalPlugins": [],
"requiredBundles": []

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 { AppDeepLink, AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { LOG_EXPLORER_PROFILE_ID } from '../common/constants';
export const getLogExplorerDeepLink = ({ isVisible }: { isVisible: boolean }): AppDeepLink => ({
id: LOG_EXPLORER_PROFILE_ID,
title: i18n.translate('xpack.discoverLogExplorer.deepLink', {
defaultMessage: 'Logs Explorer',
}),
path: `#/p/${LOG_EXPLORER_PROFILE_ID}`,
category: DEFAULT_APP_CATEGORIES.observability,
euiIconType: 'logoObservability',
navLinkStatus: isVisible ? AppNavLinkStatus.visible : AppNavLinkStatus.default,
});

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/public';
import { DiscoverLogExplorerConfig } from '../common/plugin_config';
import { DiscoverLogExplorerPlugin } from './plugin';
export function plugin() {
return new DiscoverLogExplorerPlugin();
export function plugin(context: PluginInitializerContext<DiscoverLogExplorerConfig>) {
return new DiscoverLogExplorerPlugin(context);
}

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import { CoreStart, Plugin } from '@kbn/core/public';
import { CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { LOG_EXPLORER_PROFILE_ID } from '../common/constants';
import { DiscoverLogExplorerConfig } from '../common/plugin_config';
import { createLogExplorerProfileCustomizations } from './customizations/log_explorer_profile';
import { getLogExplorerDeepLink } from './deep_links';
import {
DiscoverLogExplorerPluginSetup,
DiscoverLogExplorerPluginStart,
@ -17,11 +19,20 @@ import {
export class DiscoverLogExplorerPlugin
implements Plugin<DiscoverLogExplorerPluginSetup, DiscoverLogExplorerPluginStart>
{
private config: DiscoverLogExplorerConfig;
constructor(context: PluginInitializerContext<DiscoverLogExplorerConfig>) {
this.config = context.config.get();
}
public setup() {}
public start(core: CoreStart, plugins: DiscoverLogExplorerStartDeps) {
const { discover } = plugins;
discover.customize(LOG_EXPLORER_PROFILE_ID, createLogExplorerProfileCustomizations({ core }));
discover.registerCustomizationProfile(LOG_EXPLORER_PROFILE_ID, {
customize: createLogExplorerProfileCustomizations({ core }),
deepLinks: [getLogExplorerDeepLink({ isVisible: this.config.featureFlags.deepLinkVisible })],
});
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
import { DiscoverLogExplorerConfig } from '../common/plugin_config';
export const configSchema = schema.object({
featureFlags: schema.object({
deepLinkVisible: schema.conditional(
schema.contextRef('serverless'),
true,
schema.boolean(),
schema.never(),
{
defaultValue: false,
}
),
}),
});
export const config: PluginConfigDescriptor<DiscoverLogExplorerConfig> = {
schema: configSchema,
exposeToBrowser: {
featureFlags: {
deepLinkVisible: true,
},
},
};

View file

@ -0,0 +1,12 @@
/*
* 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 { DiscoverLogExplorerServerPlugin } from './plugin';
export { config } from './config';
export const plugin = () => new DiscoverLogExplorerServerPlugin();

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 { Plugin } from '@kbn/core/server';
export class DiscoverLogExplorerServerPlugin implements Plugin {
setup() {}
start() {}
}

View file

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["../../../typings/**/*", "common/**/*", "public/**/*", ".storybook/**/*.tsx"],
"include": ["../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*", ".storybook/**/*.tsx"],
"kbn_references": [
"@kbn/core",
"@kbn/discover-plugin",
@ -13,6 +13,7 @@
"@kbn/io-ts-utils",
"@kbn/data-views-plugin",
"@kbn/rison",
"@kbn/config-schema",
],
"exclude": ["target/**/*"]
}

View file

@ -104,6 +104,56 @@ describe('getAppResults', () => {
]);
});
it('deep links "category" and "icon" should take precedence over the same app properties', () => {
const apps = [
createApp({
euiIconType: 'logoKibana',
category: DEFAULT_APP_CATEGORIES.kibana,
deepLinks: [
{
id: 'sub-observability',
title: 'Sub Observability',
path: '/sub-observability',
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.hidden,
searchable: true,
},
{
id: 'sub-security',
title: 'Sub Security',
path: '/sub-security',
deepLinks: [],
keywords: [],
navLinkStatus: AppNavLinkStatus.visible,
searchable: true,
euiIconType: 'logoSecurity',
category: DEFAULT_APP_CATEGORIES.security,
},
],
keywords: [],
}),
];
const results = getAppResults('App 1', apps);
const [appLink, observabilityLink, securityLink] = results;
expect(appLink).toMatchObject({
icon: 'logoKibana',
meta: { categoryId: 'kibana', categoryLabel: 'Analytics' },
title: 'App 1',
});
expect(observabilityLink).toMatchObject({
icon: 'logoKibana',
meta: { categoryId: 'kibana', categoryLabel: 'Analytics' },
title: 'App 1 / Sub Observability',
});
expect(securityLink).toMatchObject({
icon: 'logoSecurity',
meta: { categoryId: 'securitySolution', categoryLabel: 'Security' },
title: 'App 1 / Sub Security',
});
});
it('only includes deepLinks when search term is non-empty', () => {
const apps = [
createApp({

View file

@ -6,7 +6,7 @@
*/
import levenshtein from 'js-levenshtein';
import { PublicAppInfo, PublicAppDeepLinkInfo } from '@kbn/core/public';
import { PublicAppInfo, PublicAppDeepLinkInfo, AppCategory } from '@kbn/core/public';
import { GlobalSearchProviderResult } from '@kbn/global-search-plugin/public';
/** Type used internally to represent an application unrolled into its separate deepLinks */
@ -16,6 +16,8 @@ export interface AppLink {
subLinkTitles: string[];
path: string;
keywords: string[];
category?: AppCategory;
euiIconType?: string;
}
/** weighting factor for scoring keywords */
@ -107,11 +109,11 @@ export const appToResult = (appLink: AppLink, score: number): GlobalSearchProvid
// Concatenate title using slashes
title: titleParts.join(' / '),
type: 'application',
icon: appLink.app.euiIconType,
icon: appLink.euiIconType ?? appLink.app.euiIconType,
url: appLink.path,
meta: {
categoryId: appLink.app.category?.id ?? null,
categoryLabel: appLink.app.category?.label ?? null,
categoryId: appLink.category?.id ?? appLink.app.category?.id ?? null,
categoryLabel: appLink.category?.label ?? appLink.app.category?.label ?? null,
},
score,
};
@ -138,6 +140,7 @@ const flattenDeepLinks = (app: PublicAppInfo, deepLink?: PublicAppDeepLinkInfo):
...(deepLink.path && deepLink.searchable
? [
{
...deepLink,
id: `${app.id}-${deepLink.id}`,
app,
path: `${app.appRoute}${deepLink.path}`,

View file

@ -31,7 +31,10 @@ const navigationTree: NavigationTreeDefinition = {
id: 'discover-dashboard-alerts-slos',
children: [
{
link: 'discover',
title: i18n.translate('xpack.serverlessObservability.nav.discover', {
defaultMessage: 'Discover',
}),
link: 'discover:log-explorer',
},
{
title: i18n.translate('xpack.serverlessObservability.nav.dashboards', {

View file

@ -4,11 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common']);
const PageObjects = getPageObjects(['common', 'navigationalSearch']);
const testSubjects = getService('testSubjects');
describe('Customizations', () => {
@ -50,6 +51,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.existOrFail('openInspectorButton');
await testSubjects.missingOrFail('discoverSaveButton');
});
it('should add a searchable deep link to the profile page', async () => {
await PageObjects.common.navigateToApp('home');
await PageObjects.navigationalSearch.searchFor('discover log explorer');
const results = await PageObjects.navigationalSearch.getDisplayedResults();
expect(results[0].label).to.eql('Discover / Logs Explorer');
});
});
});
}

View file

@ -87,7 +87,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
describe('Applications provider', function () {
it('can search for root-level applications', async () => {
const results = await findResultsWithApi('discover');
expect(results.length).to.be(1);
expect(results.length).to.be(2);
expect(results[0].title).to.be('Discover');
});

View file

@ -35,11 +35,12 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
});
await svlCommonNavigation.sidenav.expectSectionClosed('project_settings_project_nav');
// TODO: test something oblt project specific instead of generic discover
// navigate to discover
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' });
await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ deepLinkId: 'discover' });
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover:log-explorer' });
await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover:log-explorer' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({
deepLinkId: 'discover:log-explorer',
});
await expect(await browser.getCurrentUrl()).contain('/app/discover');
// check the aiops subsection
@ -78,12 +79,11 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
it('navigate using search', async () => {
await svlCommonNavigation.search.showSearch();
// TODO: test something oblt project specific instead of generic discover
await svlCommonNavigation.search.searchFor('discover');
await svlCommonNavigation.search.searchFor('discover log explorer');
await svlCommonNavigation.search.clickOnOption(0);
await svlCommonNavigation.search.hideSearch();
await expect(await browser.getCurrentUrl()).contain('/app/discover');
await expect(await browser.getCurrentUrl()).contain('/app/discover#/p/log-explorer');
});
});
}