[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 ## Configure plugins
xpack.infra.logs.app_target: discover xpack.infra.logs.app_target: discover
xpack.discoverLogExplorer.featureFlags.deepLinkVisible: true
## Set the home route ## Set the home route
uiSettings.overrides.defaultRoute: /app/observability/landing uiSettings.overrides.defaultRoute: /app/observability/landing

View file

@ -74,163 +74,165 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
isOptionsOpen = false; isOptionsOpen = false;
}; };
discover.customize('customization-examples', async ({ customizations, stateContainer }) => { discover.registerCustomizationProfile('customization-examples', {
customizations.set({ customize: async ({ customizations, stateContainer }) => {
id: 'top_nav', customizations.set({
defaultMenu: { id: 'top_nav',
newItem: { disabled: true }, defaultMenu: {
openItem: { disabled: true }, newItem: { disabled: true },
shareItem: { order: 200 }, openItem: { disabled: true },
alertsItem: { disabled: true }, shareItem: { order: 200 },
inspectItem: { disabled: true }, alertsItem: { disabled: true },
saveItem: { order: 400 }, 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,
}, },
{ getMenuItems: () => [
data: { {
id: 'documentExplorer', data: {
label: 'Document explorer', id: 'options',
iconType: 'discoverApp', label: 'Options',
testId: 'documentExplorerButton', iconType: 'arrowDown',
run: () => { iconSide: 'right',
discover.locator?.navigate({}); 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({ return () => {
id: 'search_bar', // eslint-disable-next-line no-console
CustomDataViewPicker: () => { console.log('Cleaning up Logs explorer customizations');
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');
};
}); });
} }
} }

View file

@ -297,6 +297,12 @@ export type AppDeepLink<Id extends string = string> = {
navLinkStatus?: AppNavLinkStatus; navLinkStatus?: AppNavLinkStatus;
/** Optional flag to determine if the link is searchable in the global search. Defaulting to `true` if `navLinkStatus` is `visible` or omitted */ /** Optional flag to determine if the link is searchable in the global search. Defaulting to `true` if `navLinkStatus` is `visible` or omitted */
searchable?: boolean; 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 & } & AppNavOptions &
( (
| { | {

View file

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

View file

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

View file

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

View file

@ -6,25 +6,27 @@
* Side Public License, v 1. * 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', () => { describe('createProfileRegistry', () => {
it('should allow registering profiles', () => { it('should allow registering profiles', () => {
const registry = createProfileRegistry(); const registry = createProfileRegistry();
registry.set({ registry.set({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
registry.set({ registry.set({
name: 'test2', id: 'test2',
customizationCallbacks: [], customizationCallbacks: [],
}); });
expect(registry.get('test')).toEqual({ expect(registry.get('test')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
expect(registry.get('test2')).toEqual({ expect(registry.get('test2')).toEqual({
name: 'test2', id: 'test2',
customizationCallbacks: [], customizationCallbacks: [],
}); });
}); });
@ -32,20 +34,20 @@ describe('createProfileRegistry', () => {
it('should allow overriding profiles', () => { it('should allow overriding profiles', () => {
const registry = createProfileRegistry(); const registry = createProfileRegistry();
registry.set({ registry.set({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
expect(registry.get('test')).toEqual({ expect(registry.get('test')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
const callback = jest.fn(); const callback = jest.fn();
registry.set({ registry.set({
name: 'test', id: 'test',
customizationCallbacks: [callback], customizationCallbacks: [callback],
}); });
expect(registry.get('test')).toEqual({ expect(registry.get('test')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [callback], customizationCallbacks: [callback],
}); });
}); });
@ -53,31 +55,80 @@ describe('createProfileRegistry', () => {
it('should be case insensitive', () => { it('should be case insensitive', () => {
const registry = createProfileRegistry(); const registry = createProfileRegistry();
registry.set({ registry.set({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
expect(registry.get('tEsT')).toEqual({ expect(registry.get('tEsT')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [], customizationCallbacks: [],
}); });
}); });
}); });
describe('createCustomizeFunction', () => { describe('createRegisterCustomizationProfile', () => {
test('should add a customization callback to the registry', () => { test('should add a customization callback to the registry', () => {
const registry = createProfileRegistry(); const registry = createProfileRegistry();
const customize = createCustomizeFunction(registry); const registerCustomizationProfile = createRegisterCustomizationProfile(registry);
const callback = jest.fn(); const callback = jest.fn();
customize('test', callback); registerCustomizationProfile('test', { customize: callback });
expect(registry.get('test')).toEqual({ expect(registry.get('test')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [callback], customizationCallbacks: [callback],
deepLinks: [],
}); });
const callback2 = jest.fn(); const callback2 = jest.fn();
customize('test', callback2); registerCustomizationProfile('test', { customize: callback2 });
expect(registry.get('test')).toEqual({ expect(registry.get('test')).toEqual({
name: 'test', id: 'test',
customizationCallbacks: [callback, callback2], 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. * Side Public License, v 1.
*/ */
import type { CustomizationCallback } from './types'; import type { AppDeepLink, AppUpdater } from '@kbn/core/public';
import { map, Observable, BehaviorSubject } from 'rxjs';
export interface DiscoverProfile { import type { RegisterCustomizationProfile, DiscoverProfile, DiscoverProfileId } from './types';
name: string;
customizationCallbacks: CustomizationCallback[];
}
export interface DiscoverProfileRegistry { export interface DiscoverProfileRegistry {
get(name: string): DiscoverProfile | undefined; get(id: DiscoverProfileId): DiscoverProfile | undefined;
set(profile: DiscoverProfile): void; set(profile: DiscoverProfile): void;
getContributedAppState$: () => Observable<AppUpdater>;
} }
export const createProfileRegistry = (): DiscoverProfileRegistry => { 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 { return {
get: (name) => profiles.get(name.toLowerCase()), get: (id) => profiles.get(id.toLowerCase()),
set: (profile) => profiles.set(profile.name.toLowerCase(), profile), 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 = export const createRegisterCustomizationProfile =
(profileRegistry: DiscoverProfileRegistry) => (profileRegistry: DiscoverProfileRegistry): RegisterCustomizationProfile =>
(profileName: string, callback: CustomizationCallback) => { (id, options) => {
const profile = profileRegistry.get(profileName) ?? { const profile = profileRegistry.get(id) ?? createProfile(id);
name: profileName,
customizationCallbacks: [], const { customize, deepLinks } = options;
};
profile.customizationCallbacks.push(callback); 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); 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. * Side Public License, v 1.
*/ */
import type { AppDeepLink } from '@kbn/core/public';
import type { DiscoverStateContainer } from '../application/main/services/discover_state'; import type { DiscoverStateContainer } from '../application/main/services/discover_state';
import type { DiscoverCustomizationService } from './customization_service'; import type { DiscoverCustomizationService } from './customization_service';
export type DiscoverProfileId = string;
export interface DiscoverProfile {
id: DiscoverProfileId;
customizationCallbacks: CustomizationCallback[];
deepLinks?: AppDeepLink[];
}
export interface CustomizationCallbackContext { export interface CustomizationCallbackContext {
customizations: DiscoverCustomizationService; customizations: DiscoverCustomizationService;
stateContainer: DiscoverStateContainer; stateContainer: DiscoverStateContainer;
} }
export interface DiscoverProfileOptions {
customize: CustomizationCallback;
deepLinks?: AppDeepLink[];
}
export type RegisterCustomizationProfile = (
id: DiscoverProfileId,
options: DiscoverProfileOptions
) => void;
export type CustomizationCallback = ( export type CustomizationCallback = (
options: CustomizationCallbackContext options: CustomizationCallbackContext
) => void | (() => void) | Promise<void | (() => void)>; ) => void | (() => void) | Promise<void | (() => void)>;

View file

@ -16,6 +16,11 @@ export function plugin(initializerContext: PluginInitializerContext) {
export type { ISearchEmbeddable, SearchInput } from './embeddable'; export type { ISearchEmbeddable, SearchInput } from './embeddable';
export type { DiscoverStateContainer } from './application/main/services/discover_state'; 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 { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './embeddable';
export { loadSharingDataHelpers } from './utils'; export { loadSharingDataHelpers } from './utils';

View file

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

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import React from 'react'; import React from 'react';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { import {
AppMountParameters, AppMountParameters,
AppUpdater, AppUpdater,
@ -72,8 +72,11 @@ import {
DiscoverSingleDocLocatorDefinition, DiscoverSingleDocLocatorDefinition,
} from './application/doc/locator'; } from './application/doc/locator';
import { DiscoverAppLocator, DiscoverAppLocatorDefinition } from '../common'; import { DiscoverAppLocator, DiscoverAppLocatorDefinition } from '../common';
import type { CustomizationCallback } from './customizations'; import type { RegisterCustomizationProfile } from './customizations';
import { createCustomizeFunction, createProfileRegistry } from './customizations/profile_registry'; import {
createRegisterCustomizationProfile,
createProfileRegistry,
} from './customizations/profile_registry';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER } from './embeddable/constants'; import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER } from './embeddable/constants';
const DocViewerLegacyTable = React.lazy( const DocViewerLegacyTable = React.lazy(
@ -159,7 +162,7 @@ export interface DiscoverStart {
* ``` * ```
*/ */
readonly locator: undefined | DiscoverAppLocator; readonly locator: undefined | DiscoverAppLocator;
readonly customize: (profileName: string, callback: CustomizationCallback) => void; readonly registerCustomizationProfile: RegisterCustomizationProfile;
} }
/** /**
@ -305,10 +308,23 @@ export class DiscoverPlugin
stopUrlTracker(); stopUrlTracker();
}; };
const appStateUpdater$ = combineLatest([
this.appStateUpdater,
this.profileRegistry.getContributedAppState$(),
]).pipe(
map(
([urlAppStateUpdater, profileAppStateUpdater]): AppUpdater =>
(app) => ({
...urlAppStateUpdater(app),
...profileAppStateUpdater(app),
})
)
);
core.application.register({ core.application.register({
id: PLUGIN_ID, id: PLUGIN_ID,
title: 'Discover', title: 'Discover',
updater$: this.appStateUpdater.asObservable(), updater$: appStateUpdater$,
order: 1000, order: 1000,
euiIconType: 'logoKibana', euiIconType: 'logoKibana',
defaultPath: '#/', defaultPath: '#/',
@ -416,7 +432,7 @@ export class DiscoverPlugin
return { return {
locator: this.locator, 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.alertDetails.uptime.enabled (boolean)',
'xpack.observability.unsafe.thresholdRule.enabled (boolean)', 'xpack.observability.unsafe.thresholdRule.enabled (boolean)',
'xpack.observability_onboarding.ui.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 // 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 // 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.", "description": "This plugin exposes and registers Logs+ features.",
"plugin": { "plugin": {
"id": "discoverLogExplorer", "id": "discoverLogExplorer",
"server": false, "server": true,
"browser": true, "browser": true,
"configPath": ["xpack", "discover_log_explorer"], "configPath": ["xpack", "discoverLogExplorer"],
"requiredPlugins": ["discover", "fleet", "kibanaReact", "kibanaUtils"], "requiredPlugins": ["discover", "fleet", "kibanaReact", "kibanaUtils"],
"optionalPlugins": [], "optionalPlugins": [],
"requiredBundles": [] "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. * 2.0.
*/ */
import { PluginInitializerContext } from '@kbn/core/public';
import { DiscoverLogExplorerConfig } from '../common/plugin_config';
import { DiscoverLogExplorerPlugin } from './plugin'; import { DiscoverLogExplorerPlugin } from './plugin';
export function plugin() { export function plugin(context: PluginInitializerContext<DiscoverLogExplorerConfig>) {
return new DiscoverLogExplorerPlugin(); return new DiscoverLogExplorerPlugin(context);
} }

View file

@ -5,9 +5,11 @@
* 2.0. * 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 { LOG_EXPLORER_PROFILE_ID } from '../common/constants';
import { DiscoverLogExplorerConfig } from '../common/plugin_config';
import { createLogExplorerProfileCustomizations } from './customizations/log_explorer_profile'; import { createLogExplorerProfileCustomizations } from './customizations/log_explorer_profile';
import { getLogExplorerDeepLink } from './deep_links';
import { import {
DiscoverLogExplorerPluginSetup, DiscoverLogExplorerPluginSetup,
DiscoverLogExplorerPluginStart, DiscoverLogExplorerPluginStart,
@ -17,11 +19,20 @@ import {
export class DiscoverLogExplorerPlugin export class DiscoverLogExplorerPlugin
implements Plugin<DiscoverLogExplorerPluginSetup, DiscoverLogExplorerPluginStart> implements Plugin<DiscoverLogExplorerPluginSetup, DiscoverLogExplorerPluginStart>
{ {
private config: DiscoverLogExplorerConfig;
constructor(context: PluginInitializerContext<DiscoverLogExplorerConfig>) {
this.config = context.config.get();
}
public setup() {} public setup() {}
public start(core: CoreStart, plugins: DiscoverLogExplorerStartDeps) { public start(core: CoreStart, plugins: DiscoverLogExplorerStartDeps) {
const { discover } = plugins; 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": { "compilerOptions": {
"outDir": "target/types" "outDir": "target/types"
}, },
"include": ["../../../typings/**/*", "common/**/*", "public/**/*", ".storybook/**/*.tsx"], "include": ["../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*", ".storybook/**/*.tsx"],
"kbn_references": [ "kbn_references": [
"@kbn/core", "@kbn/core",
"@kbn/discover-plugin", "@kbn/discover-plugin",
@ -13,6 +13,7 @@
"@kbn/io-ts-utils", "@kbn/io-ts-utils",
"@kbn/data-views-plugin", "@kbn/data-views-plugin",
"@kbn/rison", "@kbn/rison",
"@kbn/config-schema",
], ],
"exclude": ["target/**/*"] "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', () => { it('only includes deepLinks when search term is non-empty', () => {
const apps = [ const apps = [
createApp({ createApp({

View file

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

View file

@ -31,7 +31,10 @@ const navigationTree: NavigationTreeDefinition = {
id: 'discover-dashboard-alerts-slos', id: 'discover-dashboard-alerts-slos',
children: [ children: [
{ {
link: 'discover', title: i18n.translate('xpack.serverlessObservability.nav.discover', {
defaultMessage: 'Discover',
}),
link: 'discover:log-explorer',
}, },
{ {
title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { 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; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context'; import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) { export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer'); const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common']); const PageObjects = getPageObjects(['common', 'navigationalSearch']);
const testSubjects = getService('testSubjects'); const testSubjects = getService('testSubjects');
describe('Customizations', () => { describe('Customizations', () => {
@ -50,6 +51,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await testSubjects.existOrFail('openInspectorButton'); await testSubjects.existOrFail('openInspectorButton');
await testSubjects.missingOrFail('discoverSaveButton'); 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 () { describe('Applications provider', function () {
it('can search for root-level applications', async () => { it('can search for root-level applications', async () => {
const results = await findResultsWithApi('discover'); const results = await findResultsWithApi('discover');
expect(results.length).to.be(1); expect(results.length).to.be(2);
expect(results[0].title).to.be('Discover'); 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'); await svlCommonNavigation.sidenav.expectSectionClosed('project_settings_project_nav');
// TODO: test something oblt project specific instead of generic discover
// navigate to discover // navigate to discover
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' }); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover:log-explorer' });
await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' }); await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover:log-explorer' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ deepLinkId: 'discover' }); await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({
deepLinkId: 'discover:log-explorer',
});
await expect(await browser.getCurrentUrl()).contain('/app/discover'); await expect(await browser.getCurrentUrl()).contain('/app/discover');
// check the aiops subsection // check the aiops subsection
@ -78,12 +79,11 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
it('navigate using search', async () => { it('navigate using search', async () => {
await svlCommonNavigation.search.showSearch(); await svlCommonNavigation.search.showSearch();
// TODO: test something oblt project specific instead of generic discover await svlCommonNavigation.search.searchFor('discover log explorer');
await svlCommonNavigation.search.searchFor('discover');
await svlCommonNavigation.search.clickOnOption(0); await svlCommonNavigation.search.clickOnOption(0);
await svlCommonNavigation.search.hideSearch(); await svlCommonNavigation.search.hideSearch();
await expect(await browser.getCurrentUrl()).contain('/app/discover'); await expect(await browser.getCurrentUrl()).contain('/app/discover#/p/log-explorer');
}); });
}); });
} }