mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Fleet] Add packages to global search results (#102227)
* added public-side implementation for package search * added a test for the new search results provider and updated behaviour * added comment about open issue regarding hash router in fleet * fixed jest tests * refactor to reduce size of if statement Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
0cfd04c87d
commit
7a08bd8b69
6 changed files with 304 additions and 8 deletions
|
@ -7,8 +7,6 @@
|
|||
A new Kibana plugin exposing an API on both public and server side, to allow consumers to search for various objects and
|
||||
register result providers.
|
||||
|
||||
Note: whether this will be an oss or xpack plugin still depends on https://github.com/elastic/dev/issues/1404.
|
||||
|
||||
# Basic example
|
||||
|
||||
- registering a result provider:
|
||||
|
@ -43,8 +41,7 @@ Kibana should do its best to assist users searching for and navigating to the va
|
|||
|
||||
We should expose an API to make it possible for plugins to search for the various objects present on a Kibana instance.
|
||||
|
||||
The first consumer of this API will be the global search bar [#57576](https://github.com/elastic/kibana/issues/57576). This API
|
||||
should still be generic to answer similar needs from any other consumer, either client or server side.
|
||||
The first consumer of this API will be the global search bar [#57576](https://github.com/elastic/kibana/issues/57576). This API should still be generic to answer similar needs from any other consumer, either client or server side.
|
||||
|
||||
# Detailed design
|
||||
|
||||
|
@ -84,7 +81,7 @@ interface GlobalSearchProviderFindOptions {
|
|||
aborted$: Observable<void>;
|
||||
/**
|
||||
* The total maximum number of results (including all batches / emissions) that should be returned by the provider for a given `find` request.
|
||||
* Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer.
|
||||
* Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer.
|
||||
*/
|
||||
maxResults: number;
|
||||
}
|
||||
|
@ -462,8 +459,8 @@ search(
|
|||
|
||||
Notes:
|
||||
|
||||
- The example implementation is not streaming results from the server, meaning that all results from server-side
|
||||
registered providers will all be fetched and emitted in a single batch. Ideally, we would leverage the `bfetch` plugin
|
||||
- The example implementation is not streaming results from the server, meaning that all results from server-side
|
||||
registered providers will all be fetched and emitted in a single batch. Ideally, we would leverage the `bfetch` plugin
|
||||
to stream the results to the client instead.
|
||||
|
||||
### results sorting
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"ui": true,
|
||||
"configPath": ["xpack", "fleet"],
|
||||
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation"],
|
||||
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home"],
|
||||
"optionalPlugins": ["security", "features", "cloud", "usageCollection", "home", "globalSearch"],
|
||||
"extraPublicDirs": ["common"],
|
||||
"requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"]
|
||||
}
|
||||
|
|
|
@ -36,6 +36,14 @@ export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const sendGetPackages = (query: GetPackagesRequest['query'] = {}) => {
|
||||
return sendRequest<GetPackagesResponse>({
|
||||
path: epmRouteService.getListPath(),
|
||||
method: 'get',
|
||||
query: { experimental: true, ...query },
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetLimitedPackages = () => {
|
||||
return useRequest<GetLimitedPackagesResponse>({
|
||||
path: epmRouteService.getListLimitedPath(),
|
||||
|
|
|
@ -26,6 +26,7 @@ import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'
|
|||
import { Storage } from '../../../../src/plugins/kibana_utils/public';
|
||||
import type { LicensingPluginSetup } from '../../licensing/public';
|
||||
import type { CloudSetup } from '../../cloud/public';
|
||||
import type { GlobalSearchPluginSetup } from '../../global_search/public';
|
||||
import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, setupRouteService, appRoutesService } from '../common';
|
||||
import type { CheckPermissionsResponse, PostIngestSetupResponse } from '../common';
|
||||
|
||||
|
@ -34,6 +35,7 @@ import type { FleetConfigType } from '../common/types';
|
|||
import { FLEET_BASE_PATH } from './constants';
|
||||
import { licenseService } from './hooks';
|
||||
import { setHttpClient } from './hooks/use_request';
|
||||
import { createPackageSearchProvider } from './search_provider';
|
||||
import {
|
||||
TutorialDirectoryNotice,
|
||||
TutorialDirectoryHeaderLink,
|
||||
|
@ -62,6 +64,7 @@ export interface FleetSetupDeps {
|
|||
data: DataPublicPluginSetup;
|
||||
home?: HomePublicPluginSetup;
|
||||
cloud?: CloudSetup;
|
||||
globalSearch?: GlobalSearchPluginSetup;
|
||||
}
|
||||
|
||||
export interface FleetStartDeps {
|
||||
|
@ -192,6 +195,10 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
|
|||
});
|
||||
}
|
||||
|
||||
if (deps.globalSearch) {
|
||||
deps.globalSearch.registerResultProvider(createPackageSearchProvider(core));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
189
x-pack/plugins/fleet/public/search_provider.test.ts
Normal file
189
x-pack/plugins/fleet/public/search_provider.test.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* 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 { TestScheduler } from 'rxjs/testing';
|
||||
import { NEVER } from 'rxjs';
|
||||
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
import { createPackageSearchProvider } from './search_provider';
|
||||
import type { GetPackagesResponse } from './types';
|
||||
|
||||
jest.mock('./hooks/use_request/epm', () => {
|
||||
return {
|
||||
...jest.requireActual('./hooks/use_request/epm'),
|
||||
sendGetPackages: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { sendGetPackages } from './hooks';
|
||||
|
||||
const mockSendGetPackages = sendGetPackages as jest.Mock;
|
||||
|
||||
const testResponse: GetPackagesResponse['response'] = [
|
||||
{
|
||||
description: 'test',
|
||||
download: 'test',
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
path: 'test',
|
||||
release: 'experimental',
|
||||
savedObject: {} as any,
|
||||
status: 'installed',
|
||||
title: 'test',
|
||||
version: 'test',
|
||||
},
|
||||
{
|
||||
description: 'test1',
|
||||
download: 'test1',
|
||||
id: 'test1',
|
||||
name: 'test1',
|
||||
path: 'test1',
|
||||
release: 'ga',
|
||||
status: 'not_installed',
|
||||
title: 'test1',
|
||||
version: 'test1',
|
||||
},
|
||||
];
|
||||
|
||||
const getTestScheduler = () => {
|
||||
return new TestScheduler((actual, expected) => {
|
||||
return expect(actual).toEqual(expected);
|
||||
});
|
||||
};
|
||||
|
||||
describe('Package search provider', () => {
|
||||
let setupMock: ReturnType<typeof coreMock.createSetup>;
|
||||
|
||||
beforeEach(() => {
|
||||
setupMock = coreMock.createSetup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#find', () => {
|
||||
test('returns formatted results', () => {
|
||||
getTestScheduler().run(({ expectObservable, hot }) => {
|
||||
mockSendGetPackages.mockReturnValue(
|
||||
hot('--(a|)', { a: { data: { response: testResponse } } })
|
||||
);
|
||||
setupMock.getStartServices.mockReturnValue(
|
||||
hot('--(a|)', { a: [coreMock.createStart()] }) as any
|
||||
);
|
||||
|
||||
const packageSearchProvider = createPackageSearchProvider(setupMock);
|
||||
|
||||
expectObservable(
|
||||
packageSearchProvider.find(
|
||||
{ term: 'test' },
|
||||
{ aborted$: NEVER, maxResults: 100, preference: '' }
|
||||
)
|
||||
).toBe('--(a|)', {
|
||||
a: [
|
||||
{
|
||||
id: 'test-test',
|
||||
score: 80,
|
||||
title: 'test',
|
||||
type: 'package',
|
||||
url: {
|
||||
path: 'undefined#/detail/test-test/overview',
|
||||
prependBasePath: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'test1-test1',
|
||||
score: 80,
|
||||
title: 'test1',
|
||||
type: 'package',
|
||||
url: {
|
||||
path: 'undefined#/detail/test1-test1/overview',
|
||||
prependBasePath: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(sendGetPackages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls EPR once only', () => {
|
||||
getTestScheduler().run(({ hot }) => {
|
||||
mockSendGetPackages.mockReturnValue(hot('--(a|)', { a: { data: { response: [] } } }));
|
||||
setupMock.getStartServices.mockReturnValue(
|
||||
hot('--(a|)', { a: [coreMock.createStart()] }) as any
|
||||
);
|
||||
const packageSearchProvider = createPackageSearchProvider(setupMock);
|
||||
packageSearchProvider.find(
|
||||
{ term: 'test' },
|
||||
{ aborted$: NEVER, maxResults: 100, preference: '' }
|
||||
);
|
||||
packageSearchProvider.find(
|
||||
{ term: 'test' },
|
||||
{ aborted$: NEVER, maxResults: 100, preference: '' }
|
||||
);
|
||||
});
|
||||
|
||||
expect(sendGetPackages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('completes without returning results if aborted', () => {
|
||||
getTestScheduler().run(({ expectObservable, hot }) => {
|
||||
mockSendGetPackages.mockReturnValue(hot('--(a|)', { a: { data: { response: [] } } }));
|
||||
setupMock.getStartServices.mockReturnValue(
|
||||
hot('--(a|)', { a: [coreMock.createStart()] }) as any
|
||||
);
|
||||
const aborted$ = hot('-a', { a: undefined });
|
||||
const packageSearchProvider = createPackageSearchProvider(setupMock);
|
||||
|
||||
expectObservable(
|
||||
packageSearchProvider.find(
|
||||
{ term: 'test' },
|
||||
{ aborted$, maxResults: 100, preference: '' }
|
||||
)
|
||||
).toBe('-|');
|
||||
});
|
||||
|
||||
expect(sendGetPackages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('respect maximum results', () => {
|
||||
getTestScheduler().run(({ hot, expectObservable }) => {
|
||||
mockSendGetPackages.mockReturnValue(
|
||||
hot('--(a|)', { a: { data: { response: testResponse } } })
|
||||
);
|
||||
setupMock.getStartServices.mockReturnValue(
|
||||
hot('--(a|)', { a: [coreMock.createStart()] }) as any
|
||||
);
|
||||
const packageSearchProvider = createPackageSearchProvider(setupMock);
|
||||
expectObservable(
|
||||
packageSearchProvider.find(
|
||||
{ term: 'test' },
|
||||
{ aborted$: NEVER, maxResults: 1, preference: '' }
|
||||
)
|
||||
).toBe('--(a|)', {
|
||||
a: [
|
||||
{
|
||||
id: 'test-test',
|
||||
score: 80,
|
||||
title: 'test',
|
||||
type: 'package',
|
||||
url: {
|
||||
path: 'undefined#/detail/test-test/overview',
|
||||
prependBasePath: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(sendGetPackages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
95
x-pack/plugins/fleet/public/search_provider.ts
Normal file
95
x-pack/plugins/fleet/public/search_provider.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { CoreSetup, CoreStart } from 'src/core/public';
|
||||
|
||||
import type { Observable } from 'rxjs';
|
||||
import { from, of, combineLatest } from 'rxjs';
|
||||
import { map, shareReplay, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import type {
|
||||
GlobalSearchResultProvider,
|
||||
GlobalSearchProviderResult,
|
||||
} from '../../global_search/public';
|
||||
|
||||
import { INTEGRATIONS_PLUGIN_ID } from '../common';
|
||||
|
||||
import { sendGetPackages } from './hooks';
|
||||
import type { GetPackagesResponse } from './types';
|
||||
import { pagePathGetters } from './constants';
|
||||
|
||||
const packageType = 'package';
|
||||
|
||||
const createPackages$ = () =>
|
||||
from(sendGetPackages()).pipe(
|
||||
map(({ error, data }) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return data?.response ?? [];
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResultProvider => {
|
||||
const coreStart$ = from(core.getStartServices()).pipe(
|
||||
map(([coreStart]) => coreStart),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
let packages$: undefined | Observable<GetPackagesResponse['response']>;
|
||||
|
||||
const getPackages$ = () => {
|
||||
if (!packages$) {
|
||||
packages$ = createPackages$();
|
||||
}
|
||||
return packages$;
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'packages',
|
||||
getSearchableTypes: () => [packageType],
|
||||
find: ({ term }, { maxResults, aborted$ }) => {
|
||||
if (!term) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
term = term.toLowerCase();
|
||||
|
||||
const toSearchResults = (
|
||||
coreStart: CoreStart,
|
||||
packagesResponse: GetPackagesResponse['response']
|
||||
): GlobalSearchProviderResult[] => {
|
||||
const packages = packagesResponse.slice(0, maxResults);
|
||||
|
||||
return packages.flatMap((pkg) => {
|
||||
if (!term || !pkg.title.toLowerCase().includes(term)) {
|
||||
return [];
|
||||
}
|
||||
const pkgkey = `${pkg.name}-${pkg.version}`;
|
||||
return {
|
||||
id: pkgkey,
|
||||
type: packageType,
|
||||
title: pkg.title,
|
||||
score: 80,
|
||||
url: {
|
||||
// TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated
|
||||
// as part of migrating to non-hash based router.
|
||||
// prettier-ignore
|
||||
path: `${coreStart.application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`,
|
||||
prependBasePath: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return combineLatest([coreStart$, getPackages$()]).pipe(
|
||||
takeUntil(aborted$),
|
||||
map(([coreStart, data]) => (data ? toSearchResults(coreStart, data) : []))
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue