mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
d19a295d38
commit
9bae853586
28 changed files with 535 additions and 219 deletions
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 &
|
||||
(
|
||||
| {
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -15,5 +15,7 @@
|
|||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
"kbn_references": [
|
||||
"@kbn/deeplinks-analytics",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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)>;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -25,7 +25,7 @@ const createSetupContract = (): Setup => {
|
|||
const createStartContract = (): Start => {
|
||||
const startContract: Start = {
|
||||
locator: sharePluginMock.createLocator(),
|
||||
customize: jest.fn(),
|
||||
registerCustomizationProfile: jest.fn(),
|
||||
};
|
||||
return startContract;
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
12
x-pack/plugins/discover_log_explorer/common/plugin_config.ts
Normal file
12
x-pack/plugins/discover_log_explorer/common/plugin_config.ts
Normal 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;
|
||||
};
|
||||
}
|
|
@ -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": []
|
||||
|
|
21
x-pack/plugins/discover_log_explorer/public/deep_links.ts
Normal file
21
x-pack/plugins/discover_log_explorer/public/deep_links.ts
Normal 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,
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 })],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
34
x-pack/plugins/discover_log_explorer/server/config.ts
Normal file
34
x-pack/plugins/discover_log_explorer/server/config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
12
x-pack/plugins/discover_log_explorer/server/index.ts
Normal file
12
x-pack/plugins/discover_log_explorer/server/index.ts
Normal 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();
|
14
x-pack/plugins/discover_log_explorer/server/plugin.ts
Normal file
14
x-pack/plugins/discover_log_explorer/server/plugin.ts
Normal 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() {}
|
||||
}
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue