From c187270b5e2d90c306a55694c53614e149422d35 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Fri, 16 Apr 2021 19:59:23 +0300 Subject: [PATCH 01/17] [Search Sessions] Client side search cache (#92439) * dev docs * sessions tutorial * title * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update dev_docs/tutorials/data/search.mdx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Code review * client cache * mock utils * improve code * Use cacheOnClient in Lens * mock * docs and types * unit tests! * Search response cache + tests * remove cacheOnClient evict cache on error * test ts * shouldCacheOnClient + improve tests * remove unused * clear subs * dont unsubscribe on setItem * caching mess * t * fix jest * add size to bfetch response @ppisljar use it to reduce the # of stringify in response cache * ts * ts * docs * simplify abort controller logic and extract it into a class * docs * delete unused tests * use addAbortSignal * code review * Use shareReplay, fix tests * code review * bfetch test * code review * Leave the bfetch changes out * docs + isRestore * make sure to clean up properly * Make sure that aborting in cache works correctly Clearer restructuring of code * fix test * import * code review round 1 * ts * Added functional test for search request caching * test * skip before codefreeze Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...earchinterceptor.getserializableoptions.md | 22 + ...n-plugins-data-public.searchinterceptor.md | 1 + examples/search_examples/common/index.ts | 1 + .../search_examples/public/search/app.tsx | 67 ++- .../search_examples/server/my_strategy.ts | 1 + .../data/common/search/tabify/tabify.ts | 2 +- src/plugins/data/public/public.api.md | 2 + .../data/public/search/search_interceptor.ts | 28 +- .../public/search/session/session_service.ts | 2 +- .../search/search_abort_controller.test.ts | 22 +- .../public/search/search_abort_controller.ts | 7 +- .../public/search/search_interceptor.test.ts | 557 +++++++++++++++++- .../public/search/search_interceptor.ts | 150 ++++- .../search/search_response_cache.test.ts | 318 ++++++++++ .../public/search/search_response_cache.ts | 136 +++++ .../data_enhanced/public/search/utils.ts | 15 + x-pack/plugins/lens/public/app_plugin/app.tsx | 13 +- x-pack/test/examples/search_examples/index.ts | 1 + .../search_examples/search_sessions_cache.ts | 65 ++ 19 files changed, 1350 insertions(+), 60 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md create mode 100644 x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts create mode 100644 x-pack/plugins/data_enhanced/public/search/search_response_cache.ts create mode 100644 x-pack/plugins/data_enhanced/public/search/utils.ts create mode 100644 x-pack/test/examples/search_examples/search_sessions_cache.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md new file mode 100644 index 00000000000000..984f99004ebe82 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getSerializableOptions](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) + +## SearchInterceptor.getSerializableOptions() method + +Signature: + +```typescript +protected getSerializableOptions(options?: ISearchOptions): Pick; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | ISearchOptions | | + +Returns: + +`Pick` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 9d18309fc07be1..653f052dd5a3a8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -26,6 +26,7 @@ export declare class SearchInterceptor | Method | Modifiers | Description | | --- | --- | --- | +| [getSerializableOptions(options)](./kibana-plugin-plugins-data-public.searchinterceptor.getserializableoptions.md) | | | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, options, isTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | | [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | diff --git a/examples/search_examples/common/index.ts b/examples/search_examples/common/index.ts index dd953b1ec8982a..cc47c0f5759739 100644 --- a/examples/search_examples/common/index.ts +++ b/examples/search_examples/common/index.ts @@ -16,6 +16,7 @@ export interface IMyStrategyRequest extends IEsSearchRequest { } export interface IMyStrategyResponse extends IEsSearchResponse { cool: string; + executed_at: number; } export const SERVER_SEARCH_ROUTE_PATH = '/api/examples/search'; diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 3bac445581ae76..8f31d242faf5ea 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -111,7 +111,7 @@ export const SearchExamplesApp = ({ setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); }, [fields]); - const doAsyncSearch = async (strategy?: string) => { + const doAsyncSearch = async (strategy?: string, sessionId?: string) => { if (!indexPattern || !selectedNumericField) return; // Construct the query portion of the search request @@ -138,6 +138,7 @@ export const SearchExamplesApp = ({ const searchSubscription$ = data.search .search(req, { strategy, + sessionId, }) .subscribe({ next: (res) => { @@ -148,19 +149,30 @@ export const SearchExamplesApp = ({ ? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response res.rawResponse.aggregations[1].value : undefined; + const isCool = (res as IMyStrategyResponse).cool; + const executedAt = (res as IMyStrategyResponse).executed_at; const message = ( Searched {res.rawResponse.hits.total} documents.
The average of {selectedNumericField!.name} is{' '} {avgResult ? Math.floor(avgResult) : 0}.
- Is it Cool? {String((res as IMyStrategyResponse).cool)} + {isCool ? `Is it Cool? ${isCool}` : undefined} +
+ + {executedAt ? `Executed at? ${executedAt}` : undefined} +
); - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); + notifications.toasts.addSuccess( + { + title: 'Query result', + text: mountReactNode(message), + }, + { + toastLifeTimeMs: 300000, + } + ); searchSubscription$.unsubscribe(); } else if (isErrorResponse(res)) { // TODO: Make response error status clearer @@ -227,6 +239,10 @@ export const SearchExamplesApp = ({ doAsyncSearch('myStrategy'); }; + const onClientSideSessionCacheClickHandler = () => { + doAsyncSearch('myStrategy', data.search.session.getSessionId()); + }; + const onServerClickHandler = async () => { if (!indexPattern || !selectedNumericField) return; try { @@ -374,6 +390,45 @@ export const SearchExamplesApp = ({ + +

Client side search session caching

+
+ + data.search.session.start()} + iconType="alert" + data-test-subj="searchExamplesStartSession" + > + + + data.search.session.clear()} + iconType="alert" + data-test-subj="searchExamplesClearSession" + > + + + + + + +

Using search on the server

diff --git a/examples/search_examples/server/my_strategy.ts b/examples/search_examples/server/my_strategy.ts index 2cf039e99f6e99..0a647889600912 100644 --- a/examples/search_examples/server/my_strategy.ts +++ b/examples/search_examples/server/my_strategy.ts @@ -20,6 +20,7 @@ export const mySearchStrategyProvider = ( map((esSearchRes) => ({ ...esSearchRes, cool: request.get_cool ? 'YES' : 'NOPE', + executed_at: new Date().getTime(), })) ), cancel: async (id, options, deps) => { diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 9f096886491ad1..4a8972d4384c23 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -139,7 +139,7 @@ export function tabifyAggResponse( const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {}); const topLevelBucket: AggResponseBucket = { ...esResponse.aggregations, - doc_count: esResponse.hits.total, + doc_count: esResponse.hits?.total, }; collectBucket(aggConfigs, write, topLevelBucket, '', 1); diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index d99d754a3364db..35f13fc855e998 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2353,6 +2353,8 @@ export class SearchInterceptor { // (undocumented) protected readonly deps: SearchInterceptorDeps; // (undocumented) + protected getSerializableOptions(options?: ISearchOptions): Pick; + // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3df2313f837982..e3fb31c9179fd6 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -113,20 +113,14 @@ export class SearchInterceptor { } } - /** - * @internal - * @throws `AbortError` | `ErrorLike` - */ - protected runSearch( - request: IKibanaSearchRequest, - options?: ISearchOptions - ): Promise { - const { abortSignal, sessionId, ...requestOptions } = options || {}; + protected getSerializableOptions(options?: ISearchOptions) { + const { sessionId, ...requestOptions } = options || {}; + + const serializableOptions: ISearchOptionsSerializable = {}; const combined = { ...requestOptions, ...this.deps.session.getSearchOptions(sessionId), }; - const serializableOptions: ISearchOptionsSerializable = {}; if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId; if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore; @@ -135,10 +129,22 @@ export class SearchInterceptor { if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy; if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored; + return serializableOptions; + } + + /** + * @internal + * @throws `AbortError` | `ErrorLike` + */ + protected runSearch( + request: IKibanaSearchRequest, + options?: ISearchOptions + ): Promise { + const { abortSignal } = options || {}; return this.batchedFetch( { request, - options: serializableOptions, + options: this.getSerializableOptions(options), }, abortSignal ); diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 381410574ecda3..71f51b4bc8d83c 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -73,7 +73,7 @@ export interface SearchSessionIndicatorUiConfig { } /** - * Responsible for tracking a current search session. Supports only a single session at a time. + * Responsible for tracking a current search session. Supports a single session at a time. */ export class SessionService { public readonly state$: Observable; diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts index 68282c1e947f70..a52fdef9819b82 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.test.ts @@ -21,13 +21,15 @@ describe('search abort controller', () => { test('immediately aborts when passed an aborted signal in the constructor', () => { const controller = new AbortController(); controller.abort(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(true); }); test('aborts when input signal is aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); controller.abort(); expect(sac.getSignal().aborted).toBe(true); @@ -35,7 +37,8 @@ describe('search abort controller', () => { test('aborts when all input signals are aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); const controller2 = new AbortController(); sac.addAbortSignal(controller2.signal); @@ -48,7 +51,8 @@ describe('search abort controller', () => { test('aborts explicitly even if all inputs are not aborted', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); const controller2 = new AbortController(); sac.addAbortSignal(controller2.signal); @@ -60,7 +64,8 @@ describe('search abort controller', () => { test('doesnt abort, if cleared', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal); + const sac = new SearchAbortController(); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); sac.cleanup(); controller.abort(); @@ -77,7 +82,7 @@ describe('search abort controller', () => { }); test('doesnt abort on timeout, if cleared', () => { - const sac = new SearchAbortController(undefined, 100); + const sac = new SearchAbortController(100); expect(sac.getSignal().aborted).toBe(false); sac.cleanup(); timeTravel(100); @@ -85,7 +90,7 @@ describe('search abort controller', () => { }); test('aborts on timeout, even if no signals passed in', () => { - const sac = new SearchAbortController(undefined, 100); + const sac = new SearchAbortController(100); expect(sac.getSignal().aborted).toBe(false); timeTravel(100); expect(sac.getSignal().aborted).toBe(true); @@ -94,7 +99,8 @@ describe('search abort controller', () => { test('aborts on timeout, even if there are unaborted signals', () => { const controller = new AbortController(); - const sac = new SearchAbortController(controller.signal, 100); + const sac = new SearchAbortController(100); + sac.addAbortSignal(controller.signal); expect(sac.getSignal().aborted).toBe(false); timeTravel(100); diff --git a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts index 4482a7771dc285..7bc74b56a39030 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_abort_controller.ts @@ -18,11 +18,7 @@ export class SearchAbortController { private destroyed = false; private reason?: AbortReason; - constructor(abortSignal?: AbortSignal, timeout?: number) { - if (abortSignal) { - this.addAbortSignal(abortSignal); - } - + constructor(timeout?: number) { if (timeout) { this.timeoutSub = timer(timeout).subscribe(() => { this.reason = AbortReason.Timeout; @@ -41,6 +37,7 @@ export class SearchAbortController { }; public cleanup() { + if (this.destroyed) return; this.destroyed = true; this.timeoutSub?.unsubscribe(); this.inputAbortSignals.forEach((abortSignal) => { diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 02671974e50536..0e511c545f3e28 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -23,9 +23,12 @@ import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks import { BehaviorSubject } from 'rxjs'; import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; -const timeTravel = (msToRun = 0) => { +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +const timeTravel = async (msToRun = 0) => { + await flushPromises(); jest.advanceTimersByTime(msToRun); - return new Promise((resolve) => setImmediate(resolve)); + return flushPromises(); }; const next = jest.fn(); @@ -39,10 +42,20 @@ let fetchMock: jest.Mock; jest.useFakeTimers(); +jest.mock('./utils', () => ({ + createRequestHash: jest.fn().mockImplementation((input) => { + return Promise.resolve(JSON.stringify(input)); + }), +})); + function mockFetchImplementation(responses: any[]) { let i = 0; - fetchMock.mockImplementation(() => { + fetchMock.mockImplementation((r) => { + if (!r.request.id) i = 0; const { time = 0, value = {}, isError = false } = responses[i++]; + value.meta = { + size: 10, + }; return new Promise((resolve, reject) => setTimeout(() => { return (isError ? reject : resolve)(value); @@ -452,7 +465,7 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('session', () => { + describe('session tracking', () => { beforeEach(() => { const responses = [ { @@ -559,4 +572,540 @@ describe('EnhancedSearchInterceptor', () => { expect(sessionService.trackSearch).toBeCalledTimes(0); }); }); + + describe('session client caching', () => { + const sessionId = 'sessionId'; + const basicReq = { + params: { + test: 1, + }, + }; + + const basicCompleteResponse = [ + { + time: 10, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + const partialCompleteResponse = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + { + time: 20, + value: { + isPartial: false, + isRunning: false, + id: 1, + rawResponse: { + took: 1, + }, + }, + }, + ]; + + beforeEach(() => { + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + }); + + test('should be disabled if there is no session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, {}).subscribe({ next, error, complete }); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch different requests in a single session', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req2 = { + params: { + test: 2, + }, + }; + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should fetch the same request for two different sessions', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor + .search(basicReq, { sessionId: 'anotherSession' }) + .subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should track searches that come from cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).not.toBeCalled(); + await timeTravel(300); + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(4); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(2); + }); + + test('should cache partial responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: true, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should not cache error responses', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(2); + }); + + test('should deliver error to all replays', async () => { + const responses = [ + { + time: 10, + value: { + isPartial: true, + isRunning: false, + id: 1, + }, + }, + ]; + + mockFetchImplementation(responses); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(error).toBeCalledTimes(2); + expect(error.mock.calls[0][0].message).toEqual('Received partial response'); + expect(error.mock.calls[1][0].message).toEqual('Received partial response'); + }); + + test('should ignore anything outside params when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + something: 123, + params: { + test: 1, + }, + }; + + const req2 = { + something: 321, + params: { + test: 1, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should ignore preference when hashing', async () => { + mockFetchImplementation(basicCompleteResponse); + + const req = { + params: { + test: 1, + preference: 123, + }, + }; + + const req2 = { + params: { + test: 1, + preference: 321, + }, + }; + + searchInterceptor.search(req, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(req2, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('should return from cache for identical requests in the same session', async () => { + mockFetchImplementation(basicCompleteResponse); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + }); + + test('aborting a search that didnt get any response should retrigger search', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Abort the search request before it started + abortController.abort(); + + // Time travel to make sure nothing appens + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(0); + expect(next).toBeCalledTimes(0); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + const error2 = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + + // Search for the same thing again + searchInterceptor + .search(basicReq, { sessionId }) + .subscribe({ next: next2, error: error2, complete: complete2 }); + + // Should search again + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + }); + + test('aborting a running first search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + // Both searches should be tracked and untracked + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + // First search should error + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + + // Second search should complete + expect(next2).toBeCalledTimes(2); + expect(error2).toBeCalledTimes(0); + expect(complete2).toBeCalledTimes(1); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting a running second search shouldnt clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + + expect(fetchMock).toBeCalledTimes(1); + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(0); + expect(sessionService.trackSearch).toBeCalledTimes(1); + expect(untrack).not.toBeCalled(); + + const next2 = jest.fn(); + const error2 = jest.fn(); + const complete2 = jest.fn(); + const response2 = searchInterceptor.search(req, { + pollInterval: 0, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next: next2, error: error2, complete: complete2 }); + await timeTravel(0); + + abortController.abort(); + + await timeTravel(300); + expect(sessionService.trackSearch).toBeCalledTimes(2); + expect(untrack).toBeCalledTimes(2); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(0); + expect(complete).toBeCalledTimes(1); + + expect(next2).toBeCalledTimes(1); + expect(error2).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(0); + + // Should be called only 2 times (once per partial response) + expect(fetchMock).toBeCalledTimes(2); + }); + + test('aborting both requests should cancel underlaying search only once', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + sessionService.trackSearch.mockImplementation(() => jest.fn()); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(10); + + abortController.abort(); + + await timeTravel(300); + + expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); + }); + + test('aborting both searches should stop searching and clear cache', async () => { + mockFetchImplementation(partialCompleteResponse); + sessionService.isCurrentSession.mockImplementation((_sessionId) => _sessionId === sessionId); + sessionService.getSessionId.mockImplementation(() => sessionId); + + const untrack = jest.fn(); + sessionService.trackSearch.mockImplementation(() => untrack); + + const req = { + params: { + test: 200, + }, + }; + + const abortController = new AbortController(); + + const response = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response.subscribe({ next, error, complete }); + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + const response2 = searchInterceptor.search(req, { + pollInterval: 1, + sessionId, + abortSignal: abortController.signal, + }); + response2.subscribe({ next, error, complete }); + await timeTravel(0); + expect(fetchMock).toBeCalledTimes(1); + + abortController.abort(); + + await timeTravel(300); + + expect(next).toBeCalledTimes(2); + expect(error).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(0); + expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); + expect(error.mock.calls[1][0]).toBeInstanceOf(AbortError); + + // Should be called only 1 times (one partial response) + expect(fetchMock).toBeCalledTimes(1); + + // Clear mock and research + fetchMock.mockReset(); + mockFetchImplementation(partialCompleteResponse); + // Run the search again to see that we don't hit the cache + const response3 = searchInterceptor.search(req, { pollInterval: 1, sessionId }); + response3.subscribe({ next, error, complete }); + + await timeTravel(10); + await timeTravel(10); + await timeTravel(300); + + // Should be called 2 times (two partial response) + expect(fetchMock).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + }); + + test('aborting a completed search shouldnt effect cache', async () => { + mockFetchImplementation(basicCompleteResponse); + + const abortController = new AbortController(); + + // Start a search request + searchInterceptor + .search(basicReq, { sessionId, abortSignal: abortController.signal }) + .subscribe({ next, error, complete }); + + // Get a final response + await timeTravel(10); + expect(fetchMock).toBeCalledTimes(1); + + // Abort the search request + abortController.abort(); + + // Search for the same thing again + searchInterceptor.search(basicReq, { sessionId }).subscribe({ next, error, complete }); + + // Get the response from cache + expect(fetchMock).toBeCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index b9d8553d3dc5a8..3e7564933a0c6b 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -6,8 +6,19 @@ */ import { once } from 'lodash'; -import { throwError, Subscription } from 'rxjs'; -import { tap, finalize, catchError, filter, take, skip } from 'rxjs/operators'; +import { throwError, Subscription, from, of, fromEvent, EMPTY } from 'rxjs'; +import { + tap, + finalize, + catchError, + filter, + take, + skip, + switchMap, + shareReplay, + map, + takeUntil, +} from 'rxjs/operators'; import { TimeoutErrorMode, SearchInterceptor, @@ -16,12 +27,21 @@ import { IKibanaSearchRequest, SearchSessionState, } from '../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common'; +import { SearchResponseCache } from './search_response_cache'; +import { createRequestHash } from './utils'; import { SearchAbortController } from './search_abort_controller'; +const MAX_CACHE_ITEMS = 50; +const MAX_CACHE_SIZE_MB = 10; export class EnhancedSearchInterceptor extends SearchInterceptor { private uiSettingsSub: Subscription; private searchTimeout: number; + private readonly responseCache: SearchResponseCache = new SearchResponseCache( + MAX_CACHE_ITEMS, + MAX_CACHE_SIZE_MB + ); /** * @internal @@ -38,6 +58,7 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { } public stop() { + this.responseCache.clear(); this.uiSettingsSub.unsubscribe(); } @@ -47,19 +68,31 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { - const searchOptions = { - strategy: ENHANCED_ES_SEARCH_STRATEGY, - ...options, + private createRequestHash$(request: IKibanaSearchRequest, options: IAsyncSearchOptions) { + const { sessionId, isRestore } = options; + // Preference is used to ensure all queries go to the same set of shards and it doesn't need to be hashed + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-shard-routing.html#shard-and-node-preference + const { preference, ...params } = request.params || {}; + const hashOptions = { + ...params, + sessionId, + isRestore, }; - const { sessionId, strategy, abortSignal } = searchOptions; - const search = () => this.runSearch({ id, ...request }, searchOptions); - const searchAbortController = new SearchAbortController(abortSignal, this.searchTimeout); - this.pendingCount$.next(this.pendingCount$.getValue() + 1); - const untrackSearch = this.deps.session.isCurrentSession(options.sessionId) - ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) - : undefined; + return from(sessionId ? createRequestHash(hashOptions) : of(undefined)); + } + + /** + * @internal + * Creates a new pollSearch that share replays its results + */ + private runSearch$( + { id, ...request }: IKibanaSearchRequest, + options: IAsyncSearchOptions, + searchAbortController: SearchAbortController + ) { + const search = () => this.runSearch({ id, ...request }, options); + const { sessionId, strategy } = options; // track if this search's session will be send to background // if yes, then we don't need to cancel this search when it is aborted @@ -91,18 +124,97 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { tap((response) => (id = response.id)), catchError((e: Error) => { cancel(); - return throwError(this.handleSearchError(e, options, searchAbortController.isTimeout())); + return throwError(e); }), finalize(() => { - this.pendingCount$.next(this.pendingCount$.getValue() - 1); searchAbortController.cleanup(); - if (untrackSearch && this.deps.session.isCurrentSession(options.sessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); - } if (savedToBackgroundSub) { savedToBackgroundSub.unsubscribe(); } + }), + // This observable is cached in the responseCache. + // Using shareReplay makes sure that future subscribers will get the final response + + shareReplay(1) + ); + } + + /** + * @internal + * Creates a new search observable and a corresponding search abort controller + * If requestHash is defined, tries to return them first from cache. + */ + private getSearchResponse$( + request: IKibanaSearchRequest, + options: IAsyncSearchOptions, + requestHash?: string + ) { + const cached = requestHash ? this.responseCache.get(requestHash) : undefined; + + const searchAbortController = + cached?.searchAbortController || new SearchAbortController(this.searchTimeout); + + // Create a new abort signal if one was not passed. This fake signal will never be aborted, + // So the underlaying search will not be aborted, even if the other consumers abort. + searchAbortController.addAbortSignal(options.abortSignal ?? new AbortController().signal); + const response$ = cached?.response$ || this.runSearch$(request, options, searchAbortController); + + if (requestHash && !this.responseCache.has(requestHash)) { + this.responseCache.set(requestHash, { + response$, + searchAbortController, + }); + } + + return { + response$, + searchAbortController, + }; + } + + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { + const searchOptions = { + strategy: ENHANCED_ES_SEARCH_STRATEGY, + ...options, + }; + const { sessionId, abortSignal } = searchOptions; + + return this.createRequestHash$(request, searchOptions).pipe( + switchMap((requestHash) => { + const { searchAbortController, response$ } = this.getSearchResponse$( + request, + searchOptions, + requestHash + ); + + this.pendingCount$.next(this.pendingCount$.getValue() + 1); + const untrackSearch = this.deps.session.isCurrentSession(sessionId) + ? this.deps.session.trackSearch({ abort: () => searchAbortController.abort() }) + : undefined; + + // Abort the replay if the abortSignal is aborted. + // The underlaying search will not abort unless searchAbortController fires. + const aborted$ = (abortSignal ? fromEvent(abortSignal, 'abort') : EMPTY).pipe( + map(() => { + throw new AbortError(); + }) + ); + + return response$.pipe( + takeUntil(aborted$), + catchError((e) => { + return throwError( + this.handleSearchError(e, searchOptions, searchAbortController.isTimeout()) + ); + }), + finalize(() => { + this.pendingCount$.next(this.pendingCount$.getValue() - 1); + if (untrackSearch && this.deps.session.isCurrentSession(sessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + }) + ); }) ); } diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts new file mode 100644 index 00000000000000..e985de5e23f7d1 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.test.ts @@ -0,0 +1,318 @@ +/* + * 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 { interval, Observable, of, throwError } from 'rxjs'; +import { shareReplay, switchMap, take } from 'rxjs/operators'; +import { IKibanaSearchResponse } from 'src/plugins/data/public'; +import { SearchAbortController } from './search_abort_controller'; +import { SearchResponseCache } from './search_response_cache'; + +describe('SearchResponseCache', () => { + let cache: SearchResponseCache; + let searchAbortController: SearchAbortController; + const r: Array> = [ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 1, + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 2, + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 3, + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 4, + }, + }, + ]; + + function getSearchObservable$(responses: Array> = r) { + return interval(100).pipe( + take(responses.length), + switchMap((value: number, i: number) => { + if (responses[i].rawResponse.throw === true) { + return throwError('nooo'); + } else { + return of(responses[i]); + } + }), + shareReplay(1) + ); + } + + function wrapWithAbortController(response$: Observable>) { + return { + response$, + searchAbortController, + }; + } + + beforeEach(() => { + cache = new SearchResponseCache(3, 0.1); + searchAbortController = new SearchAbortController(); + }); + + describe('Cache eviction', () => { + test('clear evicts all', () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + + cache.clear(); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).toBeUndefined(); + }); + + test('evicts searches that threw an exception', async () => { + const res$ = getSearchObservable$(); + const err$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(1000), + }, + }, + { + isPartial: true, + isRunning: true, + rawResponse: { + throw: true, + }, + }, + ]); + cache.set('123', wrapWithAbortController(err$)); + cache.set('234', wrapWithAbortController(res$)); + + const errHandler = jest.fn(); + await err$.toPromise().catch(errHandler); + await res$.toPromise().catch(errHandler); + + expect(errHandler).toBeCalledTimes(1); + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + }); + + test('evicts searches that returned an error response', async () => { + const err$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 1, + }, + }, + { + isPartial: true, + isRunning: false, + rawResponse: { + t: 2, + }, + }, + ]); + cache.set('123', wrapWithAbortController(err$)); + + const errHandler = jest.fn(); + await err$.toPromise().catch(errHandler); + + expect(errHandler).toBeCalledTimes(0); + expect(cache.get('123')).toBeUndefined(); + }); + + test('evicts oldest item if has too many cached items', async () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + cache.set('345', wrapWithAbortController(of(finalResult))); + cache.set('456', wrapWithAbortController(of(finalResult))); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).not.toBeUndefined(); + expect(cache.get('456')).not.toBeUndefined(); + }); + + test('evicts oldest item if cache gets bigger than max size', async () => { + const largeResult$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(1000), + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 'a'.repeat(50000), + }, + }, + ]); + + cache.set('123', wrapWithAbortController(largeResult$)); + cache.set('234', wrapWithAbortController(largeResult$)); + cache.set('345', wrapWithAbortController(largeResult$)); + + await largeResult$.toPromise(); + + expect(cache.get('123')).toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).not.toBeUndefined(); + }); + + test('evicts from cache any single item that gets bigger than max size', async () => { + const largeResult$ = getSearchObservable$([ + { + isPartial: true, + isRunning: true, + rawResponse: { + t: 'a'.repeat(500), + }, + }, + { + isPartial: false, + isRunning: false, + rawResponse: { + t: 'a'.repeat(500000), + }, + }, + ]); + + cache.set('234', wrapWithAbortController(largeResult$)); + await largeResult$.toPromise(); + expect(cache.get('234')).toBeUndefined(); + }); + + test('get updates the insertion time of an item', async () => { + const finalResult = r[r.length - 1]; + cache.set('123', wrapWithAbortController(of(finalResult))); + cache.set('234', wrapWithAbortController(of(finalResult))); + cache.set('345', wrapWithAbortController(of(finalResult))); + + cache.get('123'); + cache.get('234'); + + cache.set('456', wrapWithAbortController(of(finalResult))); + + expect(cache.get('123')).not.toBeUndefined(); + expect(cache.get('234')).not.toBeUndefined(); + expect(cache.get('345')).toBeUndefined(); + expect(cache.get('456')).not.toBeUndefined(); + }); + }); + + describe('Observable behavior', () => { + test('caches a response and re-emits it', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + const finalRes = await cache.get('123')!.response$.toPromise(); + expect(finalRes).toStrictEqual(r[r.length - 1]); + }); + + test('cached$ should emit same as original search$', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + const next = jest.fn(); + const cached$ = cache.get('123'); + + cached$!.response$.subscribe({ + next, + }); + + // wait for original search to complete + await s$!.toPromise(); + + // get final response from cached$ + const finalRes = await cached$!.response$.toPromise(); + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(4); + }); + + test('cached$ should emit only current value and keep emitting if subscribed while search$ is running', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + const next = jest.fn(); + let cached$: Observable> | undefined; + s$.subscribe({ + next: (res) => { + if (res.rawResponse.t === 3) { + cached$ = cache.get('123')!.response$; + cached$!.subscribe({ + next, + }); + } + }, + }); + + // wait for original search to complete + await s$!.toPromise(); + + const finalRes = await cached$!.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(2); + }); + + test('cached$ should emit only last value if subscribed after search$ was complete 1', async () => { + const finalResult = r[r.length - 1]; + const s$ = wrapWithAbortController(of(finalResult)); + cache.set('123', s$); + + // wait for original search to complete + await s$!.response$.toPromise(); + + const next = jest.fn(); + const cached$ = cache.get('123'); + cached$!.response$.subscribe({ + next, + }); + + const finalRes = await cached$!.response$.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('cached$ should emit only last value if subscribed after search$ was complete', async () => { + const s$ = getSearchObservable$(); + cache.set('123', wrapWithAbortController(s$)); + + // wait for original search to complete + await s$!.toPromise(); + + const next = jest.fn(); + const cached$ = cache.get('123'); + cached$!.response$.subscribe({ + next, + }); + + const finalRes = await cached$!.response$.toPromise(); + + expect(finalRes).toStrictEqual(r[r.length - 1]); + expect(next).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts new file mode 100644 index 00000000000000..1467e5bf234ffc --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_response_cache.ts @@ -0,0 +1,136 @@ +/* + * 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 { Observable, Subscription } from 'rxjs'; +import { IKibanaSearchResponse, isErrorResponse } from '../../../../../src/plugins/data/public'; +import { SearchAbortController } from './search_abort_controller'; + +interface ResponseCacheItem { + response$: Observable>; + searchAbortController: SearchAbortController; +} + +interface ResponseCacheItemInternal { + response$: Observable>; + searchAbortController: SearchAbortController; + size: number; + subs: Subscription; +} + +export class SearchResponseCache { + private responseCache: Map; + private cacheSize = 0; + + constructor(private maxItems: number, private maxCacheSizeMB: number) { + this.responseCache = new Map(); + } + + private byteToMb(size: number) { + return size / (1024 * 1024); + } + + private deleteItem(key: string, clearSubs = true) { + const item = this.responseCache.get(key); + if (item) { + if (clearSubs) { + item.subs.unsubscribe(); + } + this.cacheSize -= item.size; + this.responseCache.delete(key); + } + } + + private setItem(key: string, item: ResponseCacheItemInternal) { + // The deletion of the key will move it to the end of the Map's entries. + this.deleteItem(key, false); + this.cacheSize += item.size; + this.responseCache.set(key, item); + } + + public clear() { + this.cacheSize = 0; + this.responseCache.forEach((item) => { + item.subs.unsubscribe(); + }); + this.responseCache.clear(); + } + + private shrink() { + while ( + this.responseCache.size > this.maxItems || + this.byteToMb(this.cacheSize) > this.maxCacheSizeMB + ) { + const [key] = [...this.responseCache.keys()]; + this.deleteItem(key); + } + } + + public has(key: string) { + return this.responseCache.has(key); + } + + /** + * + * @param key key to cache + * @param response$ + * @returns A ReplaySubject that mimics the behavior of the original observable + * @throws error if key already exists + */ + public set(key: string, item: ResponseCacheItem) { + if (this.responseCache.has(key)) { + throw new Error('duplicate key'); + } + + const { response$, searchAbortController } = item; + + const cacheItem: ResponseCacheItemInternal = { + response$, + searchAbortController, + subs: new Subscription(), + size: 0, + }; + + this.setItem(key, cacheItem); + + cacheItem.subs.add( + response$.subscribe({ + next: (r) => { + // TODO: avoid stringiying. Get the size some other way! + const newSize = new Blob([JSON.stringify(r)]).size; + if (this.byteToMb(newSize) < this.maxCacheSizeMB && !isErrorResponse(r)) { + this.setItem(key, { + ...cacheItem, + size: newSize, + }); + this.shrink(); + } else { + // Single item is too large to be cached, or an error response returned. + // Evict and ignore. + this.deleteItem(key); + } + }, + error: (e) => { + // Evict item on error + this.deleteItem(key); + }, + }) + ); + this.shrink(); + } + + public get(key: string): ResponseCacheItem | undefined { + const item = this.responseCache.get(key); + if (item) { + // touch the item, and move it to the end of the map's entries + this.setItem(key, item); + return { + response$: item.response$, + searchAbortController: item.searchAbortController, + }; + } + } +} diff --git a/x-pack/plugins/data_enhanced/public/search/utils.ts b/x-pack/plugins/data_enhanced/public/search/utils.ts new file mode 100644 index 00000000000000..c6c648dbb54888 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/utils.ts @@ -0,0 +1,15 @@ +/* + * 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 stringify from 'json-stable-stringify'; + +export async function createRequestHash(keys: Record) { + const msgBuffer = new TextEncoder().encode(stringify(keys)); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => ('00' + b.toString(16)).slice(-2)).join(''); +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 39163101fc7bd1..8caa1737c00ada 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -82,6 +82,8 @@ export function App({ dashboardFeatureFlag, } = useKibana().services; + const startSession = useCallback(() => data.search.session.start(), [data]); + const [state, setState] = useState(() => { return { query: data.query.queryString.getQuery(), @@ -96,7 +98,7 @@ export function App({ isSaveModalVisible: false, indicateNoData: false, isSaveable: false, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), }; }); @@ -178,7 +180,7 @@ export function App({ setState((s) => ({ ...s, filters: data.query.filterManager.getFilters(), - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); trackUiEvent('app_filters_updated'); }, @@ -188,7 +190,7 @@ export function App({ next: () => { setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); }, }); @@ -199,7 +201,7 @@ export function App({ tap(() => { setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); }), switchMap((done) => @@ -234,6 +236,7 @@ export function App({ data.query, history, initialContext, + startSession, ]); useEffect(() => { @@ -652,7 +655,7 @@ export function App({ // Time change will be picked up by the time subscription setState((s) => ({ ...s, - searchSessionId: data.search.session.start(), + searchSessionId: startSession(), })); trackUiEvent('app_query_change'); } diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index 2cac0d1b60de7c..13eac7566525e2 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -23,6 +23,7 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC await esArchiver.unload('lens/basic'); }); + loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./search_session_example')); }); } diff --git a/x-pack/test/examples/search_examples/search_sessions_cache.ts b/x-pack/test/examples/search_examples/search_sessions_cache.ts new file mode 100644 index 00000000000000..57b2d1665d9010 --- /dev/null +++ b/x-pack/test/examples/search_examples/search_sessions_cache.ts @@ -0,0 +1,65 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const toasts = getService('toasts'); + const retry = getService('retry'); + + async function getExecutedAt() { + const toast = await toasts.getToastElement(1); + const timeElem = await testSubjects.findDescendant('requestExecutedAt', toast); + const text = await timeElem.getVisibleText(); + await toasts.dismissAllToasts(); + await retry.waitFor('toasts gone', async () => { + return (await toasts.getToastCount()) === 0; + }); + return text; + } + + describe.skip('Search session client side cache', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + }); + + it('should cache responses by search session id', async () => { + await testSubjects.click('searchExamplesCacheSearch'); + const noSessionExecutedAt = await getExecutedAt(); + + // Expect searches executed in a session to share a response + await testSubjects.click('searchExamplesStartSession'); + await testSubjects.click('searchExamplesCacheSearch'); + const withSessionExecutedAt = await getExecutedAt(); + await testSubjects.click('searchExamplesCacheSearch'); + const withSessionExecutedAt2 = await getExecutedAt(); + expect(withSessionExecutedAt2).to.equal(withSessionExecutedAt); + expect(withSessionExecutedAt).not.to.equal(noSessionExecutedAt); + + // Expect new session to run search again + await testSubjects.click('searchExamplesStartSession'); + await testSubjects.click('searchExamplesCacheSearch'); + const secondSessionExecutedAt = await getExecutedAt(); + expect(secondSessionExecutedAt).not.to.equal(withSessionExecutedAt); + + // Clear session + await testSubjects.click('searchExamplesClearSession'); + await testSubjects.click('searchExamplesCacheSearch'); + const afterClearSession1 = await getExecutedAt(); + await testSubjects.click('searchExamplesCacheSearch'); + const afterClearSession2 = await getExecutedAt(); + expect(secondSessionExecutedAt).not.to.equal(afterClearSession1); + expect(afterClearSession2).not.to.equal(afterClearSession1); + }); + }); +} From 6c7d776fc103a305433ef0bca019aaf7882fb4c6 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Fri, 16 Apr 2021 19:55:16 +0200 Subject: [PATCH 02/17] minimize number of so fild asserted in tests. it creates flakines when implementation details change (#97374) --- .../apis/saved_objects/find.ts | 165 +++--------------- .../apis/saved_objects_management/find.ts | 41 +---- .../saved_objects_management/find.ts | 40 ++--- 3 files changed, 36 insertions(+), 210 deletions(-) diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index a01562861e606c..a4862707e2d0ef 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -9,7 +9,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { SavedObject } from '../../../../src/core/server'; -import { getKibanaVersion } from './lib/saved_objects_test_utils'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -17,12 +16,6 @@ export default function ({ getService }: FtrProviderContext) { const esDeleteAllIndices = getService('esDeleteAllIndices'); describe('find', () => { - let KIBANA_VERSION: string; - - before(async () => { - KIBANA_VERSION = await getKibanaVersion(getService); - }); - describe('with kibana index', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); @@ -32,33 +25,9 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - score: 0, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); @@ -129,33 +98,12 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - score: 0, - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({ + id: so.id, + namespaces: so.namespaces, + })) + ).to.eql([{ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] }]); expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); })); }); @@ -166,53 +114,15 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 2, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - score: 0, - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - { - attributes: { - title: 'Count of requests', - }, - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['foo-ns'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - score: 0, - type: 'visualization', - updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzIyLDJd', - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; namespaces: string[] }) => ({ + id: so.id, + namespaces: so.namespaces, + })) + ).to.eql([ + { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['default'] }, + { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', namespaces: ['foo-ns'] }, + ]); })); }); @@ -224,42 +134,9 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - attributes: { - title: 'Count of requests', - visState: resp.body.saved_objects[0].attributes.visState, - uiStateJSON: '{"spy":{"mode":{"name":null,"fill":false}}}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta - .searchSourceJSON, - }, - }, - namespaces: ['default'], - score: 0, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - }, - ], - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzE4LDJd', - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); })); it('wrong type should return 400 with Bad Request', async () => diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 6ab2352ebb05f6..8fb3884a5b37b6 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -34,44 +34,9 @@ export default function ({ getService }: FtrProviderContext) { .get('/api/kibana/management/saved_objects/_find?type=visualization&fields=title') .expect(200) .then((resp: Response) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzE4LDJd', - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - namespaces: ['default'], - references: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - score: 0, - updated_at: '2017-09-21T18:51:23.794Z', - meta: { - editUrl: - '/management/kibana/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', - uiCapabilitiesPath: 'visualize.show', - }, - title: 'Count of requests', - namespaceType: 'single', - }, - }, - ], - }); + expect(resp.body.saved_objects.map((so: { id: string }) => so.id)).to.eql([ + 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + ]); })); describe('unknown type', () => { diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts index 5dce8f43339a16..e5a5d69c7e4d47 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/find.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts @@ -33,28 +33,17 @@ export default function ({ getService }: PluginFunctionalProviderContext) { .set('kbn-xsrf', 'true') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'test-hidden-importable-exportable', - id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', - attributes: { - title: 'Hidden Saved object type that is importable/exportable.', - }, - references: [], - updated_at: '2021-02-11T18:51:23.794Z', - version: 'WzIsMl0=', - namespaces: ['default'], - score: 0, - meta: { - namespaceType: 'single', - }, - }, - ], - }); + expect( + resp.body.saved_objects.map((so: { id: string; type: string }) => ({ + id: so.id, + type: so.type, + })) + ).to.eql([ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + }, + ]); })); it('returns empty response for non importableAndExportable types', async () => @@ -65,12 +54,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { .set('kbn-xsrf', 'true') .expect(200) .then((resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 0, - saved_objects: [], - }); + expect(resp.body.saved_objects).to.eql([]); })); }); }); From 194355fdd3969f567f43ad4b7f63d72dcf7974a9 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Fri, 16 Apr 2021 10:57:29 -0700 Subject: [PATCH 03/17] Skip test to try and stabilize master https://github.com/elastic/kibana/issues/97378 Signed-off-by: Tyler Smalley --- .../apis/security_solution/matrix_dns_histogram.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts b/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts index 69beb65dec670f..27a7a5a5396077 100644 --- a/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts +++ b/x-pack/test/api_integration/apis/security_solution/matrix_dns_histogram.ts @@ -33,7 +33,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const retry = getService('retry'); - describe('Matrix DNS Histogram', () => { + // FIX: https://github.com/elastic/kibana/issues/97378 + describe.skip('Matrix DNS Histogram', () => { describe('Large data set', () => { before(() => esArchiver.load('security_solution/matrix_dns_histogram/large_dns_query')); after(() => esArchiver.unload('security_solution/matrix_dns_histogram/large_dns_query')); From 1cbdb26ceacb47f87adc3a83ae516c08f6fa1d0e Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 16 Apr 2021 12:11:12 -0700 Subject: [PATCH 04/17] skip flaky suite (#97355) --- .../api_integration/apis/security_solution/feature_controls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts index 1e43fd473a38d1..da28e28dae769c 100644 --- a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts +++ b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts @@ -58,7 +58,8 @@ export default function ({ getService }: FtrProviderContext) { }; }; - describe('feature controls', () => { + // FLAKY: https://github.com/elastic/kibana/issues/97355 + describe.skip('feature controls', () => { it(`APIs can't be accessed by user with no privileges`, async () => { const username = 'logstash_read'; const roleName = 'logstash_read'; From 721f4b55f506d48cad34fe38afb351dda3367a25 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 16 Apr 2021 13:52:35 -0600 Subject: [PATCH 05/17] [Security Solutions] Fixes flake with cypress tests (#97329) ## Summary Fixes some recent flakeyness with Cypress tests * Adds cypress.pipe() on button clicks around the area of flakes * Adds an alerting threshold to the utilities so we can wait for when an exact number of alerts are available on a page * Changes the alerts to not run again with 10 seconds, because if a test takes longer than 10 seconds, the rule can run a second time which can invalidate some of the text when running checks when timeline or other components update on their button clicks. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../detection_alerts/attach_to_case.spec.ts | 2 +- .../detection_alerts/closing.spec.ts | 4 ++-- .../detection_alerts/in_progress.spec.ts | 2 +- .../investigate_in_timeline.spec.ts | 2 +- .../detection_alerts/opening.spec.ts | 2 +- .../integration/exceptions/from_alert.spec.ts | 2 +- .../integration/exceptions/from_rule.spec.ts | 2 +- .../security_solution/cypress/objects/rule.ts | 4 ++-- .../security_solution/cypress/tasks/alerts.ts | 20 +++++++++++++++---- .../cypress/tasks/api_calls/rules.ts | 15 +++++++++----- .../cypress/tasks/create_new_rule.ts | 4 ++-- 11 files changed, 38 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts index e63ef513cc6382..bdf2ab96600ea7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/attach_to_case.spec.ts @@ -32,7 +32,7 @@ describe('Alerts timeline', () => { waitForAlertsIndexToBeCreated(); createCustomRuleActivated(newRule); refreshPage(); - waitForAlertsToPopulate(); + waitForAlertsToPopulate(500); // Then we login as read-only user to test. login(ROLES.reader); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts index b7c0e1c6fcd6ec..741f05129f9c47 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts @@ -39,9 +39,9 @@ describe('Closing alerts', () => { loginAndWaitForPage(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); - createCustomRuleActivated(newRule); + createCustomRuleActivated(newRule, '1', '100m', 100); refreshPage(); - waitForAlertsToPopulate(); + waitForAlertsToPopulate(100); deleteCustomRule(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts index 8efdbe82c3492e..b4f890e4d8dbfe 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/in_progress.spec.ts @@ -38,7 +38,7 @@ describe('Marking alerts as in-progress', () => { waitForAlertsIndexToBeCreated(); createCustomRuleActivated(newRule); refreshPage(); - waitForAlertsToPopulate(); + waitForAlertsToPopulate(500); }); it('Mark one alert in progress when more than one open alerts are selected', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts index bc4929cd1341d0..d705cb652d2eae 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/investigate_in_timeline.spec.ts @@ -29,7 +29,7 @@ describe('Alerts timeline', () => { waitForAlertsIndexToBeCreated(); createCustomRuleActivated(newRule); refreshPage(); - waitForAlertsToPopulate(); + waitForAlertsToPopulate(500); }); it('Investigate alert in default timeline', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts index ec0923beb4c402..bc907dccd0a048 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts @@ -39,7 +39,7 @@ describe('Opening alerts', () => { waitForAlertsIndexToBeCreated(); createCustomRuleActivated(newRule); refreshPage(); - waitForAlertsToPopulate(); + waitForAlertsToPopulate(500); selectNumberOfAlerts(5); cy.get(SELECTED_ALERTS).should('have.text', `Selected 5 alerts`); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts index d5e0b56b8e2676..e36809380df863 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_alert.spec.ts @@ -43,7 +43,7 @@ describe('From alert', () => { cleanKibana(); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsIndexToBeCreated(); - createCustomRule(newRule); + createCustomRule(newRule, 'rule_testing', '10s'); goToManageAlertsDetectionRules(); goToRuleDetails(); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts index 148254a813b569..e0d7e5a32edfd3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/from_rule.spec.ts @@ -41,7 +41,7 @@ describe('From rule', () => { cleanKibana(); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsIndexToBeCreated(); - createCustomRule(newRule); + createCustomRule(newRule, 'rule_testing', '10s'); goToManageAlertsDetectionRules(); goToRuleDetails(); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index f083cc5da6f530..099cd39ba2d7b9 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -185,7 +185,7 @@ export const existingRule: CustomRule = { name: 'Rule 1', description: 'Description for Rule 1', index: ['auditbeat-*'], - interval: '10s', + interval: '100m', severity: 'High', riskScore: '19', tags: ['rule1'], @@ -332,5 +332,5 @@ export const editedRule = { export const expectedExportedRule = (ruleResponse: Cypress.Response) => { const jsonrule = ruleResponse.body; - return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"10s","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; + return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-17520h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index dd7a163d007535..b677e36ab39183 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -35,13 +35,25 @@ export const addExceptionFromFirstAlert = () => { }; export const closeFirstAlert = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); - cy.get(CLOSE_ALERT_BTN).click(); + cy.get(TIMELINE_CONTEXT_MENU_BTN) + .first() + .pipe(($el) => $el.trigger('click')) + .should('be.visible'); + + cy.get(CLOSE_ALERT_BTN) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); }; export const closeAlerts = () => { - cy.get(TAKE_ACTION_POPOVER_BTN).click({ force: true }); - cy.get(CLOSE_SELECTED_ALERTS_BTN).click(); + cy.get(TAKE_ACTION_POPOVER_BTN) + .first() + .pipe(($el) => $el.trigger('click')) + .should('be.visible'); + + cy.get(CLOSE_SELECTED_ALERTS_BTN) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); }; export const expandFirstAlert = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 0b051f3a265815..5a816a71744cbe 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -7,7 +7,7 @@ import { CustomRule, ThreatIndicatorRule } from '../../objects/rule'; -export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => +export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', interval = '100m') => cy.request({ method: 'POST', url: 'api/detection_engine/rules', @@ -15,7 +15,7 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => rule_id: ruleId, risk_score: parseInt(rule.riskScore, 10), description: rule.description, - interval: '10s', + interval, name: rule.name, severity: rule.severity.toLocaleLowerCase(), type: 'query', @@ -67,7 +67,12 @@ export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'r failOnStatusCode: false, }); -export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') => +export const createCustomRuleActivated = ( + rule: CustomRule, + ruleId = '1', + interval = '100m', + maxSignals = 500 +) => cy.request({ method: 'POST', url: 'api/detection_engine/rules', @@ -75,7 +80,7 @@ export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') => rule_id: ruleId, risk_score: parseInt(rule.riskScore, 10), description: rule.description, - interval: '10s', + interval, name: rule.name, severity: rule.severity.toLocaleLowerCase(), type: 'query', @@ -85,7 +90,7 @@ export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') => language: 'kuery', enabled: true, tags: ['rule1'], - max_signals: 500, + max_signals: maxSignals, }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 2b7308757f9f4a..9f957a0cb9a952 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -479,7 +479,7 @@ export const selectThresholdRuleType = () => { cy.get(THRESHOLD_TYPE).click({ force: true }); }; -export const waitForAlertsToPopulate = async () => { +export const waitForAlertsToPopulate = async (alertCountThreshold = 1) => { cy.waitUntil( () => { refreshPage(); @@ -488,7 +488,7 @@ export const waitForAlertsToPopulate = async () => { .invoke('text') .then((countText) => { const alertCount = parseInt(countText, 10) || 0; - return alertCount > 0; + return alertCount >= alertCountThreshold; }); }, { interval: 500, timeout: 12000 } From e321f57f64657ffff91df8ed96f4e9fdbe5dcde7 Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 16 Apr 2021 22:28:19 -0700 Subject: [PATCH 06/17] skip flaky suite (#97382) --- .../test/api_integration/apis/short_urls/feature_controls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts b/x-pack/test/api_integration/apis/short_urls/feature_controls.ts index a2596e9eaedaf5..e55fcf10b7fac9 100644 --- a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts +++ b/x-pack/test/api_integration/apis/short_urls/feature_controls.ts @@ -12,7 +12,8 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const supertest = getService('supertestWithoutAuth'); const security = getService('security'); - describe('feature controls', () => { + // FLAKY: https://github.com/elastic/kibana/issues/97382 + describe.skip('feature controls', () => { const kibanaUsername = 'kibana_admin'; const kibanaUserRoleName = 'kibana_admin'; From e0da8b2e961793b6df66086769a0b2f830186d2d Mon Sep 17 00:00:00 2001 From: Bryan Clement Date: Sat, 17 Apr 2021 03:42:49 -0700 Subject: [PATCH 07/17] [Asset Management] Agent picker follow up (#97357) --- .../osquery/public/agents/agent_grouper.ts | 118 ++++++++++ .../osquery/public/agents/agents_table.tsx | 217 ++++++------------ .../osquery/public/agents/helpers.test.ts | 6 + .../plugins/osquery/public/agents/helpers.ts | 65 +++++- .../osquery/public/agents/translations.ts | 2 +- x-pack/plugins/osquery/public/agents/types.ts | 13 +- .../osquery/public/agents/use_agent_groups.ts | 14 +- .../public/agents/use_agent_policies.ts | 38 +++ .../osquery/public/agents/use_all_agents.ts | 24 +- .../live_query/form/agents_table_field.tsx | 5 +- 10 files changed, 344 insertions(+), 158 deletions(-) create mode 100644 x-pack/plugins/osquery/public/agents/agent_grouper.ts create mode 100644 x-pack/plugins/osquery/public/agents/use_agent_policies.ts diff --git a/x-pack/plugins/osquery/public/agents/agent_grouper.ts b/x-pack/plugins/osquery/public/agents/agent_grouper.ts new file mode 100644 index 00000000000000..419a3b9e733a41 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/agent_grouper.ts @@ -0,0 +1,118 @@ +/* + * 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 { Agent } from '../../common/shared_imports'; +import { generateColorPicker } from './helpers'; +import { + ALL_AGENTS_LABEL, + AGENT_PLATFORMS_LABEL, + AGENT_POLICY_LABEL, + AGENT_SELECTION_LABEL, +} from './translations'; +import { AGENT_GROUP_KEY, Group, GroupOption } from './types'; + +const getColor = generateColorPicker(); + +const generateGroup = (label: string, groupType: AGENT_GROUP_KEY) => { + return { + label, + groupType, + color: getColor(groupType), + size: 0, + data: [] as T[], + }; +}; + +export class AgentGrouper { + groupOrder = [ + AGENT_GROUP_KEY.All, + AGENT_GROUP_KEY.Platform, + AGENT_GROUP_KEY.Policy, + AGENT_GROUP_KEY.Agent, + ]; + groups = { + [AGENT_GROUP_KEY.All]: generateGroup(ALL_AGENTS_LABEL, AGENT_GROUP_KEY.All), + [AGENT_GROUP_KEY.Platform]: generateGroup(AGENT_PLATFORMS_LABEL, AGENT_GROUP_KEY.Platform), + [AGENT_GROUP_KEY.Policy]: generateGroup(AGENT_POLICY_LABEL, AGENT_GROUP_KEY.Policy), + [AGENT_GROUP_KEY.Agent]: generateGroup(AGENT_SELECTION_LABEL, AGENT_GROUP_KEY.Agent), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateGroup(key: AGENT_GROUP_KEY, data: any[], append = false) { + if (!data?.length) { + return; + } + const group = this.groups[key]; + if (append) { + group.data.push(...data); + } else { + group.data = data; + } + group.size = data.length; + } + + setTotalAgents(total: number): void { + this.groups[AGENT_GROUP_KEY.All].size = total; + } + + generateOptions(): GroupOption[] { + const opts: GroupOption[] = []; + for (const key of this.groupOrder) { + const { label, size, groupType, data, color } = this.groups[key]; + if (size === 0) { + continue; + } + + switch (key) { + case AGENT_GROUP_KEY.All: + opts.push({ + label, + options: [ + { + label, + value: { groupType, size }, + color, + }, + ], + }); + break; + case AGENT_GROUP_KEY.Platform: + case AGENT_GROUP_KEY.Policy: + opts.push({ + label, + options: (data as Group[]).map(({ name, id, size: groupSize }) => ({ + label: name !== id ? `${name} (${id})` : name, + key: id, + color: getColor(groupType), + value: { groupType, id, size: groupSize }, + })), + }); + break; + case AGENT_GROUP_KEY.Agent: + opts.push({ + label, + options: (data as Agent[]).map((agent: Agent) => ({ + label: `${agent.local_metadata.host.hostname} (${agent.local_metadata.elastic.agent.id})`, + key: agent.local_metadata.elastic.agent.id, + color, + value: { + groupType, + groups: { + policy: agent.policy_id ?? '', + platform: agent.local_metadata.os.platform, + }, + id: agent.local_metadata.elastic.agent.id, + online: agent.active, + }, + })), + }); + break; + } + } + return opts; + } +} diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index 5f1b6a0d2f0b1c..38132957c341f9 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -5,179 +5,98 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiComboBox, EuiHealth, EuiHighlight } from '@elastic/eui'; +import { useDebounce } from 'react-use'; import { useAllAgents } from './use_all_agents'; import { useAgentGroups } from './use_agent_groups'; import { useOsqueryPolicies } from './use_osquery_policies'; -import { Agent } from '../../common/shared_imports'; +import { AgentGrouper } from './agent_grouper'; import { getNumAgentsInGrouping, generateAgentCheck, getNumOverlapped, - generateColorPicker, + generateAgentSelection, } from './helpers'; -import { - ALL_AGENTS_LABEL, - AGENT_PLATFORMS_LABEL, - AGENT_POLICY_LABEL, - SELECT_AGENT_LABEL, - AGENT_SELECTION_LABEL, - generateSelectedAgentsMessage, -} from './translations'; - -import { AGENT_GROUP_KEY, SelectedGroups, AgentOptionValue, GroupOptionValue } from './types'; +import { SELECT_AGENT_LABEL, generateSelectedAgentsMessage } from './translations'; -export interface AgentsSelection { - agents: string[]; - allAgentsSelected: boolean; - platformsSelected: string[]; - policiesSelected: string[]; -} +import { + AGENT_GROUP_KEY, + SelectedGroups, + AgentOptionValue, + GroupOption, + AgentSelection, +} from './types'; interface AgentsTableProps { - agentSelection: AgentsSelection; - onChange: (payload: AgentsSelection) => void; + agentSelection: AgentSelection; + onChange: (payload: AgentSelection) => void; } -type GroupOption = EuiComboBoxOptionOption; - -const getColor = generateColorPicker(); +const perPage = 10; +const DEBOUNCE_DELAY = 100; // ms const AgentsTableComponent: React.FC = ({ onChange }) => { + // search related + const [searchValue, setSearchValue] = useState(''); + const [modifyingSearch, setModifyingSearch] = useState(false); + const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); + useDebounce( + () => { + // update the real search value, set the typing flag + setDebouncedSearchValue(searchValue); + setModifyingSearch(false); + }, + DEBOUNCE_DELAY, + [searchValue] + ); + + // grouping related const osqueryPolicyData = useOsqueryPolicies(); const { loading: groupsLoading, totalCount: totalNumAgents, groups } = useAgentGroups( osqueryPolicyData ); - const { agents } = useAllAgents(osqueryPolicyData); - const [loading, setLoading] = useState(true); + const grouper = useMemo(() => new AgentGrouper(), []); + const { agentsLoading, agents } = useAllAgents(osqueryPolicyData, debouncedSearchValue, { + perPage, + }); + + // option related const [options, setOptions] = useState([]); const [selectedOptions, setSelectedOptions] = useState([]); const [numAgentsSelected, setNumAgentsSelected] = useState(0); useEffect(() => { - const allAgentsLabel = ALL_AGENTS_LABEL; - const opts: GroupOption[] = [ - { - label: allAgentsLabel, - options: [ - { - label: allAgentsLabel, - value: { groupType: AGENT_GROUP_KEY.All, size: totalNumAgents }, - color: getColor(AGENT_GROUP_KEY.All), - }, - ], - }, - ]; - - if (groups.platforms.length > 0) { - const groupType = AGENT_GROUP_KEY.Platform; - opts.push({ - label: AGENT_PLATFORMS_LABEL, - options: groups.platforms.map(({ name, size }) => ({ - label: name, - color: getColor(groupType), - value: { groupType, size }, - })), - }); - } - - if (groups.policies.length > 0) { - const groupType = AGENT_GROUP_KEY.Policy; - opts.push({ - label: AGENT_POLICY_LABEL, - options: groups.policies.map(({ name, size }) => ({ - label: name, - color: getColor(groupType), - value: { groupType, size }, - })), - }); - } - - if (agents && agents.length > 0) { - const groupType = AGENT_GROUP_KEY.Agent; - opts.push({ - label: AGENT_SELECTION_LABEL, - options: (agents as Agent[]).map((agent: Agent) => ({ - label: agent.local_metadata.host.hostname, - color: getColor(groupType), - value: { - groupType, - groups: { policy: agent.policy_id ?? '', platform: agent.local_metadata.os.platform }, - id: agent.local_metadata.elastic.agent.id, - online: agent.active, - }, - })), - }); - } - setLoading(false); - setOptions(opts); - }, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents]); + // update the groups when groups or agents have changed + grouper.setTotalAgents(totalNumAgents); + grouper.updateGroup(AGENT_GROUP_KEY.Platform, groups.platforms); + grouper.updateGroup(AGENT_GROUP_KEY.Policy, groups.policies); + grouper.updateGroup(AGENT_GROUP_KEY.Agent, agents); + const newOptions = grouper.generateOptions(); + setOptions(newOptions); + }, [groups.platforms, groups.policies, totalNumAgents, groupsLoading, agents, grouper]); const onSelection = useCallback( (selection: GroupOption[]) => { - // TODO?: optimize this by making it incremental - const newAgentSelection: AgentsSelection = { - agents: [], - allAgentsSelected: false, - platformsSelected: [], - policiesSelected: [], - }; - // parse through the selections to be able to determine how many are actually selected - const selectedAgents = []; - const selectedGroups: SelectedGroups = { - policy: {}, - platform: {}, - }; - - // TODO: clean this up, make it less awkward - for (const opt of selection) { - const groupType = opt.value?.groupType; - let value; - switch (groupType) { - case AGENT_GROUP_KEY.All: - newAgentSelection.allAgentsSelected = true; - break; - case AGENT_GROUP_KEY.Platform: - value = opt.value as GroupOptionValue; - if (!newAgentSelection.allAgentsSelected) { - // we don't need to calculate diffs when all agents are selected - selectedGroups.platform[opt.label] = value.size; - } - newAgentSelection.platformsSelected.push(opt.label); - break; - case AGENT_GROUP_KEY.Policy: - value = opt.value as GroupOptionValue; - if (!newAgentSelection.allAgentsSelected) { - // we don't need to calculate diffs when all agents are selected - selectedGroups.policy[opt.label] = value.size ?? 0; - } - newAgentSelection.policiesSelected.push(opt.label); - break; - case AGENT_GROUP_KEY.Agent: - value = opt.value as AgentOptionValue; - if (!newAgentSelection.allAgentsSelected) { - // we don't need to count how many agents are selected if they are all selected - selectedAgents.push(opt.value); - } - // TODO: fix this casting by updating the opt type to be a union - newAgentSelection.agents.push(value.id as string); - break; - default: - // this should never happen! - // eslint-disable-next-line no-console - console.error(`unknown group type ${groupType}`); - } - } + // TODO?: optimize this by making the selection computation incremental + const { + newAgentSelection, + selectedAgents, + selectedGroups, + }: { + newAgentSelection: AgentSelection; + selectedAgents: AgentOptionValue[]; + selectedGroups: SelectedGroups; + } = generateAgentSelection(selection); if (newAgentSelection.allAgentsSelected) { setNumAgentsSelected(totalNumAgents); } else { const checkAgent = generateAgentCheck(selectedGroups); setNumAgentsSelected( // filter out all the agents counted by selected policies and platforms - selectedAgents.filter((a) => checkAgent(a as AgentOptionValue)).length + + selectedAgents.filter(checkAgent).length + // add the number of agents added via policy and platform groups getNumAgentsInGrouping(selectedGroups) - // subtract the number of agents double counted by policy/platform selections @@ -190,32 +109,40 @@ const AgentsTableComponent: React.FC = ({ onChange }) => { [groups, onChange, totalNumAgents] ); - const renderOption = useCallback((option, searchValue, contentClassName) => { + const renderOption = useCallback((option, searchVal, contentClassName) => { const { label, value } = option; return value?.groupType === AGENT_GROUP_KEY.Agent ? ( - {label} + {label} ) : ( - {label} + [{value?.size ?? 0}]   - ({value?.size}) + {label} ); }, []); + + const onSearchChange = useCallback((v: string) => { + // set the typing flag and update the search value + setModifyingSearch(v !== ''); + setSearchValue(v); + }, []); + return (
-

{SELECT_AGENT_LABEL}

{numAgentsSelected > 0 ? {generateSelectedAgentsMessage(numAgentsSelected)} : ''}   { const { platforms, policies, overlap } = processAggregations(input); expect(platforms).toEqual([ { + id: 'darwin', name: 'darwin', size: 200, }, @@ -59,10 +60,12 @@ describe('processAggregations', () => { expect(platforms).toEqual([]); expect(policies).toEqual([ { + id: '8cd01a60-8a74-11eb-86cb-c58693443a4f', name: '8cd01a60-8a74-11eb-86cb-c58693443a4f', size: 100, }, { + id: '8cd06880-8a74-11eb-86cb-c58693443a4f', name: '8cd06880-8a74-11eb-86cb-c58693443a4f', size: 100, }, @@ -107,16 +110,19 @@ describe('processAggregations', () => { const { platforms, policies, overlap } = processAggregations(input); expect(platforms).toEqual([ { + id: 'darwin', name: 'darwin', size: 200, }, ]); expect(policies).toEqual([ { + id: '8cd01a60-8a74-11eb-86cb-c58693443a4f', name: '8cd01a60-8a74-11eb-86cb-c58693443a4f', size: 100, }, { + id: '8cd06880-8a74-11eb-86cb-c58693443a4f', name: '8cd06880-8a74-11eb-86cb-c58693443a4f', size: 100, }, diff --git a/x-pack/plugins/osquery/public/agents/helpers.ts b/x-pack/plugins/osquery/public/agents/helpers.ts index 830fca5f57caa0..14a8dd64fb4da4 100644 --- a/x-pack/plugins/osquery/public/agents/helpers.ts +++ b/x-pack/plugins/osquery/public/agents/helpers.ts @@ -20,6 +20,9 @@ import { Group, AgentOptionValue, AggregationDataPoint, + AgentSelection, + GroupOptionValue, + GroupOption, } from './types'; export type InspectResponse = Inspect & { response: string[] }; @@ -43,11 +46,12 @@ export const processAggregations = (aggs: Record) => { const platformTerms = aggs.platforms as TermsAggregate; const policyTerms = aggs.policies as TermsAggregate; - const policies = policyTerms?.buckets.map((o) => ({ name: o.key, size: o.doc_count })) ?? []; + const policies = + policyTerms?.buckets.map((o) => ({ name: o.key, id: o.key, size: o.doc_count })) ?? []; if (platformTerms?.buckets) { for (const { key, doc_count: size, policies: platformPolicies } of platformTerms.buckets) { - platforms.push({ name: key, size }); + platforms.push({ name: key, id: key, size }); if (platformPolicies?.buckets && policies.length > 0) { overlap[key] = platformPolicies.buckets.reduce((acc: { [key: string]: number }, pol) => { acc[pol.key] = pol.doc_count; @@ -96,6 +100,63 @@ export const generateAgentCheck = (selectedGroups: SelectedGroups) => { }; }; +export const generateAgentSelection = (selection: GroupOption[]) => { + const newAgentSelection: AgentSelection = { + agents: [], + allAgentsSelected: false, + platformsSelected: [], + policiesSelected: [], + }; + // parse through the selections to be able to determine how many are actually selected + const selectedAgents: AgentOptionValue[] = []; + const selectedGroups: SelectedGroups = { + policy: {}, + platform: {}, + }; + + // TODO: clean this up, make it less awkward + for (const opt of selection) { + const groupType = opt.value?.groupType; + let value; + switch (groupType) { + case AGENT_GROUP_KEY.All: + newAgentSelection.allAgentsSelected = true; + break; + case AGENT_GROUP_KEY.Platform: + value = opt.value as GroupOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to calculate diffs when all agents are selected + selectedGroups.platform[opt.value?.id ?? opt.label] = value.size; + } + newAgentSelection.platformsSelected.push(opt.label); + break; + case AGENT_GROUP_KEY.Policy: + value = opt.value as GroupOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to calculate diffs when all agents are selected + selectedGroups.policy[opt.value?.id ?? opt.label] = value.size; + } + newAgentSelection.policiesSelected.push(opt.label); + break; + case AGENT_GROUP_KEY.Agent: + value = opt.value as AgentOptionValue; + if (!newAgentSelection.allAgentsSelected) { + // we don't need to count how many agents are selected if they are all selected + selectedAgents.push(value); + } + if (value?.id) { + newAgentSelection.agents.push(value.id); + } + break; + default: + // this should never happen! + // eslint-disable-next-line no-console + console.error(`unknown group type ${groupType}`); + } + } + return { newAgentSelection, selectedGroups, selectedAgents }; +}; + export const generateTablePaginationOptions = ( activePage: number, limit: number, diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts index af99a73d63de29..209761b4c8bdfb 100644 --- a/x-pack/plugins/osquery/public/agents/translations.ts +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -40,7 +40,7 @@ export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.select }); export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', { - defaultMessage: `Select Agents`, + defaultMessage: `Select agents or groups`, }); export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { diff --git a/x-pack/plugins/osquery/public/agents/types.ts b/x-pack/plugins/osquery/public/agents/types.ts index 2fa8ddaf345cdd..b26404f9c5e708 100644 --- a/x-pack/plugins/osquery/public/agents/types.ts +++ b/x-pack/plugins/osquery/public/agents/types.ts @@ -6,6 +6,7 @@ */ import { TermsAggregate } from '@elastic/elasticsearch/api/types'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; interface BaseDataPoint { key: string; @@ -17,6 +18,7 @@ export type AggregationDataPoint = BaseDataPoint & { }; export interface Group { + id: string; name: string; size: number; } @@ -28,14 +30,23 @@ export interface SelectedGroups { [groupType: string]: { [groupName: string]: number }; } +export type GroupOption = EuiComboBoxOptionOption; + +export interface AgentSelection { + agents: string[]; + allAgentsSelected: boolean; + platformsSelected: string[]; + policiesSelected: string[]; +} + interface BaseGroupOption { + id?: string; groupType: AGENT_GROUP_KEY; } export type AgentOptionValue = BaseGroupOption & { groups: { [groupType: string]: string }; online: boolean; - id: string; }; export type GroupOptionValue = BaseGroupOption & { diff --git a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts index 0eaca65d02d4be..0853891f1919d6 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -7,6 +7,7 @@ import { useState } from 'react'; import { useQuery } from 'react-query'; import { useKibana } from '../common/lib/kibana'; +import { useAgentPolicies } from './use_agent_policies'; import { OsqueryQueries, @@ -25,6 +26,7 @@ interface UseAgentGroups { export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => { const { data } = useKibana().services; + const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies); const [platforms, setPlatforms] = useState([]); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); @@ -78,14 +80,22 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA setPlatforms(newPlatforms); setOverlap(newOverlap); - setPolicies(newPolicies); + setPolicies( + newPolicies.map((p) => { + const name = agentPolicyById[p.id]?.name ?? p.name; + return { + ...p, + name, + }; + }) + ); } setLoading(false); setTotalCount(responseData.totalCount); }, { - enabled: !osqueryPoliciesLoading, + enabled: !osqueryPoliciesLoading && !agentPoliciesLoading, } ); diff --git a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts new file mode 100644 index 00000000000000..3045423ccbe2d2 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts @@ -0,0 +1,38 @@ +/* + * 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 { useQueries, UseQueryResult } from 'react-query'; +import { useKibana } from '../common/lib/kibana'; +import { + AgentPolicy, + agentPolicyRouteService, + GetOneAgentPolicyResponse, +} from '../../../fleet/common'; + +export const useAgentPolicies = (policyIds: string[] = []) => { + const { http } = useKibana().services; + + const agentResponse = useQueries( + policyIds.map((policyId) => ({ + queryKey: ['agentPolicy', policyId], + queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)), + enabled: policyIds.length > 0, + })) + ) as Array>; + + const agentPoliciesLoading = agentResponse.some((p) => p.isLoading); + const agentPolicies = agentResponse.map((p) => p.data?.item); + const agentPolicyById = agentPolicies.reduce((acc, p) => { + if (!p) { + return acc; + } + acc[p.id] = p; + return acc; + }, {} as { [key: string]: AgentPolicy }); + + return { agentPoliciesLoading, agentPolicies, agentPolicyById }; +}; diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index 607f9ae0076929..bd9b1c32412e6d 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -14,16 +14,30 @@ interface UseAllAgents { osqueryPoliciesLoading: boolean; } -export const useAllAgents = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents) => { - // TODO: properly fetch these in an async manner +interface RequestOptions { + perPage?: number; + page?: number; +} + +// TODO: break out the paginated vs all cases into separate hooks +export const useAllAgents = ( + { osqueryPolicies, osqueryPoliciesLoading }: UseAllAgents, + searchValue = '', + opts: RequestOptions = { perPage: 9000 } +) => { + const { perPage } = opts; const { http } = useKibana().services; const { isLoading: agentsLoading, data: agentData } = useQuery( - ['agents', osqueryPolicies], + ['agents', osqueryPolicies, searchValue, perPage], async () => { + let kuery = `(${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')})`; + if (searchValue) { + kuery += ` and (local_metadata.host.hostname:/${searchValue}/ or local_metadata.elastic.agent.id:/${searchValue}/)`; + } return await http.get('/api/fleet/agents', { query: { - kuery: osqueryPolicies.map((p) => `policy_id:${p}`).join(' or '), - perPage: 9000, + kuery, + perPage, }, }); }, diff --git a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx index 4bc9262af76133..ccde0fd8305f90 100644 --- a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx +++ b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx @@ -7,10 +7,11 @@ import React, { useCallback } from 'react'; import { FieldHook } from '../../shared_imports'; -import { AgentsTable, AgentsSelection } from '../../agents/agents_table'; +import { AgentsTable } from '../../agents/agents_table'; +import { AgentSelection } from '../../agents/types'; interface AgentsTableFieldProps { - field: FieldHook; + field: FieldHook; } const AgentsTableFieldComponent: React.FC = ({ field }) => { From a89b75671000d6c8431ff150b4f555e1f00f361e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Sat, 17 Apr 2021 15:52:32 +0100 Subject: [PATCH 08/17] skip flaky suite (#97387) --- x-pack/test/api_integration/apis/lens/existing_fields.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/lens/existing_fields.ts b/x-pack/test/api_integration/apis/lens/existing_fields.ts index 88949401f102ad..03587869939196 100644 --- a/x-pack/test/api_integration/apis/lens/existing_fields.ts +++ b/x-pack/test/api_integration/apis/lens/existing_fields.ts @@ -160,7 +160,8 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('existing_fields apis', () => { + // FLAKY: https://github.com/elastic/kibana/issues/97387 + describe.skip('existing_fields apis', () => { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('visualize/default'); From 3b31d81196799a9ced9acd5f30082b0f7aed1ce7 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Sat, 17 Apr 2021 22:29:27 -0700 Subject: [PATCH 09/17] [Dashboard] Makes lens default editor for creating new panels (#96181) * Makes lens default editor in dashboard Added all editors menu to dashboard panel toolbar Fixed toggle on editor menu Removed unnecessary comments Added data test subjects to editor menu buttons Populated editor menu with vis types Removed unused imports Fixed imports Adds showCreateNewMenu prop to AddPanelFlyout Rearranged order of editor menu options Fixed ts errors Added groupnig to embeddable factory Use embeddable state transfer service to redirect to editors Added showGroups to TypeSelectionState Fixed add panel flyout test Fixed data test subjects Fixed factory groupings Removed unused import Fixed page object Added telemtry to dashboard toolbar Added telemtry to editor menu Fix ml embeddable functional tests Fix lens dashboard test Fix empty dashboard test Fixed ts errors Fixed time to visualize security test Fixed empty dashboard test Fixed clickAddNewEmbeddableLink in dashboardAddPanel service Fixed agg based vis functional tests Revert test changes Fixed typo Fix tests Fix more tests Fix ts errors Fixed more tests Fixed toolbar sizes and margins to align with lens Fix tests Fixed callbacks Fixed button prop type New vis modal copy updates Added savedObjectMetaData to log stream embeddable factory Addressed feedback Fixed ts error Fix more tests Fixed ts errors Updated dashboard empty prompt copy Adds tooltip to log stream embeddable factory saved object meta data Made icons monochrome in toolbar Fixed icon colors in dark mode Cleaned up css Fixed ts errors Updated snapshot Fixed map icon color * Added tooltips for ML embeddables * Restored test * Added empty dashboard panel test * Fixed i18n id * Fix dashboard_embedding test * Removed unused service * Fixed i18n error * Added icon and description properties to embeddable factory definition * Fixed ts errors * Fixed expected value Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...public.embeddablefactory.getdescription.md | 17 ++ ...le-public.embeddablefactory.geticontype.md | 17 ++ ...dable-public.embeddablefactory.grouping.md | 13 + ...ins-embeddable-public.embeddablefactory.md | 3 + ...able-public.embeddablefactorydefinition.md | 2 +- ...ns-embeddable-public.openaddpanelflyout.md | 3 +- src/plugins/dashboard/kibana.json | 3 +- .../public/application/_dashboard_app.scss | 15 +- .../public/application/dashboard_router.tsx | 2 + .../dashboard_container_factory.tsx | 2 +- .../dashboard_empty_screen.test.tsx.snap | 2 +- .../listing/dashboard_listing.test.tsx | 2 + .../application/top_nav/dashboard_top_nav.tsx | 127 ++++++--- .../application/top_nav/editor_menu.tsx | 255 ++++++++++++++++++ .../dashboard/public/application/types.ts | 2 + .../dashboard/public/dashboard_strings.ts | 2 +- src/plugins/dashboard/public/plugin.tsx | 2 + .../default_embeddable_factory_provider.ts | 3 + .../lib/embeddables/embeddable_factory.ts | 17 ++ .../embeddable_factory_definition.ts | 3 + .../add_panel/add_panel_flyout.test.tsx | 2 + .../add_panel/add_panel_flyout.tsx | 5 +- .../add_panel/open_add_panel_flyout.tsx | 3 + src/plugins/embeddable/public/public.api.md | 7 +- .../solution_toolbar/items/button.scss | 1 - .../solution_toolbar/items/button.tsx | 10 +- .../solution_toolbar/items/popover.tsx | 10 +- .../items/primary_button.scss | 20 ++ .../solution_toolbar/items/primary_button.tsx | 18 +- .../solution_toolbar/items/quick_group.scss | 13 + .../solution_toolbar/items/quick_group.tsx | 12 +- .../solution_toolbar/solution_toolbar.scss | 15 +- .../solution_toolbar/solution_toolbar.tsx | 9 +- src/plugins/presentation_util/public/index.ts | 1 + .../visualize_embeddable_factory.tsx | 2 +- src/plugins/visualizations/public/index.ts | 2 +- .../public/wizard/dialog_navigation.tsx | 2 +- .../public/wizard/new_vis_modal.tsx | 7 +- .../public/wizard/show_new_vis.tsx | 7 + test/examples/embeddables/adding_children.ts | 23 +- .../dashboard/create_and_add_embeddables.ts | 26 +- .../dashboard/dashboard_unsaved_listing.ts | 8 +- .../apps/dashboard/dashboard_unsaved_state.ts | 4 +- .../dashboard/edit_embeddable_redirects.ts | 8 +- .../apps/dashboard/edit_visualizations.js | 3 +- .../apps/dashboard/empty_dashboard.ts | 9 +- test/functional/apps/dashboard/view_edit.ts | 6 +- .../functional/page_objects/dashboard_page.ts | 10 - .../services/dashboard/add_panel.ts | 30 ++- .../services/dashboard/visualizations.ts | 45 +--- .../new_visualize_flow/dashboard_embedding.ts | 5 - .../log_stream_embeddable_factory.ts | 10 + .../anomaly_charts_embeddable_factory.ts | 17 +- .../anomaly_swimlane_embeddable_factory.ts | 17 +- .../apps/ml_embeddables_in_dashboard.ts | 4 +- .../apps/dashboard/dashboard_lens_by_value.ts | 2 - .../apps/dashboard/dashboard_maps_by_value.ts | 9 +- .../time_to_visualize_security.ts | 7 +- .../functional/apps/dashboard/sync_colors.ts | 2 - .../dashboard_mode/dashboard_empty_screen.js | 7 - x-pack/test/functional/apps/lens/dashboard.ts | 2 - .../test/functional/apps/lens/lens_tagging.ts | 5 +- .../maps/embeddable/embeddable_library.js | 4 +- .../apps/maps/embeddable/save_and_return.js | 6 +- .../anomaly_charts_dashboard_embeddables.ts | 4 +- .../test/functional/page_objects/lens_page.ts | 3 +- 66 files changed, 684 insertions(+), 230 deletions(-) create mode 100644 docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md create mode 100644 docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md create mode 100644 docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md create mode 100644 src/plugins/dashboard/public/application/top_nav/editor_menu.tsx create mode 100644 src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md new file mode 100644 index 00000000000000..1699351349bf84 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [getDescription](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md) + +## EmbeddableFactory.getDescription() method + +Returns a description about the embeddable. + +Signature: + +```typescript +getDescription(): string; +``` +Returns: + +`string` + diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md new file mode 100644 index 00000000000000..58b987e5630c48 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [getIconType](./kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md) + +## EmbeddableFactory.getIconType() method + +Returns an EUI Icon type to be displayed in a menu. + +Signature: + +```typescript +getIconType(): string; +``` +Returns: + +`string` + diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md new file mode 100644 index 00000000000000..c4dbe739ddfcb7 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableFactory](./kibana-plugin-plugins-embeddable-public.embeddablefactory.md) > [grouping](./kibana-plugin-plugins-embeddable-public.embeddablefactory.grouping.md) + +## EmbeddableFactory.grouping property + +Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping options in the editors menu in Dashboard for creating new embeddables + +Signature: + +```typescript +readonly grouping?: UiActionsPresentableGrouping; +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md index b355acd0567a82..8ee60e1f58a2b6 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactory.md @@ -16,6 +16,7 @@ export interface EmbeddableFactoryUiActionsPresentableGrouping | Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping options in the editors menu in Dashboard for creating new embeddables | | [isContainerType](./kibana-plugin-plugins-embeddable-public.embeddablefactory.iscontainertype.md) | boolean | True if is this factory create embeddables that are Containers. Used in the add panel to conditionally show whether these can be added to another container. It's just not supported right now, but once nested containers are officially supported we can probably get rid of this interface. | | [isEditable](./kibana-plugin-plugins-embeddable-public.embeddablefactory.iseditable.md) | () => Promise<boolean> | Returns whether the current user should be allowed to edit this type of embeddable. Most of the time this should be based off the capabilities service, hence it's async. | | [savedObjectMetaData](./kibana-plugin-plugins-embeddable-public.embeddablefactory.savedobjectmetadata.md) | SavedObjectMetaData<TSavedObjectAttributes> | | @@ -29,6 +30,8 @@ export interface EmbeddableFactoryThis will likely change in future iterations when we improve in place editing capabilities. | | [createFromSavedObject(savedObjectId, input, parent)](./kibana-plugin-plugins-embeddable-public.embeddablefactory.createfromsavedobject.md) | Creates a new embeddable instance based off the saved object id. | | [getDefaultInput(partial)](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdefaultinput.md) | Can be used to get any default input, to be passed in to during the creation process. Default input will not be stored in a parent container, so any inherited input from a container will trump default input parameters. | +| [getDescription()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdescription.md) | Returns a description about the embeddable. | | [getDisplayName()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getdisplayname.md) | Returns a display name for this type of embeddable. Used in "Create new... " options in the add panel for containers. | | [getExplicitInput()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.getexplicitinput.md) | Can be used to request explicit input from the user, to be passed in to EmbeddableFactory:create. Explicit input is stored on the parent container for this embeddable. It overrides any inherited input passed down from the parent container. | +| [getIconType()](./kibana-plugin-plugins-embeddable-public.embeddablefactory.geticontype.md) | Returns an EUI Icon type to be displayed in a menu. | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md index 6ecb88e7c017ea..dd61272625160e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablefactorydefinition.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations'>>; +export declare type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations' | 'grouping' | 'getIconType' | 'getDescription'>>; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md index add46463753590..90caaa3035b348 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md @@ -14,6 +14,7 @@ export declare function openAddPanelFlyout(options: { overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef; ``` @@ -21,7 +22,7 @@ export declare function openAddPanelFlyout(options: { | Parameter | Type | Description | | --- | --- | --- | -| options | {
embeddable: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
} | | +| options | {
embeddable: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
showCreateNewMenu?: boolean;
} | | Returns: diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 41335069461fae..54eaf461b73d73 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -11,7 +11,8 @@ "share", "uiActions", "urlForwarding", - "presentationUtil" + "presentationUtil", + "visualizations" ], "optionalPlugins": [ "home", diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index 30253afff391fa..f6525377cce70f 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -66,4 +66,17 @@ .dshUnsavedListingItem__actions { flex-direction: column; } -} \ No newline at end of file +} + +// Temporary fix for two tone icons to make them monochrome +.dshSolutionToolbar__editorContextMenu--dark { + .euiIcon path { + fill: $euiColorGhost; + } +} + +.dshSolutionToolbar__editorContextMenu--light { + .euiIcon path { + fill: $euiColorInk; + } +} diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index e5281a257ee13d..ed68afc5e97b15 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -80,6 +80,7 @@ export async function mountApp({ embeddable: embeddableStart, kibanaLegacy: { dashboardConfig }, savedObjectsTaggingOss, + visualizations, } = pluginsStart; const spacesApi = pluginsStart.spacesOss?.isSpacesAvailable ? pluginsStart.spacesOss : undefined; @@ -123,6 +124,7 @@ export async function mountApp({ visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) }, storeSearchSession: Boolean(coreStart.application.capabilities.dashboard.storeSearchSession), }, + visualizations, }; const getUrlStateStorage = (history: RouteComponentProps['history']) => diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 9b93f0bbd07119..ff592742488f5d 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -49,7 +49,7 @@ export class DashboardContainerFactoryDefinition public readonly getDisplayName = () => { return i18n.translate('dashboard.factory.displayName', { - defaultMessage: 'dashboard', + defaultMessage: 'Dashboard', }); }; diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 4cd3eb13f36095..138d665866af06 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -287,7 +287,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `

- Add your first panel + Add your first visualization

().services; const [state, setState] = useState({ chromeIsVisible: false }); const [isSaveInProgress, setIsSaveInProgress] = useState(false); + const lensAlias = visualizations.getAliases().find(({ name }) => name === 'lens'); + const quickButtonVisTypes = ['markdown', 'maps']; const stateTransferService = embeddable.getStateTransfer(); + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const trackUiMetric = usageCollection?.reportUiCounter.bind( + usageCollection, + DashboardConstants.DASHBOARDS_ID + ); useEffect(() => { const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { @@ -152,27 +161,36 @@ export function DashboardTopNav({ uiSettings, ]); - const createNew = useCallback(async () => { - const type = 'visualization'; - const factory = embeddable.getEmbeddableFactory(type); + const createNewVisType = useCallback( + (visType?: BaseVisType | VisTypeAlias) => () => { + let path = ''; + let appId = ''; - if (!factory) { - throw new EmbeddableFactoryNotFoundError(type); - } + if (visType) { + if (trackUiMetric) { + trackUiMetric(METRIC_TYPE.CLICK, visType.name); + } - await factory.create({} as EmbeddableInput, dashboardContainer); - }, [dashboardContainer, embeddable]); + if ('aliasPath' in visType) { + appId = visType.aliasApp; + path = visType.aliasPath; + } else { + appId = 'visualize'; + path = `#/create?type=${encodeURIComponent(visType.name)}`; + } + } else { + appId = 'visualize'; + path = '#/create?'; + } - const createNewVisType = useCallback( - (newVisType: string) => async () => { - stateTransferService.navigateToEditor('visualize', { - path: `#/create?type=${encodeURIComponent(newVisType)}`, + stateTransferService.navigateToEditor(appId, { + path, state: { originatingApp: DashboardConstants.DASHBOARDS_ID, }, }); }, - [stateTransferService] + [trackUiMetric, stateTransferService] ); const clearAddPanel = useCallback(() => { @@ -563,38 +581,57 @@ export function DashboardTopNav({ const { TopNavMenu } = navigation.ui; - const quickButtons = [ - { - iconType: 'visText', - createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', { - defaultMessage: 'Markdown', - }), - onClick: createNewVisType('markdown'), - 'data-test-subj': 'dashboardMarkdownQuickButton', - }, - { - iconType: 'controlsHorizontal', - createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', { - defaultMessage: 'Input control', - }), - onClick: createNewVisType('input_control_vis'), - 'data-test-subj': 'dashboardInputControlsQuickButton', - }, - ]; + const getVisTypeQuickButton = (visTypeName: string) => { + const visType = + visualizations.get(visTypeName) || + visualizations.getAliases().find(({ name }) => name === visTypeName); + + if (visType) { + if ('aliasPath' in visType) { + const { name, icon, title } = visType as VisTypeAlias; + + return { + iconType: icon, + createType: title, + onClick: createNewVisType(visType as VisTypeAlias), + 'data-test-subj': `dashboardQuickButton${name}`, + isDarkModeEnabled: IS_DARK_THEME, + }; + } else { + const { name, icon, title, titleInWizard } = visType as BaseVisType; + + return { + iconType: icon, + createType: titleInWizard || title, + onClick: createNewVisType(visType as BaseVisType), + 'data-test-subj': `dashboardQuickButton${name}`, + isDarkModeEnabled: IS_DARK_THEME, + }; + } + } + + return; + }; + + const quickButtons = quickButtonVisTypes + .map(getVisTypeQuickButton) + .filter((button) => button) as QuickButtonProps[]; return ( <> + {viewMode !== ViewMode.VIEW ? ( - + {{ primaryActionButton: ( ), @@ -605,6 +642,12 @@ export function DashboardTopNav({ data-test-subj="dashboardAddPanelButton" /> ), + extraButtons: [ + , + ], }} ) : null} diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx new file mode 100644 index 00000000000000..5205f5b294c4fc --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -0,0 +1,255 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + EuiContextMenuItemIcon, +} from '@elastic/eui'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { i18n } from '@kbn/i18n'; +import { BaseVisType, VisGroups, VisTypeAlias } from '../../../../visualizations/public'; +import { SolutionToolbarPopover } from '../../../../presentation_util/public'; +import { EmbeddableFactoryDefinition, EmbeddableInput } from '../../services/embeddable'; +import { useKibana } from '../../services/kibana_react'; +import { DashboardAppServices } from '../types'; +import { DashboardContainer } from '..'; +import { DashboardConstants } from '../../dashboard_constants'; +import { dashboardReplacePanelAction } from '../../dashboard_strings'; + +interface Props { + /** Dashboard container */ + dashboardContainer: DashboardContainer; + /** Handler for creating new visualization of a specified type */ + createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void; +} + +interface FactoryGroup { + id: string; + appName: string; + icon: EuiContextMenuItemIcon; + panelId: number; + factories: EmbeddableFactoryDefinition[]; +} + +export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { + const { + core, + embeddable, + visualizations, + usageCollection, + uiSettings, + } = useKibana().services; + + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const trackUiMetric = usageCollection?.reportUiCounter.bind( + usageCollection, + DashboardConstants.DASHBOARDS_ID + ); + + const createNewAggsBasedVis = useCallback( + (visType?: BaseVisType) => () => + visualizations.showNewVisModal({ + originatingApp: DashboardConstants.DASHBOARDS_ID, + outsideVisualizeApp: true, + showAggsSelection: true, + selectedVisType: visType, + }), + [visualizations] + ); + + const getVisTypesByGroup = (group: VisGroups) => + visualizations + .getByGroup(group) + .sort(({ name: a }: BaseVisType | VisTypeAlias, { name: b }: BaseVisType | VisTypeAlias) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }) + .filter(({ hidden }: BaseVisType) => !hidden); + + const promotedVisTypes = getVisTypesByGroup(VisGroups.PROMOTED); + const aggsBasedVisTypes = getVisTypesByGroup(VisGroups.AGGBASED); + const toolVisTypes = getVisTypesByGroup(VisGroups.TOOLS); + const visTypeAliases = visualizations + .getAliases() + .sort(({ promotion: a = false }: VisTypeAlias, { promotion: b = false }: VisTypeAlias) => + a === b ? 0 : a ? -1 : 1 + ); + + const factories = embeddable + ? Array.from(embeddable.getEmbeddableFactories()).filter( + ({ type, isEditable, canCreateNew, isContainerType }) => + isEditable() && !isContainerType && canCreateNew() && type !== 'visualization' + ) + : []; + + const factoryGroupMap: Record = {}; + const ungroupedFactories: EmbeddableFactoryDefinition[] = []; + const aggBasedPanelID = 1; + + let panelCount = 1 + aggBasedPanelID; + + factories.forEach((factory: EmbeddableFactoryDefinition, index) => { + const { grouping } = factory; + + if (grouping) { + grouping.forEach((group) => { + if (factoryGroupMap[group.id]) { + factoryGroupMap[group.id].factories.push(factory); + } else { + factoryGroupMap[group.id] = { + id: group.id, + appName: group.getDisplayName ? group.getDisplayName({ embeddable }) : group.id, + icon: (group.getIconType + ? group.getIconType({ embeddable }) + : 'empty') as EuiContextMenuItemIcon, + factories: [factory], + panelId: panelCount, + }; + + panelCount++; + } + }); + } else { + ungroupedFactories.push(factory); + } + }); + + const getVisTypeMenuItem = (visType: BaseVisType): EuiContextMenuPanelItemDescriptor => { + const { name, title, titleInWizard, description, icon = 'empty', group } = visType; + return { + name: titleInWizard || title, + icon: icon as string, + onClick: + group === VisGroups.AGGBASED ? createNewAggsBasedVis(visType) : createNewVisType(visType), + 'data-test-subj': `visType-${name}`, + toolTipContent: description, + }; + }; + + const getVisTypeAliasMenuItem = ( + visTypeAlias: VisTypeAlias + ): EuiContextMenuPanelItemDescriptor => { + const { name, title, description, icon = 'empty' } = visTypeAlias; + + return { + name: title, + icon, + onClick: createNewVisType(visTypeAlias), + 'data-test-subj': `visType-${name}`, + toolTipContent: description, + }; + }; + + const getEmbeddableFactoryMenuItem = ( + factory: EmbeddableFactoryDefinition + ): EuiContextMenuPanelItemDescriptor => { + const icon = factory?.getIconType ? factory.getIconType() : 'empty'; + + const toolTipContent = factory?.getDescription ? factory.getDescription() : undefined; + + return { + name: factory.getDisplayName(), + icon, + toolTipContent, + onClick: async () => { + if (trackUiMetric) { + trackUiMetric(METRIC_TYPE.CLICK, factory.type); + } + let newEmbeddable; + if (factory.getExplicitInput) { + const explicitInput = await factory.getExplicitInput(); + newEmbeddable = await dashboardContainer.addNewEmbeddable(factory.type, explicitInput); + } else { + newEmbeddable = await factory.create({} as EmbeddableInput, dashboardContainer); + } + + if (newEmbeddable) { + core.notifications.toasts.addSuccess({ + title: dashboardReplacePanelAction.getSuccessMessage( + `'${newEmbeddable.getInput().title}'` || '' + ), + 'data-test-subj': 'addEmbeddableToDashboardSuccess', + }); + } + }, + 'data-test-subj': `createNew-${factory.type}`, + }; + }; + + const aggsPanelTitle = i18n.translate('dashboard.editorMenu.aggBasedGroupTitle', { + defaultMessage: 'Aggregation based', + }); + + const editorMenuPanels = [ + { + id: 0, + items: [ + ...visTypeAliases.map(getVisTypeAliasMenuItem), + ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({ + name: appName, + icon, + panel: panelId, + 'data-test-subj': `dashboardEditorMenu-${id}Group`, + })), + ...ungroupedFactories.map(getEmbeddableFactoryMenuItem), + ...promotedVisTypes.map(getVisTypeMenuItem), + { + name: aggsPanelTitle, + icon: 'visualizeApp', + panel: aggBasedPanelID, + 'data-test-subj': `dashboardEditorAggBasedMenuItem`, + }, + ...toolVisTypes.map(getVisTypeMenuItem), + ], + }, + { + id: aggBasedPanelID, + title: aggsPanelTitle, + items: aggsBasedVisTypes.map(getVisTypeMenuItem), + }, + ...Object.values(factoryGroupMap).map( + ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({ + id: panelId, + title: appName, + items: groupFactories.map(getEmbeddableFactoryMenuItem), + }) + ), + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index 6415fdfd73ee8b..dd291291ce9d61 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -25,6 +25,7 @@ import { DataPublicPluginStart, IndexPatternsContract } from '../services/data'; import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; import { DashboardPanelStorage } from './lib'; import { UrlForwardingStart } from '../../../url_forwarding/public'; +import { VisualizationsStart } from '../../../visualizations/public'; export type DashboardRedirect = (props: RedirectToProps) => void; export type RedirectToProps = @@ -83,4 +84,5 @@ export interface DashboardAppServices { savedObjectsClient: SavedObjectsClientContract; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedQueryService: DataPublicPluginStart['query']['savedQueries']; + visualizations: VisualizationsStart; } diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 79a59d0cfa6051..531ff815312cfe 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -377,7 +377,7 @@ export const emptyScreenStrings = { }), getEmptyWidgetTitle: () => i18n.translate('dashboard.emptyWidget.addPanelTitle', { - defaultMessage: 'Add your first panel', + defaultMessage: 'Add your first visualization', }), getEmptyWidgetDescription: () => i18n.translate('dashboard.emptyWidget.addPanelDescription', { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index e2f52a47455b31..0fad1c51f433ae 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -24,6 +24,7 @@ import { PluginInitializerContext, SavedObjectsClientContract, } from '../../../core/public'; +import { VisualizationsStart } from '../../visualizations/public'; import { createKbnUrlTracker } from './services/kibana_utils'; import { UsageCollectionSetup } from './services/usage_collection'; @@ -115,6 +116,7 @@ export interface DashboardStartDependencies { presentationUtil: PresentationUtilPluginStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; spacesOss?: SpacesOssPluginStart; + visualizations: VisualizationsStart; } export type DashboardSetup = void; diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index 27164b3cddbc22..b260c594591fa4 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -37,11 +37,14 @@ export const defaultEmbeddableFactoryProvider = < type: def.type, isEditable: def.isEditable.bind(def), getDisplayName: def.getDisplayName.bind(def), + getDescription: def.getDescription ? def.getDescription.bind(def) : () => '', + getIconType: def.getIconType ? def.getIconType.bind(def) : () => 'empty', savedObjectMetaData: def.savedObjectMetaData, telemetry: def.telemetry || (() => ({})), inject: def.inject || ((state: EmbeddableStateWithType) => state), extract: def.extract || ((state: EmbeddableStateWithType) => ({ state, references: [] })), migrations: def.migrations || {}, + grouping: def.grouping, }; return factory; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 7f3277130f90fd..6ec035f442dd2c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -14,6 +14,7 @@ import { IContainer } from '../containers/i_container'; import { PropertySpec } from '../types'; import { PersistableState } from '../../../../kibana_utils/common'; import { EmbeddableStateWithType } from '../../../common/types'; +import { UiActionsPresentableGrouping } from '../../../../ui_actions/public'; export interface EmbeddableInstanceConfiguration { id: string; @@ -48,6 +49,12 @@ export interface EmbeddableFactory< readonly savedObjectMetaData?: SavedObjectMetaData; + /** + * Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping + * options in the editors menu in Dashboard for creating new embeddables + */ + readonly grouping?: UiActionsPresentableGrouping; + /** * True if is this factory create embeddables that are Containers. Used in the add panel to * conditionally show whether these can be added to another container. It's just not @@ -62,6 +69,16 @@ export interface EmbeddableFactory< */ getDisplayName(): string; + /** + * Returns an EUI Icon type to be displayed in a menu. + */ + getIconType(): string; + + /** + * Returns a description about the embeddable. + */ + getDescription(): string; + /** * If false, this type of embeddable can't be created with the "createNew" functionality. Instead, * use createFromSavedObject, where an existing saved object must first exist. diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts index a64aa32c6e7c4a..f2819f2a2e6640 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts @@ -33,5 +33,8 @@ export type EmbeddableFactoryDefinition< | 'extract' | 'inject' | 'migrations' + | 'grouping' + | 'getIconType' + | 'getDescription' > >; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx index 432897763aa049..1c96945f014c8b 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx @@ -61,6 +61,7 @@ test('createNewEmbeddable() add embeddable to container', async () => { getAllFactories={start.getEmbeddableFactories} notifications={core.notifications} SavedObjectFinder={() => null} + showCreateNewMenu /> ) as ReactWrapper; @@ -112,6 +113,7 @@ test('selecting embeddable in "Create new ..." list calls createNewEmbeddable()' getAllFactories={start.getEmbeddableFactories} notifications={core.notifications} SavedObjectFinder={(props) => } + showCreateNewMenu /> ) as ReactWrapper; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index 8caec4a4428c3f..6d6a68d7e5e2aa 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -26,6 +26,7 @@ interface Props { getAllFactories: EmbeddableStart['getEmbeddableFactories']; notifications: CoreSetup['notifications']; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; } interface State { @@ -134,7 +135,9 @@ export class AddPanelFlyout extends React.Component { defaultMessage: 'No matching objects found.', })} > - + {this.props.showCreateNewMenu ? ( + + ) : null} ); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index bed97c82095c79..f0c6e81644b3d0 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -20,6 +20,7 @@ export function openAddPanelFlyout(options: { overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef { const { embeddable, @@ -28,6 +29,7 @@ export function openAddPanelFlyout(options: { overlays, notifications, SavedObjectFinder, + showCreateNewMenu, } = options; const flyoutSession = overlays.openFlyout( toMountPoint( @@ -42,6 +44,7 @@ export function openAddPanelFlyout(options: { getAllFactories={getAllFactories} notifications={notifications} SavedObjectFinder={SavedObjectFinder} + showCreateNewMenu={showCreateNewMenu} /> ), { diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 220039de2f34ee..d522a4e5fa8e88 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -378,8 +378,12 @@ export interface EmbeddableFactory; createFromSavedObject(savedObjectId: string, input: Partial, parent?: IContainer): Promise; getDefaultInput(partial: Partial): Partial; + getDescription(): string; getDisplayName(): string; getExplicitInput(): Promise>; + getIconType(): string; + // Warning: (ae-forgotten-export) The symbol "PresentableGrouping" needs to be exported by the entry point index.d.ts + readonly grouping?: PresentableGrouping; readonly isContainerType: boolean; readonly isEditable: () => Promise; // Warning: (ae-forgotten-export) The symbol "SavedObjectMetaData" needs to be exported by the entry point index.d.ts @@ -393,7 +397,7 @@ export interface EmbeddableFactory = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations'>>; +export type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations' | 'grouping' | 'getIconType' | 'getDescription'>>; // Warning: (ae-missing-release-tag) "EmbeddableFactoryNotFoundError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -724,6 +728,7 @@ export function openAddPanelFlyout(options: { overlays: OverlayStart_2; notifications: NotificationsStart_2; SavedObjectFinder: React.ComponentType; + showCreateNewMenu?: boolean; }): OverlayRef_2; // Warning: (ae-missing-release-tag) "OutputSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index 79c3d4cca7ace1..b8022201acf596 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -1,4 +1,3 @@ - .solutionToolbarButton { line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx index 5de8e24ef5f0de..ee1bbd64b5f871 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx @@ -12,17 +12,19 @@ import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/butt import './button.scss'; -export interface Props extends Pick { +export interface Props + extends Pick { label: string; primary?: boolean; + isDarkModeEnabled?: boolean; } -export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => ( +export const SolutionToolbarButton = ({ label, primary, className, ...rest }: Props) => ( {label} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx index fbb34e165190d5..33850005b498be 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -20,14 +20,20 @@ type AllowedPopoverProps = Omit< export type Props = AllowedButtonProps & AllowedPopoverProps; -export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => { +export const SolutionToolbarPopover = ({ + label, + iconType, + primary, + iconSide, + ...popover +}: Props) => { const [isOpen, setIsOpen] = useState(false); const onButtonClick = () => setIsOpen((status) => !status); const closePopover = () => setIsOpen(false); const button = ( - + ); return ( diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss new file mode 100644 index 00000000000000..c3d89f430d70c4 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss @@ -0,0 +1,20 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// Temporary fix for lensApp icon not support ghost color +.solutionToolbar__primaryButton--dark { + .euiIcon path { + fill: $euiColorInk; + } +} + +.solutionToolbar__primaryButton--light { + .euiIcon path { + fill: $euiColorGhost; + } +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx index e2ef75e45a4049..dcf16228ac63b7 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx @@ -10,6 +10,20 @@ import React from 'react'; import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; -export const PrimaryActionButton = (props: Omit) => ( - +import './primary_button.scss'; + +export interface Props extends Omit { + isDarkModeEnabled?: boolean; +} + +export const PrimaryActionButton = ({ isDarkModeEnabled, ...props }: Props) => ( + ); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 639ff5bf2a117a..870a9a945ed5d3 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -2,4 +2,17 @@ .quickButtonGroup__button { background-color: $euiColorEmptyShade; } + + // Temporary fix for two tone icons to make them monochrome + .quickButtonGroup__button--dark { + .euiIcon path { + fill: $euiColorGhost; + } + } + // Temporary fix for two tone icons to make them monochrome + .quickButtonGroup__button--light { + .euiIcon path { + fill: $euiColorInk; + } + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx index 58f8bd803b636a..eb0a395548cd90 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -17,23 +17,27 @@ import './quick_group.scss'; export interface QuickButtonProps extends Pick { createType: string; onClick: () => void; + isDarkModeEnabled?: boolean; } export interface Props { buttons: QuickButtonProps[]; } -type Option = EuiButtonGroupOptionProps & Omit; +type Option = EuiButtonGroupOptionProps & + Omit; export const QuickButtonGroup = ({ buttons }: Props) => { const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { - const { createType: label, ...rest } = button; + const { createType: label, isDarkModeEnabled, ...rest } = button; const title = strings.getAriaButtonLabel(label); return { ...rest, 'aria-label': title, - className: 'quickButtonGroup__button', + className: `quickButtonGroup__button ${ + isDarkModeEnabled ? 'quickButtonGroup__button--dark' : 'quickButtonGroup__button--light' + }`, id: `${htmlIdGenerator()()}${index}`, label, title, @@ -46,7 +50,7 @@ export const QuickButtonGroup = ({ buttons }: Props) => { return ( { +export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => { const { primaryActionButton, quickButtonGroup, @@ -49,8 +50,10 @@ export const SolutionToolbar = ({ children }: Props) => { return ( {primaryActionButton} diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 9c5f65de409555..fd3ae894192977 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -19,6 +19,7 @@ export { LazySavedObjectSaveModalDashboard, withSuspense, } from './components'; + export { AddFromLibraryButton, PrimaryActionButton, diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 2b5a611cd946e8..48bff8d203ebd7 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -113,7 +113,7 @@ export class VisualizeEmbeddableFactory public getDisplayName() { return i18n.translate('visualizations.displayName', { - defaultMessage: 'visualization', + defaultMessage: 'Visualization', }); } diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index e5b1ba73d9d1c0..dbcbb864d2316b 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -25,7 +25,7 @@ export { getVisSchemas } from './vis_schemas'; /** @public types */ export { VisualizationsSetup, VisualizationsStart }; export { VisGroups } from './vis_types'; -export type { VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; +export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; export { SerializedVis, SerializedVisData, VisData } from './vis'; export type VisualizeEmbeddableFactoryContract = PublicContract; export type VisualizeEmbeddableContract = PublicContract; diff --git a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx index 1de177e12f40da..c92514d54166fc 100644 --- a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx +++ b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx @@ -24,7 +24,7 @@ function DialogNavigation(props: DialogNavigationProps) { {i18n.translate('visualizations.newVisWizard.goBackLink', { - defaultMessage: 'Go back', + defaultMessage: 'Select a different visualization', })} diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index d36b734f75be2e..317f9d1bb363db 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -41,6 +41,8 @@ interface TypeSelectionProps { outsideVisualizeApp?: boolean; stateTransfer?: EmbeddableStateTransfer; originatingApp?: string; + showAggsSelection?: boolean; + selectedVisType?: BaseVisType; } interface TypeSelectionState { @@ -69,8 +71,9 @@ class NewVisModal extends React.Component import('./new_vis_modal')); @@ -29,6 +30,8 @@ export interface ShowNewVisModalParams { originatingApp?: string; outsideVisualizeApp?: boolean; createByValue?: boolean; + showAggsSelection?: boolean; + selectedVisType?: BaseVisType; } /** @@ -41,6 +44,8 @@ export function showNewVisModal({ onClose, originatingApp, outsideVisualizeApp, + showAggsSelection, + selectedVisType, }: ShowNewVisModalParams = {}) { const container = document.createElement('div'); let isClosed = false; @@ -78,6 +83,8 @@ export function showNewVisModal({ usageCollection={getUsageCollector()} application={getApplication()} docLinks={getDocLinks()} + showAggsSelection={showAggsSelection} + selectedVisType={selectedVisType} /> diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 8b59012bf98253..ee06622a33f511 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -13,31 +13,12 @@ import { PluginFunctionalProviderContext } from 'test/plugin_functional/services export default function ({ getService }: PluginFunctionalProviderContext) { const testSubjects = getService('testSubjects'); const flyout = getService('flyout'); - const retry = getService('retry'); - describe('creating and adding children', () => { + describe('adding children', () => { before(async () => { await testSubjects.click('embeddablePanelExample'); }); - it('Can create a new child', async () => { - await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); - - // this seem like an overkill, but clicking this button which opens context menu was flaky - await testSubjects.waitForEnabled('createNew'); - await retry.waitFor('createNew popover opened', async () => { - await testSubjects.click('createNew'); - return await testSubjects.exists('createNew-TODO_EMBEDDABLE'); - }); - await testSubjects.click('createNew-TODO_EMBEDDABLE'); - - await testSubjects.setValue('taskInputField', 'new task'); - await testSubjects.click('createTodoEmbeddable'); - const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); - expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task']); - }); - it('Can add a child backed off a saved object', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); @@ -46,7 +27,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { await testSubjects.moveMouseTo('euiFlyoutCloseButton'); await flyout.ensureClosed('dashboardAddPanel'); const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); - expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task', 'Take the garbage out']); + expect(tasks).to.eql(['Goes out on Wednesdays!', 'Take the garbage out']); }); }); } diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.ts b/test/functional/apps/dashboard/create_and_add_embeddables.ts index 9b8fc4785a6718..3de3b2f843f554 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.ts +++ b/test/functional/apps/dashboard/create_and_add_embeddables.ts @@ -35,8 +35,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds new visualization via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( @@ -52,9 +52,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a new visualization', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess( @@ -71,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a markdown visualization via the quick button', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await PageObjects.dashboard.clickMarkdownQuickButton(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visualize.saveVisualizationExpectSuccess( 'visualization from markdown quick button', { redirectToOrigin: true } @@ -84,21 +83,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); }); - it('adds an input control visualization via the quick button', async () => { - const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - await PageObjects.dashboard.clickInputControlsQuickButton(); - await PageObjects.visualize.saveVisualizationExpectSuccess( - 'visualization from input control quick button', - { redirectToOrigin: true } - ); - - await retry.try(async () => { - const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.eql(originalPanelCount + 1); - }); - await PageObjects.dashboard.waitForRenderComplete(); - }); - it('saves the listing page instead of the visualization to the app link', async () => { await PageObjects.header.clickVisualize(true); const currentUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts index 233d2e91467fee..1cdc4bbff2c532 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_listing.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_listing.ts @@ -25,8 +25,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard unsaved listing', () => { const addSomePanels = async () => { // add an area chart by value - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationAndReturn(); @@ -132,8 +132,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.switchToEditMode(); // add another panel so we can delete it later - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess('Wildvis', { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index e6cc91880010ae..fd203cd8c1356d 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -41,8 +41,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('shows the unsaved changes badge after adding panels', async () => { await PageObjects.dashboard.switchToEditMode(); // add an area chart by value - await dashboardAddPanel.clickCreateNewLink(); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationAndReturn(); diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.ts b/test/functional/apps/dashboard/edit_embeddable_redirects.ts index 8b7b98a59aa126..be540e18a503f9 100644 --- a/test/functional/apps/dashboard/edit_embeddable_redirects.ts +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.ts @@ -13,10 +13,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); const esArchiver = getService('esArchiver'); - const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardAddPanel = getService('dashboardAddPanel'); describe('edit embeddable redirects', () => { before(async () => { @@ -88,10 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const newTitle = 'test create panel originatingApp'; await PageObjects.dashboard.loadSavedDashboard('few panels'); await PageObjects.dashboard.switchToEditMode(); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { saveAsNew: true, redirectToOrigin: false, diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index ce32f53587e747..b2f21aefcf79cc 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -14,13 +14,14 @@ export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); const originalMarkdownText = 'Original markdown text'; const modifiedMarkdownText = 'Modified markdown text'; const createMarkdownVis = async (title) => { - await PageObjects.dashboard.clickMarkdownQuickButton(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); if (title) { diff --git a/test/functional/apps/dashboard/empty_dashboard.ts b/test/functional/apps/dashboard/empty_dashboard.ts index c096d90aa3595e..2cfa6d73dcb728 100644 --- a/test/functional/apps/dashboard/empty_dashboard.ts +++ b/test/functional/apps/dashboard/empty_dashboard.ts @@ -41,15 +41,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should open add panel when add button is clicked', async () => { - await testSubjects.click('dashboardAddPanelButton'); + await dashboardAddPanel.clickOpenAddPanel(); const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); expect(isAddPanelOpen).to.be(true); await testSubjects.click('euiFlyoutCloseButton'); }); it('should add new visualization from dashboard', async () => { - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndAddMarkdown({ name: 'Dashboard Test Markdown', markdown: 'Markdown text', @@ -57,5 +55,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.waitForRenderComplete(); await dashboardExpect.markdownWithValuesExists(['Markdown text']); }); + + it('should open editor menu when editor button is clicked', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await testSubjects.existOrFail('dashboardEditorContextMenu'); + }); }); } diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index c5c7daab27ff19..99a78ebd069c5d 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -113,10 +113,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('when a new vis is added', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); - - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); - await PageObjects.visualize.clickAggBasedVisualizations(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); await PageObjects.visualize.clickAreaChart(); await PageObjects.visualize.clickNewSearch(); await PageObjects.visualize.saveVisualizationExpectSuccess('new viz panel', { diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 34559afdf6ae1a..9c12296db138c6 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -413,16 +413,6 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.click('confirmSaveSavedObjectButton'); } - public async clickMarkdownQuickButton() { - log.debug('Click markdown quick button'); - await testSubjects.click('dashboardMarkdownQuickButton'); - } - - public async clickInputControlsQuickButton() { - log.debug('Click input controls quick button'); - await testSubjects.click('dashboardInputControlsQuickButton'); - } - /** * * @param dashboardTitle {String} diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 7bb1603e0193f9..a4e0c8b2647dd8 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -30,15 +30,41 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }: FtrPro await PageObjects.common.sleep(500); } + async clickQuickButton(visType: string) { + log.debug(`DashboardAddPanel.clickQuickButton${visType}`); + await testSubjects.click(`dashboardQuickButton${visType}`); + } + + async clickMarkdownQuickButton() { + await this.clickQuickButton('markdown'); + } + + async clickMapQuickButton() { + await this.clickQuickButton('map'); + } + + async clickEditorMenuButton() { + log.debug('DashboardAddPanel.clickEditorMenuButton'); + await testSubjects.click('dashboardEditorMenuButton'); + } + + async clickAggBasedVisualizations() { + log.debug('DashboardAddPanel.clickEditorMenuAggBasedMenuItem'); + await testSubjects.click('dashboardEditorAggBasedMenuItem'); + } + async clickVisType(visType: string) { log.debug('DashboardAddPanel.clickVisType'); await testSubjects.click(`visType-${visType}`); } + async clickEmbeddableFactoryGroupButton(groupId: string) { + log.debug('DashboardAddPanel.clickEmbeddableFactoryGroupButton'); + await testSubjects.click(`dashboardEditorMenu-${groupId}Group`); + } + async clickAddNewEmbeddableLink(type: string) { - await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); - await testSubjects.missingOrFail(`createNew-${type}`); } async toggleFilterPopover() { diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index d1aaa6aa1bd707..2bf7458ff9c5f4 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -10,8 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function DashboardVisualizationProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -31,8 +29,8 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await dashboardAddPanel.ensureAddPanelIsShowing(); - await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('metrics'); await PageObjects.visualize.clickVisualBuilder(); await PageObjects.visualize.saveVisualizationExpectSuccess(name); } @@ -87,39 +85,13 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F await dashboardAddPanel.addSavedSearch(name); } - async clickAddVisualizationButton() { - log.debug('DashboardVisualizations.clickAddVisualizationButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - } - - async isNewVisDialogShowing() { - log.debug('DashboardVisualizations.isNewVisDialogShowing'); - return await find.existsByCssSelector('.visNewVisDialog'); - } - - async ensureNewVisualizationDialogIsShowing() { - let isShowing = await this.isNewVisDialogShowing(); - log.debug(`DashboardVisualizations.ensureNewVisualizationDialogIsShowing:${isShowing}`); - if (!isShowing) { - await retry.try(async () => { - await this.clickAddVisualizationButton(); - isShowing = await this.isNewVisDialogShowing(); - log.debug(`DashboardVisualizations.ensureNewVisualizationDialogIsShowing:${isShowing}`); - if (!isShowing) { - throw new Error('New Vis Dialog still not open, trying again.'); - } - }); - } - } - async createAndAddMarkdown({ name, markdown }: { name: string; markdown: string }) { log.debug(`createAndAddMarkdown(${markdown})`); const inViewMode = await PageObjects.dashboard.getIsInViewMode(); if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); await PageObjects.visualize.saveVisualizationExpectSuccess(name, { @@ -134,10 +106,10 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickAggBasedVisualizations(); - await PageObjects.visualize.clickMetric(); - await find.clickByCssSelector('li.euiListGroupItem:nth-of-type(2)'); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await dashboardAddPanel.clickVisType('metric'); + await testSubjects.click('savedObjectTitlelogstash-*'); await testSubjects.exists('visualizesaveAndReturnButton'); await testSubjects.click('visualizesaveAndReturnButton'); } @@ -148,8 +120,7 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await this.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(markdown); await PageObjects.visEditor.clickGo(); await testSubjects.click('visualizesaveAndReturnButton'); diff --git a/test/new_visualize_flow/dashboard_embedding.ts b/test/new_visualize_flow/dashboard_embedding.ts index 6a1315dbfc91ed..04b91542223bad 100644 --- a/test/new_visualize_flow/dashboard_embedding.ts +++ b/test/new_visualize_flow/dashboard_embedding.ts @@ -22,7 +22,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardExpect = getService('dashboardExpect'); - const testSubjects = getService('testSubjects'); const dashboardVisualizations = getService('dashboardVisualizations'); const PageObjects = getPageObjects([ 'common', @@ -47,8 +46,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adding a metric visualization', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); expect(originalPanelCount).to.eql(0); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndEmbedMetric('Embedding Vis Test'); await PageObjects.dashboard.waitForRenderComplete(); await dashboardExpect.metricValuesExist(['0']); @@ -59,8 +56,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adding a markdown', async function () { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); expect(originalPanelCount).to.eql(1); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.createAndEmbedMarkdown({ name: 'Embedding Markdown Test', markdown: 'Nice to meet you, markdown is my name', diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts index 4b9b2f99215b7a..1c7e8ceb28fb4c 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts @@ -40,6 +40,16 @@ export class LogStreamEmbeddableFactoryDefinition }); } + public getDescription() { + return i18n.translate('xpack.infra.logStreamEmbeddable.description', { + defaultMessage: 'Add a table of live streaming logs.', + }); + } + + public getIconType() { + return 'logsApp'; + } + public async getExplicitInput() { return { title: i18n.translate('xpack.infra.logStreamEmbeddable.title', { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts index ac5ff2094e22b4..4788d809f016ff 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { StartServicesAccessor } from 'kibana/public'; +import { PLUGIN_ICON, PLUGIN_ID, ML_APP_NAME } from '../../../common/constants/app'; import type { EmbeddableFactoryDefinition, IContainer, @@ -27,6 +28,14 @@ export class AnomalyChartsEmbeddableFactory implements EmbeddableFactoryDefinition { public readonly type = ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; + public readonly grouping = [ + { + id: PLUGIN_ID, + getDisplayName: () => ML_APP_NAME, + getIconType: () => PLUGIN_ICON, + }, + ]; + constructor( private getStartServices: StartServicesAccessor ) {} @@ -37,7 +46,13 @@ export class AnomalyChartsEmbeddableFactory public getDisplayName() { return i18n.translate('xpack.ml.components.mlAnomalyExplorerEmbeddable.displayName', { - defaultMessage: 'ML anomaly chart', + defaultMessage: 'Anomaly chart', + }); + } + + public getDescription() { + return i18n.translate('xpack.ml.components.mlAnomalyExplorerEmbeddable.description', { + defaultMessage: 'View anomaly detection results in a chart.', }); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index fdb2ef8527923b..bc45e075710c55 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { StartServicesAccessor } from 'kibana/public'; +import { PLUGIN_ID, PLUGIN_ICON, ML_APP_NAME } from '../../../common/constants/app'; import type { EmbeddableFactoryDefinition, IContainer, @@ -26,6 +27,14 @@ export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { public readonly type = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; + public readonly grouping = [ + { + id: PLUGIN_ID, + getDisplayName: () => ML_APP_NAME, + getIconType: () => PLUGIN_ICON, + }, + ]; + constructor( private getStartServices: StartServicesAccessor ) {} @@ -36,7 +45,13 @@ export class AnomalySwimlaneEmbeddableFactory public getDisplayName() { return i18n.translate('xpack.ml.components.jobAnomalyScoreEmbeddable.displayName', { - defaultMessage: 'ML anomaly swim lane', + defaultMessage: 'Anomaly swim lane', + }); + } + + public getDescription() { + return i18n.translate('xpack.ml.components.jobAnomalyScoreEmbeddable.description', { + defaultMessage: 'View anomaly detection results in a timeline.', }); } diff --git a/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts b/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts index deb91f6b9b1efc..51875c683346e4 100644 --- a/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts +++ b/x-pack/test/accessibility/apps/ml_embeddables_in_dashboard.ts @@ -96,8 +96,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('can open job selection flyout', async () => { await PageObjects.dashboard.clickCreateDashboardPrompt(); await ml.dashboardEmbeddables.assertDashboardIsEmpty(); - await dashboardAddPanel.clickOpenAddPanel(); - await dashboardAddPanel.ensureAddPanelIsShowing(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickEmbeddableFactoryGroupButton('ml'); await dashboardAddPanel.clickAddNewEmbeddableLink('ml_anomaly_charts'); await ml.dashboardJobSelectionTable.assertJobSelectionTableExists(); await a11y.testAppSnapshot(); diff --git a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts index 56a8ab46a57da7..87ecfe0dcada92 100644 --- a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts @@ -15,7 +15,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); describe('dashboard lens by value', function () { before(async () => { @@ -27,7 +26,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('can add a lens panel by value', async () => { - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await PageObjects.lens.createAndAddLensFromDashboard({}); const newPanelCount = await PageObjects.dashboard.getPanelCount(); expect(newPanelCount).to.eql(1); diff --git a/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts index 15c76c3367a86d..487dc90e1877ef 100644 --- a/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts @@ -19,10 +19,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const log = getService('log'); const esArchiver = getService('esArchiver'); - const dashboardVisualizations = getService('dashboardVisualizations'); const dashboardPanelActions = getService('dashboardPanelActions'); const testSubjects = getService('testSubjects'); const appsMenu = getService('appsMenu'); + const dashboardAddPanel = getService('dashboardAddPanel'); const LAYER_NAME = 'World Countries'; let mapCounter = 0; @@ -33,7 +33,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await PageObjects.visualize.clickMapsApp(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickVisType('maps'); await PageObjects.maps.clickSaveAndReturnButton(); } @@ -82,8 +83,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('adding a map by value', () => { it('can add a map by value', async () => { await createNewDashboard(); - - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await createAndAddMapByValue(); const newPanelCount = await PageObjects.dashboard.getPanelCount(); expect(newPanelCount).to.eql(1); @@ -93,7 +92,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('editing a map by value', () => { before(async () => { await createNewDashboard(); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await createAndAddMapByValue(); await editByValueMap(); }); @@ -112,7 +110,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('editing a map and adding to map library', () => { beforeEach(async () => { await createNewDashboard(); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await createAndAddMapByValue(); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index 3ebc53cc7cf270..730c00a8d5e4f1 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -21,7 +21,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'lens', ]); - const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardExpect = getService('dashboardExpect'); const testSubjects = getService('testSubjects'); @@ -85,7 +85,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('can add a lens panel by value', async () => { - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await PageObjects.lens.createAndAddLensFromDashboard({}); const newPanelCount = await PageObjects.dashboard.getPanelCount(); expect(newPanelCount).to.eql(1); @@ -171,9 +170,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.waitForRenderComplete(); - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMarkdownWidget(); + await dashboardAddPanel.clickMarkdownQuickButton(); await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); await PageObjects.visEditor.clickGo(); diff --git a/x-pack/test/functional/apps/dashboard/sync_colors.ts b/x-pack/test/functional/apps/dashboard/sync_colors.ts index 7e54f966870c3a..09575c355913e1 100644 --- a/x-pack/test/functional/apps/dashboard/sync_colors.ts +++ b/x-pack/test/functional/apps/dashboard/sync_colors.ts @@ -49,7 +49,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.dashboard.clickCreateDashboardPrompt(); await dashboardAddPanel.clickCreateNewLink(); - await dashboardAddPanel.clickVisType('lens'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.lens.goToTimeRange(); @@ -68,7 +67,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.save('vis1', false, true); await PageObjects.header.waitUntilLoadingHasFinished(); await dashboardAddPanel.clickCreateNewLink(); - await dashboardAddPanel.clickVisType('lens'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.lens.configureDimension({ diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js index 57925ad50d155e..37311de5341955 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const testSubjects = getService('testSubjects'); const esArchiver = getService('esArchiver'); - const dashboardVisualizations = getService('dashboardVisualizations'); const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); @@ -29,9 +28,6 @@ export default function ({ getPageObjects, getService }) { it('adds Lens visualization to empty dashboard', async () => { const title = 'Dashboard Test Lens'; - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await PageObjects.lens.createAndAddLensFromDashboard({ title, redirectToOrigin: true }); await PageObjects.dashboard.waitForRenderComplete(); await testSubjects.exists(`embeddablePanelHeading-${title}`); @@ -87,9 +83,6 @@ export default function ({ getPageObjects, getService }) { const title = 'non-dashboard Test Lens'; await PageObjects.dashboard.loadSavedDashboard('empty dashboard test'); await PageObjects.dashboard.switchToEditMode(); - await testSubjects.exists('dashboardAddNewPanelButton'); - await testSubjects.click('dashboardAddNewPanelButton'); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await PageObjects.lens.createAndAddLensFromDashboard({ title }); await PageObjects.lens.notLinkedToOriginatingApp(); await PageObjects.common.navigateToApp('dashboard'); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index a15176d76f9538..1490abb320ca64 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -134,7 +134,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.addFilter('geo.dest', 'is', 'LS'); await dashboardAddPanel.clickCreateNewLink(); - await dashboardAddPanel.clickVisType('lens'); await PageObjects.header.waitUntilLoadingHasFinished(); const hasGeoDestFilter = await filterBar.hasFilter('geo.dest', 'LS'); expect(hasGeoDestFilter).to.be(false); @@ -200,7 +199,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.clickCreateNewLink(); - await dashboardAddPanel.clickVisType('lens'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.lens.goToTimeRange(); diff --git a/x-pack/test/functional/apps/lens/lens_tagging.ts b/x-pack/test/functional/apps/lens/lens_tagging.ts index 7ce31709498fcc..6fff2baa2d0ccb 100644 --- a/x-pack/test/functional/apps/lens/lens_tagging.ts +++ b/x-pack/test/functional/apps/lens/lens_tagging.ts @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const retry = getService('retry'); const find = getService('find'); - const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); const PageObjects = getPageObjects([ 'common', @@ -39,8 +39,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a new tag to a Lens visualization', async () => { // create lens - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickLensWidget(); + await dashboardAddPanel.clickCreateNewLink(); await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', diff --git a/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js b/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js index 40e73f0d8a7632..9bff4e56c6c5be 100644 --- a/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js +++ b/x-pack/test/functional/apps/maps/embeddable/embeddable_library.js @@ -15,7 +15,6 @@ export default function ({ getPageObjects, getService }) { const security = getService('security'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); describe('maps in embeddable library', () => { before(async () => { @@ -34,8 +33,7 @@ export default function ({ getPageObjects, getService }) { }); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); - await dashboardAddPanel.clickCreateNewLink(); - await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await dashboardAddPanel.clickEditorMenuButton(); await PageObjects.visualize.clickMapsApp(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.maps.waitForLayersToLoad(); diff --git a/x-pack/test/functional/apps/maps/embeddable/save_and_return.js b/x-pack/test/functional/apps/maps/embeddable/save_and_return.js index a3abb01b4cf9f0..a7e649548306ba 100644 --- a/x-pack/test/functional/apps/maps/embeddable/save_and_return.js +++ b/x-pack/test/functional/apps/maps/embeddable/save_and_return.js @@ -11,7 +11,6 @@ export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'maps', 'visualize']); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardVisualizations = getService('dashboardVisualizations'); const testSubjects = getService('testSubjects'); const security = getService('security'); @@ -37,9 +36,8 @@ export default function ({ getPageObjects, getService }) { beforeEach(async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); - await dashboardAddPanel.clickCreateNewLink(); - await await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); - await PageObjects.visualize.clickMapsApp(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickVisType('maps'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.maps.waitForLayersToLoad(); }); diff --git a/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts b/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts index f7bfd7f7a4c62e..0aee183c1a4a56 100644 --- a/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts +++ b/x-pack/test/functional/apps/ml/embeddables/anomaly_charts_dashboard_embeddables.ts @@ -87,8 +87,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('can open job selection flyout', async () => { await PageObjects.dashboard.clickCreateDashboardPrompt(); await ml.dashboardEmbeddables.assertDashboardIsEmpty(); - await dashboardAddPanel.clickOpenAddPanel(); - await dashboardAddPanel.ensureAddPanelIsShowing(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickEmbeddableFactoryGroupButton('ml'); await dashboardAddPanel.clickAddNewEmbeddableLink('ml_anomaly_charts'); await ml.dashboardJobSelectionTable.assertJobSelectionTableExists(); }); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 65020be390f9d8..100ed8e079d379 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -18,6 +18,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont const find = getService('find'); const comboBox = getService('comboBox'); const browser = getService('browser'); + const dashboardAddPanel = getService('dashboardAddPanel'); const PageObjects = getPageObjects([ 'common', @@ -753,7 +754,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont if (inViewMode) { await PageObjects.dashboard.switchToEditMode(); } - await PageObjects.visualize.clickLensWidget(); + await dashboardAddPanel.clickCreateNewLink(); await this.goToTimeRange(); await this.configureDimension({ dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', From 1a3e033c90eb9217168073abddcca2e11220009a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Sun, 18 Apr 2021 12:50:02 +0300 Subject: [PATCH 10/17] [Partial Results] Move other bucket into Search Source (#96384) * Move inspector adapter integration into search source * docs and ts * Move other bucket to search source * test ts + delete unused tabilfy function * hierarchical param in aggconfig. ts improvements more inspector tests * fix jest * separate inspect more tests * jest * inspector * Error handling and more tests * put the fun in functional tests * code review * Add functional test for other bucket in search example app * test * test * ts * test * test * ts Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...ins-data-public.aggconfigs.hierarchical.md | 11 + ...a-plugin-plugins-data-public.aggconfigs.md | 3 +- ...in-plugins-data-public.aggconfigs.todsl.md | 9 +- ...s-data-public.isearchoptions.inspector.md} | 8 +- ...ugin-plugins-data-public.isearchoptions.md | 2 +- ...-plugins-data-public.searchsource.fetch.md | 4 +- ...plugins-data-public.searchsource.fetch_.md | 4 +- ...ins-data-public.searchsourcefields.aggs.md | 2 +- ...-plugins-data-public.searchsourcefields.md | 2 +- ...s-data-server.isearchoptions.inspector.md} | 8 +- ...ugin-plugins-data-server.isearchoptions.md | 2 +- examples/search_examples/public/index.scss | 6 + .../search_examples/public/search/app.tsx | 274 ++++++---- .../common/search/aggs/agg_configs.test.ts | 8 +- .../data/common/search/aggs/agg_configs.ts | 8 +- .../data/common/search/aggs/agg_type.ts | 31 +- .../_terms_other_bucket_helper.test.ts | 6 +- .../buckets/_terms_other_bucket_helper.ts | 13 +- .../data/common/search/aggs/buckets/terms.ts | 26 +- .../esaggs/request_handler.test.ts | 43 +- .../expressions/esaggs/request_handler.ts | 62 +-- .../search_source/inspect/inspector_stats.ts | 2 +- .../search_source/search_source.test.ts | 482 +++++++++++++++--- .../search/search_source/search_source.ts | 179 ++++++- .../data/common/search/search_source/types.ts | 3 +- .../data/common/search/tabify/index.ts | 23 +- src/plugins/data/common/search/types.ts | 15 +- src/plugins/data/public/public.api.md | 17 +- .../public/search/expressions/esaggs.test.ts | 7 +- .../data/public/search/expressions/esaggs.ts | 7 +- .../server/search/expressions/esaggs.test.ts | 6 +- .../data/server/search/expressions/esaggs.ts | 8 +- src/plugins/data/server/server.api.md | 8 +- .../public/application/angular/discover.js | 22 +- .../embeddable/search_embeddable.ts | 22 +- .../classes/sources/es_source/es_source.ts | 13 +- x-pack/test/examples/search_examples/index.ts | 3 +- .../search_examples/search_example.ts | 38 ++ 38 files changed, 964 insertions(+), 423 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md rename docs/development/plugins/data/public/{kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md => kibana-plugin-plugins-data-public.isearchoptions.inspector.md} (52%) rename docs/development/plugins/data/server/{kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md => kibana-plugin-plugins-data-server.isearchoptions.inspector.md} (52%) create mode 100644 x-pack/test/examples/search_examples/search_example.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md new file mode 100644 index 00000000000000..66d540c48c3bc8 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) + +## AggConfigs.hierarchical property + +Signature: + +```typescript +hierarchical?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md index 22f8994747aa29..02e9a63d95ba37 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md @@ -22,6 +22,7 @@ export declare class AggConfigs | --- | --- | --- | --- | | [aggs](./kibana-plugin-plugins-data-public.aggconfigs.aggs.md) | | IAggConfig[] | | | [createAggConfig](./kibana-plugin-plugins-data-public.aggconfigs.createaggconfig.md) | | <T extends AggConfig = AggConfig>(params: CreateAggConfigParams, { addToAggConfigs }?: {
addToAggConfigs?: boolean | undefined;
}) => T | | +| [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) | | boolean | | | [indexPattern](./kibana-plugin-plugins-data-public.aggconfigs.indexpattern.md) | | IndexPattern | | | [timeFields](./kibana-plugin-plugins-data-public.aggconfigs.timefields.md) | | string[] | | | [timeRange](./kibana-plugin-plugins-data-public.aggconfigs.timerange.md) | | TimeRange | | @@ -46,5 +47,5 @@ export declare class AggConfigs | [onSearchRequestStart(searchSource, options)](./kibana-plugin-plugins-data-public.aggconfigs.onsearchrequeststart.md) | | | | [setTimeFields(timeFields)](./kibana-plugin-plugins-data-public.aggconfigs.settimefields.md) | | | | [setTimeRange(timeRange)](./kibana-plugin-plugins-data-public.aggconfigs.settimerange.md) | | | -| [toDsl(hierarchical)](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | | +| [toDsl()](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md index 055c4113ca3e46..1327e976db0ce3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.todsl.md @@ -7,15 +7,8 @@ Signature: ```typescript -toDsl(hierarchical?: boolean): Record; +toDsl(): Record; ``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| hierarchical | boolean | | - Returns: `Record` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md similarity index 52% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md index b4431b9467b71e..9961292aaf2177 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.inspector.md @@ -1,11 +1,13 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [inspector](./kibana-plugin-plugins-data-public.isearchoptions.inspector.md) -## ISearchOptions.requestResponder property +## ISearchOptions.inspector property + +Inspector integration options Signature: ```typescript -requestResponder?: RequestResponder; +inspector?: IInspectorInfo; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md index cc0cb538be6113..21fb7e3dfc7e87 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md @@ -16,10 +16,10 @@ export interface ISearchOptions | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-public.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | | [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | +| [inspector](./kibana-plugin-plugins-data-public.isearchoptions.inspector.md) | IInspectorInfo | Inspector integration options | | [isRestore](./kibana-plugin-plugins-data-public.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-public.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-public.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | -| [requestResponder](./kibana-plugin-plugins-data-public.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-public.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-public.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index 623d6366d4d131..e6ba1a51a867d2 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -14,7 +14,7 @@ Fetch this source and reject the returned Promise on error Signature: ```typescript -fetch(options?: ISearchOptions): Promise>; +fetch(options?: ISearchOptions): Promise>; ``` ## Parameters @@ -25,5 +25,5 @@ fetch(options?: ISearchOptions): PromiseReturns: -`Promise>` +`Promise>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index d5641107a88aa1..4369cf7c087da7 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): import("rxjs").Observable>; +fetch$(options?: ISearchOptions): Observable>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): import("rxjs").ObservableReturns: -`import("rxjs").Observable>` +`Observable>` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md index f6bab8e424857d..12011f82429969 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md @@ -9,5 +9,5 @@ Signature: ```typescript -aggs?: any; +aggs?: object | IAggConfigs | (() => object); ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md index d0f53936eb56aa..981d956a9e89be 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.md @@ -16,7 +16,7 @@ export interface SearchSourceFields | Property | Type | Description | | --- | --- | --- | -| [aggs](./kibana-plugin-plugins-data-public.searchsourcefields.aggs.md) | any | [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) | +| [aggs](./kibana-plugin-plugins-data-public.searchsourcefields.aggs.md) | object | IAggConfigs | (() => object) | [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) | | [fields](./kibana-plugin-plugins-data-public.searchsourcefields.fields.md) | SearchFieldValue[] | Retrieve fields via the search Fields API | | [fieldsFromSource](./kibana-plugin-plugins-data-public.searchsourcefields.fieldsfromsource.md) | NameList | Retreive fields directly from \_source (legacy behavior) | | [filter](./kibana-plugin-plugins-data-public.searchsourcefields.filter.md) | Filter[] | Filter | (() => Filter[] | Filter | undefined) | [Filter](./kibana-plugin-plugins-data-public.filter.md) | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md similarity index 52% rename from docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md rename to docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md index 7440f5a9d26cfc..ab755334643aae 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.inspector.md @@ -1,11 +1,13 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [inspector](./kibana-plugin-plugins-data-server.isearchoptions.inspector.md) -## ISearchOptions.requestResponder property +## ISearchOptions.inspector property + +Inspector integration options Signature: ```typescript -requestResponder?: RequestResponder; +inspector?: IInspectorInfo; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 413a59be3d4278..cdb5664f96cddb 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -16,10 +16,10 @@ export interface ISearchOptions | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-server.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | | [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | +| [inspector](./kibana-plugin-plugins-data-server.isearchoptions.inspector.md) | IInspectorInfo | Inspector integration options | | [isRestore](./kibana-plugin-plugins-data-server.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-server.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-server.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | -| [requestResponder](./kibana-plugin-plugins-data-server.isearchoptions.requestresponder.md) | RequestResponder | | | [sessionId](./kibana-plugin-plugins-data-server.isearchoptions.sessionid.md) | string | A session ID, grouping multiple search requests into a single session. | | [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) | string | Use this option to force using a specific server side search strategy. Leave empty to use the default strategy. | diff --git a/examples/search_examples/public/index.scss b/examples/search_examples/public/index.scss index e69de29bb2d1d6..b623fecf78640f 100644 --- a/examples/search_examples/public/index.scss +++ b/examples/search_examples/public/index.scss @@ -0,0 +1,6 @@ +@import '@elastic/eui/src/global_styling/variables/header'; + +.searchExampleStepDsc { + padding-left: $euiSizeXL; + font-style: italic; +} diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 8f31d242faf5ea..b2a4991d0717b6 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -20,13 +20,13 @@ import { EuiTitle, EuiText, EuiFlexGrid, - EuiFlexGroup, EuiFlexItem, EuiCheckbox, EuiSpacer, EuiCode, EuiComboBox, EuiFormLabel, + EuiTabbedContent, } from '@elastic/eui'; import { CoreStart } from '../../../../src/core/public'; @@ -60,6 +60,11 @@ function getNumeric(fields?: IndexPatternField[]) { return fields?.filter((f) => f.type === 'number' && f.aggregatable); } +function getAggregatableStrings(fields?: IndexPatternField[]) { + if (!fields) return []; + return fields?.filter((f) => f.type === 'string' && f.aggregatable); +} + function formatFieldToComboBox(field?: IndexPatternField | null) { if (!field) return []; return formatFieldsToComboBox([field]); @@ -90,6 +95,9 @@ export const SearchExamplesApp = ({ const [selectedNumericField, setSelectedNumericField] = useState< IndexPatternField | null | undefined >(); + const [selectedBucketField, setSelectedBucketField] = useState< + IndexPatternField | null | undefined + >(); const [request, setRequest] = useState>({}); const [response, setResponse] = useState>({}); @@ -108,6 +116,7 @@ export const SearchExamplesApp = ({ setFields(indexPattern?.fields); }, [indexPattern]); useEffect(() => { + setSelectedBucketField(fields?.length ? getAggregatableStrings(fields)[0] : null); setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null); }, [fields]); @@ -203,28 +212,40 @@ export const SearchExamplesApp = ({ .setField('index', indexPattern) .setField('filter', filters) .setField('query', query) - .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']) + .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['']) + .setField('size', selectedFields.length ? 100 : 0) .setField('trackTotalHits', 100); - if (selectedNumericField) { - searchSource.setField('aggs', () => { - return data.search.aggs - .createAggConfigs(indexPattern, [ - { type: 'avg', params: { field: selectedNumericField.name } }, - ]) - .toDsl(); + const aggDef = []; + if (selectedBucketField) { + aggDef.push({ + type: 'terms', + schema: 'split', + params: { field: selectedBucketField.name, size: 2, otherBucket: true }, }); } + if (selectedNumericField) { + aggDef.push({ type: 'avg', params: { field: selectedNumericField.name } }); + } + if (aggDef.length > 0) { + const ac = data.search.aggs.createAggConfigs(indexPattern, aggDef); + searchSource.setField('aggs', ac); + } setRequest(searchSource.getSearchRequestBody()); const res = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; - notifications.toasts.addSuccess({ - title: 'Query result', - text: mountReactNode(message), - }); + notifications.toasts.addSuccess( + { + title: 'Query result', + text: mountReactNode(message), + }, + { + toastLifeTimeMs: 300000, + } + ); } catch (e) { setResponse(e.body); notifications.toasts.addWarning(`An error has occurred: ${e.message}`); @@ -263,6 +284,55 @@ export const SearchExamplesApp = ({ doSearchSourceSearch(); }; + const reqTabs = [ + { + id: 'request', + name: Request, + content: ( + <> + + Search body sent to ES + + {JSON.stringify(request, null, 2)} + + + ), + }, + { + id: 'response', + name: Response, + content: ( + <> + + + + + + {JSON.stringify(response, null, 2)} + + + ), + }, + ]; + return ( @@ -284,59 +354,75 @@ export const SearchExamplesApp = ({ useDefaultBehaviors={true} indexPatterns={indexPattern ? [indexPattern] : undefined} /> - + + + Index Pattern + { + const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); + setIndexPattern(newIndexPattern); + }} + isClearable={false} + /> + + + Field (bucket) + { + if (option.length) { + const fld = indexPattern?.getFieldByName(option[0].label); + setSelectedBucketField(fld || null); + } else { + setSelectedBucketField(null); + } + }} + sortMatchesBy="startsWith" + data-test-subj="searchBucketField" + /> + + + Numeric Field (metric) + { + if (option.length) { + const fld = indexPattern?.getFieldByName(option[0].label); + setSelectedNumericField(fld || null); + } else { + setSelectedNumericField(null); + } + }} + sortMatchesBy="startsWith" + data-test-subj="searchMetricField" + /> + + + Fields to queryString + { + const flds = option + .map((opt) => indexPattern?.getFieldByName(opt?.label)) + .filter((f) => f); + setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); + }} + sortMatchesBy="startsWith" + /> + + + - - - - Index Pattern - { - const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); - setIndexPattern(newIndexPattern); - }} - isClearable={false} - /> - - - Numeric Field to Aggregate - { - const fld = indexPattern?.getFieldByName(option[0].label); - setSelectedNumericField(fld || null); - }} - sortMatchesBy="startsWith" - /> - - - - - Fields to query (leave blank to include all fields) - { - const flds = option - .map((opt) => indexPattern?.getFieldByName(opt?.label)) - .filter((f) => f); - setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []); - }} - sortMatchesBy="startsWith" - /> - - -

@@ -352,15 +438,32 @@ export const SearchExamplesApp = ({ - + + + + + + + @@ -446,41 +549,8 @@ export const SearchExamplesApp = ({ - - -

Request

-
- Search body sent to ES - - {JSON.stringify(request, null, 2)} - -
- - -

Response

-
- - - - - {JSON.stringify(response, null, 2)} - + + diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts index 3ce528e6ed8932..28102544ae0553 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.test.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts @@ -342,8 +342,8 @@ describe('AggConfigs', () => { { enabled: true, type: 'max', schema: 'metric', params: { field: 'bytes' } }, ]; - const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); - const topLevelDsl = ac.toDsl(true); + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true }); + const topLevelDsl = ac.toDsl(); const buckets = ac.bySchemaName('buckets'); const metrics = ac.bySchemaName('metrics'); @@ -412,8 +412,8 @@ describe('AggConfigs', () => { }, ]; - const ac = new AggConfigs(indexPattern, configStates, { typesRegistry }); - const topLevelDsl = ac.toDsl(true)['2']; + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true }); + const topLevelDsl = ac.toDsl()['2']; expect(Object.keys(topLevelDsl.aggs)).toContain('1'); expect(Object.keys(topLevelDsl.aggs)).toContain('1-bucket'); diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts index 4d5d49754387d6..2932ef7325aed8 100644 --- a/src/plugins/data/common/search/aggs/agg_configs.ts +++ b/src/plugins/data/common/search/aggs/agg_configs.ts @@ -43,6 +43,7 @@ function parseParentAggs(dslLvlCursor: any, dsl: any) { export interface AggConfigsOptions { typesRegistry: AggTypesRegistryStart; + hierarchical?: boolean; } export type CreateAggConfigParams = Assign; @@ -65,6 +66,8 @@ export class AggConfigs { public indexPattern: IndexPattern; public timeRange?: TimeRange; public timeFields?: string[]; + public hierarchical?: boolean = false; + private readonly typesRegistry: AggTypesRegistryStart; aggs: IAggConfig[]; @@ -80,6 +83,7 @@ export class AggConfigs { this.aggs = []; this.indexPattern = indexPattern; + this.hierarchical = opts.hierarchical; configStates.forEach((params: any) => this.createAggConfig(params)); } @@ -174,12 +178,12 @@ export class AggConfigs { return true; } - toDsl(hierarchical: boolean = false): Record { + toDsl(): Record { const dslTopLvl = {}; let dslLvlCursor: Record; let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | []; - if (hierarchical) { + if (this.hierarchical) { // collect all metrics, and filter out the ones that we won't be copying nestedMetrics = this.aggs .filter(function (agg) { diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 33fdc45a605b71..f0f3912bf64fea 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -13,12 +13,23 @@ import { ISearchSource } from 'src/plugins/data/public'; import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; +import { estypes } from '@elastic/elasticsearch'; import { initParams } from './agg_params'; import { AggConfig } from './agg_config'; import { IAggConfigs } from './agg_configs'; import { BaseParamType } from './param_types/base'; import { AggParamType } from './param_types/agg'; +type PostFlightRequestFn = ( + resp: estypes.SearchResponse, + aggConfigs: IAggConfigs, + aggConfig: TAggConfig, + searchSource: ISearchSource, + inspectorRequestAdapter?: RequestAdapter, + abortSignal?: AbortSignal, + searchSessionId?: string +) => Promise>; + export interface AggTypeConfig< TAggConfig extends AggConfig = AggConfig, TParam extends AggParamType = AggParamType @@ -40,15 +51,7 @@ export interface AggTypeConfig< customLabels?: boolean; json?: boolean; decorateAggConfig?: () => any; - postFlightRequest?: ( - resp: any, - aggConfigs: IAggConfigs, - aggConfig: TAggConfig, - searchSource: ISearchSource, - inspectorRequestAdapter?: RequestAdapter, - abortSignal?: AbortSignal, - searchSessionId?: string - ) => Promise; + postFlightRequest?: PostFlightRequestFn; getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat; getValue?: (agg: TAggConfig, bucket: any) => any; getKey?: (bucket: any, key: any, agg: TAggConfig) => any; @@ -188,15 +191,7 @@ export class AggType< * @param searchSessionId - searchSessionId to be used for grouping requests into a single search session * @return {Promise} */ - postFlightRequest: ( - resp: any, - aggConfigs: IAggConfigs, - aggConfig: TAggConfig, - searchSource: ISearchSource, - inspectorRequestAdapter?: RequestAdapter, - abortSignal?: AbortSignal, - searchSessionId?: string - ) => Promise; + postFlightRequest: PostFlightRequestFn; /** * Get the serialized format for the values produced by this agg type, * overridden by several metrics that always output a simple number. diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 56e720d237c455..2aa0d346afe343 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -433,7 +433,7 @@ describe('Terms Agg Other bucket helper', () => { aggConfigs.aggs[0] as IBucketAggConfig, otherAggConfig() ); - expect(mergedResponse.aggregations['1'].buckets[3].key).toEqual('__other__'); + expect((mergedResponse!.aggregations!['1'] as any).buckets[3].key).toEqual('__other__'); } }); @@ -455,7 +455,7 @@ describe('Terms Agg Other bucket helper', () => { otherAggConfig() ); - expect(mergedResponse.aggregations['1'].buckets[1]['2'].buckets[3].key).toEqual( + expect((mergedResponse!.aggregations!['1'] as any).buckets[1]['2'].buckets[3].key).toEqual( '__other__' ); } @@ -471,7 +471,7 @@ describe('Terms Agg Other bucket helper', () => { aggConfigs.aggs[0] as IBucketAggConfig ); expect( - updatedResponse.aggregations['1'].buckets.find( + (updatedResponse!.aggregations!['1'] as any).buckets.find( (bucket: Record) => bucket.key === '__missing__' ) ).toBeDefined(); diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts index 742615bc49d8fa..6230ae897b1702 100644 --- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -7,6 +7,7 @@ */ import { isNumber, keys, values, find, each, cloneDeep, flatten } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; import { buildExistsFilter, buildPhrasesFilter, buildQueryFromFilters } from '../../../../common'; import { AggGroupNames } from '../agg_groups'; import { IAggConfigs } from '../agg_configs'; @@ -42,7 +43,7 @@ const getNestedAggDSL = (aggNestedDsl: Record, startFromAggId: stri */ const getAggResultBuckets = ( aggConfigs: IAggConfigs, - response: any, + response: estypes.SearchResponse['aggregations'], aggWithOtherBucket: IBucketAggConfig, key: string ) => { @@ -72,8 +73,8 @@ const getAggResultBuckets = ( } } } - if (responseAgg[aggWithOtherBucket.id]) { - return responseAgg[aggWithOtherBucket.id].buckets; + if (responseAgg?.[aggWithOtherBucket.id]) { + return (responseAgg[aggWithOtherBucket.id] as any).buckets; } return []; }; @@ -235,11 +236,11 @@ export const buildOtherBucketAgg = ( export const mergeOtherBucketAggResponse = ( aggsConfig: IAggConfigs, - response: any, + response: estypes.SearchResponse, otherResponse: any, otherAgg: IBucketAggConfig, requestAgg: Record -) => { +): estypes.SearchResponse => { const updatedResponse = cloneDeep(response); each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => { if (!bucket.doc_count || key === undefined) return; @@ -276,7 +277,7 @@ export const mergeOtherBucketAggResponse = ( }; export const updateMissingBucket = ( - response: any, + response: estypes.SearchResponse, aggConfigs: IAggConfigs, agg: IBucketAggConfig ) => { diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 77c9c6e391c0a0..03cf14a577a509 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,25 +101,21 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const requestResponder = inspectorRequestAdapter?.start( - i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { - defaultMessage: 'Other bucket', - }), - { - description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { - defaultMessage: - 'This request counts the number of documents that fall ' + - 'outside the criterion of the data buckets.', - }), - searchSessionId, - } - ); - const response = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, - requestResponder, + inspector: { + adapter: inspectorRequestAdapter, + title: i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', { + defaultMessage: 'Other bucket', + }), + description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', { + defaultMessage: + 'This request counts the number of documents that fall ' + + 'outside the criterion of the data buckets.', + }), + }, }) .toPromise(); diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index c2566535916a8b..b30e5740fa3fb0 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -9,7 +9,7 @@ import type { MockedKeys } from '@kbn/utility-types/jest'; import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; -import type { IAggConfig, IAggConfigs } from '../../aggs'; +import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; import { searchSourceCommonMock } from '../../search_source/mocks'; @@ -38,7 +38,6 @@ describe('esaggs expression function - public', () => { filters: undefined, indexPattern: ({ id: 'logstash-*' } as unknown) as jest.Mocked, inspectorAdapters: {}, - metricsAtAllLevels: false, partialRows: false, query: undefined, searchSessionId: 'abc123', @@ -76,21 +75,7 @@ describe('esaggs expression function - public', () => { test('setField(aggs)', async () => { expect(searchSource.setField).toHaveBeenCalledTimes(5); - expect(typeof (searchSource.setField as jest.Mock).mock.calls[2][1]).toBe('function'); - expect((searchSource.setField as jest.Mock).mock.calls[2][1]()).toEqual( - mockParams.aggs.toDsl() - ); - expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(mockParams.metricsAtAllLevels); - - // make sure param is passed through - jest.clearAllMocks(); - await handleRequest({ - ...mockParams, - metricsAtAllLevels: true, - }); - searchSource = await mockParams.searchSourceService.create(); - (searchSource.setField as jest.Mock).mock.calls[2][1](); - expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(true); + expect((searchSource.setField as jest.Mock).mock.calls[2][1]).toEqual(mockParams.aggs); }); test('setField(filter)', async () => { @@ -133,36 +118,24 @@ describe('esaggs expression function - public', () => { test('calls searchSource.fetch', async () => { await handleRequest(mockParams); const searchSource = await mockParams.searchSourceService.create(); + expect(searchSource.fetch$).toHaveBeenCalledWith({ abortSignal: mockParams.abortSignal, sessionId: mockParams.searchSessionId, + inspector: { + title: 'Data', + description: 'This request queries Elasticsearch to fetch the data for the visualization.', + adapter: undefined, + }, }); }); - test('calls agg.postFlightRequest if it exiests and agg is enabled', async () => { - mockParams.aggs.aggs[0].enabled = true; - await handleRequest(mockParams); - expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(1); - - // ensure it works if the function doesn't exist - jest.clearAllMocks(); - mockParams.aggs.aggs[0] = ({ type: { name: 'count' } } as unknown) as IAggConfig; - expect(async () => await handleRequest(mockParams)).not.toThrowError(); - }); - - test('should skip agg.postFlightRequest call if the agg is disabled', async () => { - mockParams.aggs.aggs[0].enabled = false; - await handleRequest(mockParams); - expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(0); - }); - test('tabifies response data', async () => { await handleRequest(mockParams); expect(tabifyAggResponse).toHaveBeenCalledWith( mockParams.aggs, {}, { - metricsAtAllLevels: mockParams.metricsAtAllLevels, partialRows: mockParams.partialRows, timeRange: mockParams.timeRange, } diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 5620698a475386..173b2067cad6bc 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -40,28 +40,12 @@ export interface RequestHandlerParams { getNow?: () => Date; } -function getRequestMainResponder(inspectorAdapters: Adapters, searchSessionId?: string) { - return inspectorAdapters.requests?.start( - i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { - defaultMessage: 'Data', - }), - { - description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { - defaultMessage: - 'This request queries Elasticsearch to fetch the data for the visualization.', - }), - searchSessionId, - } - ); -} - export const handleRequest = async ({ abortSignal, aggs, filters, indexPattern, inspectorAdapters, - metricsAtAllLevels, partialRows, query, searchSessionId, @@ -100,9 +84,7 @@ export const handleRequest = async ({ }, }); - requestSearchSource.setField('aggs', function () { - return aggs.toDsl(metricsAtAllLevels); - }); + requestSearchSource.setField('aggs', aggs); requestSearchSource.onRequestStart((paramSearchSource, options) => { return aggs.onSearchRequestStart(paramSearchSource, options); @@ -128,35 +110,27 @@ export const handleRequest = async ({ requestSearchSource.setField('query', query); inspectorAdapters.requests?.reset(); - const requestResponder = getRequestMainResponder(inspectorAdapters, searchSessionId); - const response$ = await requestSearchSource.fetch$({ - abortSignal, - sessionId: searchSessionId, - requestResponder, - }); - - // Note that rawResponse is not deeply cloned here, so downstream applications using courier - // must take care not to mutate it, or it could have unintended side effects, e.g. displaying - // response data incorrectly in the inspector. - let response = await response$.toPromise(); - for (const agg of aggs.aggs) { - if (agg.enabled && typeof agg.type.postFlightRequest === 'function') { - response = await agg.type.postFlightRequest( - response, - aggs, - agg, - requestSearchSource, - inspectorAdapters.requests, - abortSignal, - searchSessionId - ); - } - } + const response = await requestSearchSource + .fetch$({ + abortSignal, + sessionId: searchSessionId, + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { + defaultMessage: 'Data', + }), + description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the visualization.', + }), + }, + }) + .toPromise(); const parsedTimeRange = timeRange ? calculateBounds(timeRange, { forceNow }) : null; const tabifyParams = { - metricsAtAllLevels, + metricsAtAllLevels: aggs.hierarchical, partialRows, timeRange: parsedTimeRange ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields } diff --git a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts index 24507a7e13058e..e5a3acc23eee89 100644 --- a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts +++ b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts @@ -50,7 +50,7 @@ export function getRequestInspectorStats(searchSource: ISearchSource) { /** @public */ export function getResponseInspectorStats( - resp: estypes.SearchResponse, + resp?: estypes.SearchResponse, searchSource?: ISearchSource ) { const lastRequest = diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 3726e5d0c33e8c..7f8a4fceff05db 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -11,6 +11,10 @@ import { IndexPattern } from '../../index_patterns'; import { GetConfigFn } from '../../types'; import { fetchSoon } from './legacy'; import { SearchSource, SearchSourceDependencies, SortDirection } from './'; +import { AggConfigs, AggTypesRegistryStart } from '../../'; +import { mockAggTypesRegistry } from '../aggs/test_helpers'; +import { RequestResponder } from 'src/plugins/inspector/common'; +import { switchMap } from 'rxjs/operators'; jest.mock('./legacy', () => ({ fetchSoon: jest.fn().mockResolvedValue({}), @@ -39,6 +43,21 @@ const indexPattern2 = ({ getSourceFiltering: () => mockSource2, } as unknown) as IndexPattern; +const fields3 = [{ name: 'foo-bar' }, { name: 'field1' }, { name: 'field2' }]; +const indexPattern3 = ({ + title: 'foo', + fields: { + getByName: (name: string) => { + return fields3.find((field) => field.name === name); + }, + filter: () => { + return fields3; + }, + }, + getComputedFields, + getSourceFiltering: () => mockSource, +} as unknown) as IndexPattern; + const runtimeFieldDef = { type: 'keyword', script: { @@ -61,8 +80,8 @@ describe('SearchSource', () => { .fn() .mockReturnValue( of( - { rawResponse: { isPartial: true, isRunning: true } }, - { rawResponse: { isPartial: false, isRunning: false } } + { rawResponse: { test: 1 }, isPartial: true, isRunning: true }, + { rawResponse: { test: 2 }, isPartial: false, isRunning: false } ) ); @@ -81,17 +100,19 @@ describe('SearchSource', () => { describe('#getField()', () => { test('gets the value for the property', () => { - searchSource.setField('aggs', 5); - expect(searchSource.getField('aggs')).toBe(5); + searchSource.setField('aggs', { i: 5 }); + expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 }); }); }); describe('#getFields()', () => { test('gets the value for the property', () => { - searchSource.setField('aggs', 5); + searchSource.setField('aggs', { i: 5 }); expect(searchSource.getFields()).toMatchInlineSnapshot(` Object { - "aggs": 5, + "aggs": Object { + "i": 5, + }, } `); }); @@ -100,7 +121,7 @@ describe('SearchSource', () => { describe('#removeField()', () => { test('remove property', () => { searchSource = new SearchSource({}, searchSourceDependencies); - searchSource.setField('aggs', 5); + searchSource.setField('aggs', { i: 5 }); searchSource.removeField('aggs'); expect(searchSource.getField('aggs')).toBeFalsy(); }); @@ -108,8 +129,20 @@ describe('SearchSource', () => { describe('#setField() / #flatten', () => { test('sets the value for the property', () => { - searchSource.setField('aggs', 5); - expect(searchSource.getField('aggs')).toBe(5); + searchSource.setField('aggs', { i: 5 }); + expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 }); + }); + + test('sets the value for the property with AggConfigs', () => { + const typesRegistry = mockAggTypesRegistry(); + + const ac = new AggConfigs(indexPattern3, [{ type: 'avg', params: { field: 'field1' } }], { + typesRegistry, + }); + + searchSource.setField('aggs', ac); + const request = searchSource.getSearchRequestBody(); + expect(request.aggs).toStrictEqual({ '1': { avg: { field: 'field1' } } }); }); describe('computed fields handling', () => { @@ -631,7 +664,7 @@ describe('SearchSource', () => { const fn = jest.fn(); searchSource.onRequestStart(fn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); }); @@ -644,7 +677,7 @@ describe('SearchSource', () => { const parentFn = jest.fn(); parent.onRequestStart(parentFn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); expect(parentFn).not.toBeCalled(); @@ -664,69 +697,13 @@ describe('SearchSource', () => { const parentFn = jest.fn(); parent.onRequestStart(parentFn); const options = {}; - await searchSource.fetch(options); + await searchSource.fetch$(options).toPromise(); expect(fn).toBeCalledWith(searchSource, options); expect(parentFn).toBeCalledWith(searchSource, options); }); }); - describe('#legacy fetch()', () => { - beforeEach(() => { - searchSourceDependencies = { - ...searchSourceDependencies, - getConfig: jest.fn(() => { - return true; // batchSearches = true - }) as GetConfigFn, - }; - }); - - test('should call msearch', async () => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - await searchSource.fetch(options); - expect(fetchSoon).toBeCalledTimes(1); - }); - }); - - describe('#search service fetch()', () => { - test('should call msearch', async () => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - - await searchSource.fetch(options); - expect(mockSearchMethod).toBeCalledTimes(1); - }); - - test('should return partial results', (done) => { - searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); - const options = {}; - - const next = jest.fn(); - const complete = () => { - expect(next).toBeCalledTimes(2); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": true, - "isRunning": true, - }, - ] - `); - expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": false, - "isRunning": false, - }, - ] - `); - done(); - }; - searchSource.fetch$(options).subscribe({ next, complete }); - }); - }); - describe('#serialize', () => { test('should reference index patterns', () => { const indexPattern123 = { id: '123' } as IndexPattern; @@ -884,4 +861,373 @@ describe('SearchSource', () => { ); }); }); + + describe('fetch$', () => { + describe('#legacy fetch()', () => { + beforeEach(() => { + searchSourceDependencies = { + ...searchSourceDependencies, + getConfig: jest.fn(() => { + return true; // batchSearches = true + }) as GetConfigFn, + }; + }); + + test('should call msearch', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + await searchSource.fetch$(options).toPromise(); + expect(fetchSoon).toBeCalledTimes(1); + }); + }); + + describe('responses', () => { + test('should return partial results', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, complete }); + await res$.toPromise(); + + expect(next).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 1, + }, + ] + `); + expect(next.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 2, + }, + ] + `); + }); + + test('shareReplays result', async () => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = jest.fn(); + const next2 = jest.fn(); + const complete2 = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, complete }); + res$.subscribe({ next: next2, complete: complete2 }); + await res$.toPromise(); + + expect(next).toBeCalledTimes(2); + expect(next2).toBeCalledTimes(2); + expect(complete).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(1); + expect(searchSourceDependencies.search).toHaveBeenCalledTimes(1); + }); + + test('should emit error on empty response', async () => { + searchSourceDependencies.search = mockSearchMethod = jest + .fn() + .mockReturnValue( + of({ rawResponse: { test: 1 }, isPartial: true, isRunning: true }, undefined) + ); + + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const error = jest.fn(); + const complete = jest.fn(); + const res$ = searchSource.fetch$(options); + res$.subscribe({ next, error, complete }); + await res$.toPromise().catch((e) => {}); + + expect(next).toBeCalledTimes(1); + expect(error).toBeCalledTimes(1); + expect(complete).toBeCalledTimes(0); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "test": 1, + }, + ] + `); + expect(error.mock.calls[0][0]).toBe(undefined); + }); + }); + + describe('inspector', () => { + let requestResponder: RequestResponder; + beforeEach(() => { + requestResponder = ({ + stats: jest.fn(), + ok: jest.fn(), + error: jest.fn(), + json: jest.fn(), + } as unknown) as RequestResponder; + }); + + test('calls inspector if provided', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + await searchSource.fetch$(options).toPromise(); + + expect(options.inspector.adapter.start).toBeCalledTimes(1); + expect(requestResponder.error).not.toBeCalled(); + expect(requestResponder.json).toBeCalledTimes(1); + expect(requestResponder.ok).toBeCalledTimes(1); + // First and last + expect(requestResponder.stats).toBeCalledTimes(2); + }); + + test('calls inspector only once, with multiple subs (shareReplay)', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + const res$ = searchSource.fetch$(options); + + const complete1 = jest.fn(); + const complete2 = jest.fn(); + + res$.subscribe({ + complete: complete1, + }); + res$.subscribe({ + complete: complete2, + }); + + await res$.toPromise(); + + expect(complete1).toBeCalledTimes(1); + expect(complete2).toBeCalledTimes(1); + expect(options.inspector.adapter.start).toBeCalledTimes(1); + }); + + test('calls error on inspector', async () => { + const options = { + inspector: { + title: 'a', + adapter: { + start: jest.fn().mockReturnValue(requestResponder), + } as any, + }, + }; + + searchSourceDependencies.search = jest.fn().mockReturnValue(of(Promise.reject('aaaaa'))); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + await searchSource + .fetch$(options) + .toPromise() + .catch(() => {}); + + expect(options.inspector.adapter.start).toBeCalledTimes(1); + expect(requestResponder.json).toBeCalledTimes(1); + expect(requestResponder.error).toBeCalledTimes(1); + expect(requestResponder.ok).toBeCalledTimes(0); + expect(requestResponder.stats).toBeCalledTimes(0); + }); + }); + + describe('postFlightRequest', () => { + let fetchSub: any; + + function getAggConfigs(typesRegistry: AggTypesRegistryStart, enabled: boolean) { + return new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled, + params: { field: 'field1' }, + }, + ], + { + typesRegistry, + } + ); + } + + beforeEach(() => { + fetchSub = { + next: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + }; + }); + + test('doesnt call any post flight requests if disabled', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn(); + const ac = getAggConfigs(typesRegistry, false); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(2); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(fetchSub.error).toHaveBeenCalledTimes(0); + + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0); + }); + + test('doesnt call any post flight if searchsource has error', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn(); + const ac = getAggConfigs(typesRegistry, true); + + searchSourceDependencies.search = jest.fn().mockImplementation(() => + of(1).pipe( + switchMap((r) => { + throw r; + }) + ) + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + await fetch$.toPromise().catch((e) => {}); + + expect(fetchSub.next).toHaveBeenCalledTimes(0); + expect(fetchSub.complete).toHaveBeenCalledTimes(0); + expect(fetchSub.error).toHaveBeenNthCalledWith(1, 1); + + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0); + }); + + test('calls post flight requests, fires 1 extra response, returns last response', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({ + other: 5, + }); + + const allac = new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled: true, + params: { field: 'field1' }, + }, + { + type: 'avg', + enabled: true, + params: { field: 'field2' }, + }, + { + type: 'avg', + enabled: true, + params: { field: 'foo-bar' }, + }, + ], + { + typesRegistry, + } + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', allac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + const resp = await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(3); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(fetchSub.error).toHaveBeenCalledTimes(0); + expect(resp).toStrictEqual({ other: 5 }); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); + }); + + test('calls post flight requests only once, with multiple subs (shareReplay)', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({ + other: 5, + }); + + const allac = new AggConfigs( + indexPattern3, + [ + { + type: 'avg', + enabled: true, + params: { field: 'field1' }, + }, + ], + { + typesRegistry, + } + ); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', allac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + const fetchSub2 = { + next: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + }; + fetch$.subscribe(fetchSub2); + + await fetch$.toPromise(); + + expect(fetchSub.next).toHaveBeenCalledTimes(3); + expect(fetchSub.complete).toHaveBeenCalledTimes(1); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1); + }); + + test('calls post flight requests, handles error', async () => { + const typesRegistry = mockAggTypesRegistry(); + typesRegistry.get('avg').postFlightRequest = jest.fn().mockRejectedValue(undefined); + const ac = getAggConfigs(typesRegistry, true); + + searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('index', indexPattern); + searchSource.setField('aggs', ac); + const fetch$ = searchSource.fetch$({}); + fetch$.subscribe(fetchSub); + + await fetch$.toPromise().catch(() => {}); + + expect(fetchSub.next).toHaveBeenCalledTimes(2); + expect(fetchSub.complete).toHaveBeenCalledTimes(0); + expect(fetchSub.error).toHaveBeenCalledTimes(1); + expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index e1e7a8292d6773..1c1c32228703f1 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,12 +60,22 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, isFunction, isEqual, uniqWith, isObject } from 'lodash'; -import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators'; -import { defer, from } from 'rxjs'; +import { + catchError, + finalize, + first, + last, + map, + shareReplay, + switchMap, + tap, +} from 'rxjs/operators'; +import { defer, EMPTY, from, Observable } from 'rxjs'; +import { estypes } from '@elastic/elasticsearch'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns'; -import { ISearchGeneric, ISearchOptions } from '../..'; +import { AggConfigs, ISearchGeneric, ISearchOptions } from '../..'; import type { ISearchSource, SearchFieldValue, @@ -75,7 +85,15 @@ import type { import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch'; import { getRequestInspectorStats, getResponseInspectorStats } from './inspect'; -import { getEsQueryConfig, buildEsQuery, Filter, UI_SETTINGS } from '../../../common'; +import { + getEsQueryConfig, + buildEsQuery, + Filter, + UI_SETTINGS, + isErrorResponse, + isPartialResponse, + IKibanaSearchResponse, +} from '../../../common'; import { getHighlightRequest } from '../../../common/field_formats'; import { fetchSoon } from './legacy'; import { extractReferences } from './extract_references'; @@ -256,10 +274,8 @@ export class SearchSource { */ fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; - return defer(() => this.requestIsStarting(options)).pipe( - tap(() => { - options.requestResponder?.stats(getRequestInspectorStats(this)); - }), + + const s$ = defer(() => this.requestIsStarting(options)).pipe( switchMap(() => { const searchRequest = this.flatten(); this.history = [searchRequest]; @@ -273,21 +289,14 @@ export class SearchSource { }), tap((response) => { // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved - if ((response as any).error) { + if (!response || (response as any).error) { throw new RequestFailure(null, response); - } else { - options.requestResponder?.stats(getResponseInspectorStats(response, this)); - options.requestResponder?.ok({ json: response }); } }), - catchError((e) => { - options.requestResponder?.error({ json: e }); - throw e; - }), - finalize(() => { - options.requestResponder?.json(this.getSearchRequestBody()); - }) + shareReplay() ); + + return this.inspectSearch(s$, options); } /** @@ -328,9 +337,96 @@ export class SearchSource { * PRIVATE APIS ******/ + private inspectSearch(s$: Observable>, options: ISearchOptions) { + const { id, title, description, adapter } = options.inspector || { title: '' }; + + const requestResponder = adapter?.start(title, { + id, + description, + searchSessionId: options.sessionId, + }); + + const trackRequestBody = () => { + try { + requestResponder?.json(this.getSearchRequestBody()); + } catch (e) {} // eslint-disable-line no-empty + }; + + // Track request stats on first emit, swallow errors + const first$ = s$ + .pipe( + first(undefined, null), + tap(() => { + requestResponder?.stats(getRequestInspectorStats(this)); + trackRequestBody(); + }), + catchError(() => { + trackRequestBody(); + return EMPTY; + }), + finalize(() => { + first$.unsubscribe(); + }) + ) + .subscribe(); + + // Track response stats on last emit, as well as errors + const last$ = s$ + .pipe( + catchError((e) => { + requestResponder?.error({ json: e }); + return EMPTY; + }), + last(undefined, null), + tap((finalResponse) => { + if (finalResponse) { + requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.ok({ json: finalResponse }); + } + }), + finalize(() => { + last$.unsubscribe(); + }) + ) + .subscribe(); + + return s$; + } + + private hasPostFlightRequests() { + const aggs = this.getField('aggs'); + if (aggs instanceof AggConfigs) { + return aggs.aggs.some( + (agg) => agg.enabled && typeof agg.type.postFlightRequest === 'function' + ); + } else { + return false; + } + } + + private async fetchOthers(response: estypes.SearchResponse, options: ISearchOptions) { + const aggs = this.getField('aggs'); + if (aggs instanceof AggConfigs) { + for (const agg of aggs.aggs) { + if (agg.enabled && typeof agg.type.postFlightRequest === 'function') { + response = await agg.type.postFlightRequest( + response, + aggs, + agg, + this, + options.inspector?.adapter, + options.abortSignal, + options.sessionId + ); + } + } + return response; + } + } + /** * Run a search using the search service - * @return {Promise>} + * @return {Observable>} */ private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) { const { search, getConfig, onResponse } = this.dependencies; @@ -340,6 +436,43 @@ export class SearchSource { }); return search({ params, indexType: searchRequest.indexType }, options).pipe( + switchMap((response) => { + return new Observable>((obs) => { + if (isErrorResponse(response)) { + obs.error(response); + } else if (isPartialResponse(response)) { + obs.next(response); + } else { + if (!this.hasPostFlightRequests()) { + obs.next(response); + obs.complete(); + } else { + // Treat the complete response as partial, then run the postFlightRequests. + obs.next({ + ...response, + isPartial: true, + isRunning: true, + }); + const sub = from(this.fetchOthers(response.rawResponse, options)).subscribe({ + next: (responseWithOther) => { + obs.next({ + ...response, + rawResponse: responseWithOther, + }); + }, + error: (e) => { + obs.error(e); + sub.unsubscribe(); + }, + complete: () => { + obs.complete(); + sub.unsubscribe(); + }, + }); + } + } + }); + }), map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) ); } @@ -452,6 +585,12 @@ export class SearchSource { getConfig(UI_SETTINGS.SORT_OPTIONS) ); return addToBody(key, sort); + case 'aggs': + if ((val as any) instanceof AggConfigs) { + return addToBody('aggs', val.toDsl()); + } else { + return addToBody('aggs', val); + } default: return addToBody(key, val); } diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index a178b38693d92e..99f3f67a5e257c 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -7,6 +7,7 @@ */ import { NameList } from 'elasticsearch'; +import { IAggConfigs } from 'src/plugins/data/public'; import { Query } from '../..'; import { Filter } from '../../es_query'; import { IndexPattern } from '../../index_patterns'; @@ -78,7 +79,7 @@ export interface SearchSourceFields { /** * {@link AggConfigs} */ - aggs?: any; + aggs?: object | IAggConfigs | (() => object); from?: number; size?: number; source?: NameList; diff --git a/src/plugins/data/common/search/tabify/index.ts b/src/plugins/data/common/search/tabify/index.ts index 168d4cf9d4c370..74fbc7ba4cfa4a 100644 --- a/src/plugins/data/common/search/tabify/index.ts +++ b/src/plugins/data/common/search/tabify/index.ts @@ -6,27 +6,6 @@ * Side Public License, v 1. */ -import { SearchResponse } from 'elasticsearch'; -import { SearchSource } from '../search_source'; -import { tabifyAggResponse } from './tabify'; -import { tabifyDocs, TabifyDocsOptions } from './tabify_docs'; -import { TabbedResponseWriterOptions } from './types'; - -export const tabify = ( - searchSource: SearchSource, - esResponse: SearchResponse, - opts: Partial | TabifyDocsOptions -) => { - return !esResponse.aggregations - ? tabifyDocs(esResponse, searchSource.getField('index'), opts as TabifyDocsOptions) - : tabifyAggResponse( - searchSource.getField('aggs'), - esResponse, - opts as Partial - ); -}; - -export { tabifyDocs }; - +export { tabifyDocs } from './tabify_docs'; export { tabifyAggResponse } from './tabify'; export { tabifyGetColumns } from './get_columns'; diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 37de8dc49d3c6a..e3ec499a0020db 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -9,7 +9,7 @@ import { Observable } from 'rxjs'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; import { IndexPattern } from '..'; -import type { RequestResponder } from '../../../inspector/common'; +import type { RequestAdapter } from '../../../inspector/common'; export type ISearchGeneric = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, @@ -81,6 +81,13 @@ export interface IKibanaSearchRequest { params?: Params; } +export interface IInspectorInfo { + adapter?: RequestAdapter; + title: string; + id?: string; + description?: string; +} + export interface ISearchOptions { /** * An `AbortSignal` that allows the caller of `search` to abort a search request. @@ -117,10 +124,12 @@ export interface ISearchOptions { /** * Index pattern reference is used for better error messages */ - indexPattern?: IndexPattern; - requestResponder?: RequestResponder; + /** + * Inspector integration options + */ + inspector?: IInspectorInfo; } /** diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 35f13fc855e998..0dd06691d68bbd 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -46,6 +46,7 @@ import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_ import { History } from 'history'; import { Href } from 'history'; import { HttpSetup } from 'kibana/public'; +import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; import { IconType } from '@elastic/eui'; import { IncomingHttpHeaders } from 'http'; import { InjectedIntl } from '@kbn/i18n/react'; @@ -254,6 +255,8 @@ export class AggConfigs { getResponseAggById(id: string): AggConfig | undefined; getResponseAggs(): AggConfig[]; // (undocumented) + hierarchical?: boolean; + // (undocumented) indexPattern: IndexPattern; jsonDataEquals(aggConfigs: AggConfig[]): boolean; // (undocumented) @@ -267,7 +270,7 @@ export class AggConfigs { // (undocumented) timeRange?: TimeRange; // (undocumented) - toDsl(hierarchical?: boolean): Record; + toDsl(): Record; } // @internal (undocumented) @@ -1672,13 +1675,11 @@ export type ISearchGeneric = >; + fetch$(options?: ISearchOptions): Observable>; // @deprecated - fetch(options?: ISearchOptions): Promise>; + fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): SearchSourceFields; getId(): string; @@ -2462,7 +2463,7 @@ export class SearchSource { // @public export interface SearchSourceFields { // (undocumented) - aggs?: any; + aggs?: object | IAggConfigs_2 | (() => object); // Warning: (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts fields?: SearchFieldValue[]; // @deprecated diff --git a/src/plugins/data/public/search/expressions/esaggs.test.ts b/src/plugins/data/public/search/expressions/esaggs.test.ts index d7a6446781c437..e75bd7be219de9 100644 --- a/src/plugins/data/public/search/expressions/esaggs.test.ts +++ b/src/plugins/data/public/search/expressions/esaggs.test.ts @@ -100,17 +100,20 @@ describe('esaggs expression function - public', () => { expect(handleEsaggsRequest).toHaveBeenCalledWith({ abortSignal: mockHandlers.abortSignal, - aggs: { foo: 'bar' }, + aggs: { + foo: 'bar', + hierarchical: true, + }, filters: undefined, indexPattern: {}, inspectorAdapters: mockHandlers.inspectorAdapters, - metricsAtAllLevels: args.metricsAtAllLevels, partialRows: args.partialRows, query: undefined, searchSessionId: 'abc123', searchSourceService: startDependencies.searchSource, timeFields: args.timeFields, timeRange: undefined, + getNow: undefined, }); }); diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 45d24af3a6ebb5..1e3d56c71e423b 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -8,7 +8,6 @@ import { get } from 'lodash'; import { StartServicesAccessor } from 'src/core/public'; -import { Adapters } from 'src/plugins/inspector/common'; import { EsaggsExpressionFunctionDefinition, EsaggsStartDependencies, @@ -44,14 +43,14 @@ export function getFunctionDefinition({ indexPattern, args.aggs!.map((agg) => agg.value) ); + aggConfigs.hierarchical = args.metricsAtAllLevels; return await handleEsaggsRequest({ - abortSignal: (abortSignal as unknown) as AbortSignal, + abortSignal, aggs: aggConfigs, filters: get(input, 'filters', undefined), indexPattern, - inspectorAdapters: inspectorAdapters as Adapters, - metricsAtAllLevels: args.metricsAtAllLevels, + inspectorAdapters, partialRows: args.partialRows, query: get(input, 'query', undefined) as any, searchSessionId: getSearchSessionId(), diff --git a/src/plugins/data/server/search/expressions/esaggs.test.ts b/src/plugins/data/server/search/expressions/esaggs.test.ts index 124a171de63782..15287e9d8cf5bd 100644 --- a/src/plugins/data/server/search/expressions/esaggs.test.ts +++ b/src/plugins/data/server/search/expressions/esaggs.test.ts @@ -108,11 +108,13 @@ describe('esaggs expression function - server', () => { expect(handleEsaggsRequest).toHaveBeenCalledWith({ abortSignal: mockHandlers.abortSignal, - aggs: { foo: 'bar' }, + aggs: { + foo: 'bar', + hierarchical: args.metricsAtAllLevels, + }, filters: undefined, indexPattern: {}, inspectorAdapters: mockHandlers.inspectorAdapters, - metricsAtAllLevels: args.metricsAtAllLevels, partialRows: args.partialRows, query: undefined, searchSessionId: 'abc123', diff --git a/src/plugins/data/server/search/expressions/esaggs.ts b/src/plugins/data/server/search/expressions/esaggs.ts index 61fd320d89b951..bb22a491b157e9 100644 --- a/src/plugins/data/server/search/expressions/esaggs.ts +++ b/src/plugins/data/server/search/expressions/esaggs.ts @@ -9,7 +9,6 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, StartServicesAccessor } from 'src/core/server'; -import { Adapters } from 'src/plugins/inspector/common'; import { EsaggsExpressionFunctionDefinition, EsaggsStartDependencies, @@ -61,13 +60,14 @@ export function getFunctionDefinition({ args.aggs!.map((agg) => agg.value) ); + aggConfigs.hierarchical = args.metricsAtAllLevels; + return await handleEsaggsRequest({ - abortSignal: (abortSignal as unknown) as AbortSignal, + abortSignal, aggs: aggConfigs, filters: get(input, 'filters', undefined), indexPattern, - inspectorAdapters: inspectorAdapters as Adapters, - metricsAtAllLevels: args.metricsAtAllLevels, + inspectorAdapters, partialRows: args.partialRows, query: get(input, 'query', undefined) as any, searchSessionId: getSearchSessionId(), diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 622356c4441ac3..3316e8102e50ac 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -26,12 +26,14 @@ import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; import { ErrorToastOptions } from 'src/core/public/notifications'; import { estypes } from '@elastic/elasticsearch'; +import { EventEmitter } from 'events'; import { ExecutionContext } from 'src/plugins/expressions/common'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; +import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public'; import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; @@ -999,13 +1001,11 @@ export interface IScopedSearchClient extends ISearchClient { export interface ISearchOptions { abortSignal?: AbortSignal; indexPattern?: IndexPattern; + // Warning: (ae-forgotten-export) The symbol "IInspectorInfo" needs to be exported by the entry point index.d.ts + inspector?: IInspectorInfo; isRestore?: boolean; isStored?: boolean; legacyHitsTotal?: boolean; - // Warning: (ae-forgotten-export) The symbol "RequestResponder" needs to be exported by the entry point index.d.ts - // - // (undocumented) - requestResponder?: RequestResponder; sessionId?: string; strategy?: string; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 35a89eb45f35ee..4099d5e8ef7e29 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -415,11 +415,20 @@ function discoverController($route, $scope) { $scope.fetchStatus = fetchStatuses.LOADING; $scope.resultState = getResultState($scope.fetchStatus, $scope.rows); + inspectorAdapters.requests.reset(); return $scope.volatileSearchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, - requestResponder: getRequestResponder({ searchSessionId }), + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.inspectorRequestDataTitle', { + defaultMessage: 'data', + }), + description: i18n.translate('discover.inspectorRequestDescription', { + defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', + }), + }, }) .toPromise() .then(onResults) @@ -465,17 +474,6 @@ function discoverController($route, $scope) { await refetch$.next(); }; - function getRequestResponder({ searchSessionId = null } = { searchSessionId: null }) { - inspectorAdapters.requests.reset(); - const title = i18n.translate('discover.inspectorRequestDataTitle', { - defaultMessage: 'data', - }); - const description = i18n.translate('discover.inspectorRequestDescription', { - defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', - }); - return inspectorAdapters.requests.start(title, { description, searchSessionId }); - } - $scope.resetQuery = function () { history.push( $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index 237da72ae3a523..dbaf07fed18c29 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -317,17 +317,6 @@ export class SearchEmbeddable // Log request to inspector this.inspectorAdapters.requests!.reset(); - const title = i18n.translate('discover.embeddable.inspectorRequestDataTitle', { - defaultMessage: 'Data', - }); - const description = i18n.translate('discover.embeddable.inspectorRequestDescription', { - defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', - }); - - const requestResponder = this.inspectorAdapters.requests!.start(title, { - description, - searchSessionId, - }); this.searchScope.$apply(() => { this.searchScope!.isLoading = true; @@ -340,7 +329,16 @@ export class SearchEmbeddable .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, - requestResponder, + inspector: { + adapter: this.inspectorAdapters.requests, + title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', { + defaultMessage: 'Data', + }), + description: i18n.translate('discover.embeddable.inspectorRequestDescription', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the search.', + }), + }, }) .toPromise(); this.updateOutput({ loading: false, error: undefined }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 2915eaec8ac776..50043772af95bb 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,12 +167,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const requestResponder = this.getInspectorAdapters()?.requests?.start(requestName, { - id: requestId, - description: requestDescription, - searchSessionId, - }); - let resp; try { resp = await searchSource @@ -180,7 +174,12 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource abortSignal: abortController.signal, sessionId: searchSessionId, legacyHitsTotal: false, - requestResponder, + inspector: { + adapter: this.getInspectorAdapters()?.requests, + id: requestId, + title: requestName, + description: requestDescription, + }, }) .toPromise(); } catch (error) { diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index 13eac7566525e2..65e214cda4cf8c 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -23,7 +23,8 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC await esArchiver.unload('lens/basic'); }); - loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./search_session_example')); + loadTestFile(require.resolve('./search_example')); + loadTestFile(require.resolve('./search_sessions_cache')); }); } diff --git a/x-pack/test/examples/search_examples/search_example.ts b/x-pack/test/examples/search_examples/search_example.ts new file mode 100644 index 00000000000000..19a9535ebb9510 --- /dev/null +++ b/x-pack/test/examples/search_examples/search_example.ts @@ -0,0 +1,38 @@ +/* + * 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 { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'timePicker']); + const retry = getService('retry'); + + describe.skip('Search session example', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + }); + + it('should have an other bucket', async () => { + await PageObjects.timePicker.setAbsoluteRange( + 'Jan 1, 2014 @ 00:00:00.000', + 'Jan 1, 2016 @ 00:00:00.000' + ); + await testSubjects.click('searchSourceWithOther'); + + await retry.waitFor('has other bucket', async () => { + await testSubjects.click('responseTab'); + const codeBlock = await testSubjects.find('responseCodeBlock'); + const visibleText = await codeBlock.getVisibleText(); + return visibleText.indexOf('__other__') > -1; + }); + }); + }); +} From fed17c2b6e71deefb4ff30a1eabff1cb485de283 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Sun, 18 Apr 2021 16:40:54 +0200 Subject: [PATCH 11/17] Rule registry bundle size (#97251) --- x-pack/plugins/apm/common/alert_types.ts | 6 +-- .../plugins/apm/common/anomaly_detection.ts | 2 +- x-pack/plugins/apm/common/ml_constants.ts | 24 ++++++++++++ x-pack/plugins/apm/common/rules.ts | 25 ------------ .../apm/common/rules/apm_rule_field_map.ts | 20 ++++++++++ .../rules/apm_rule_registry_settings.ts | 10 +++++ .../apm/common/service_health_status.ts | 2 +- .../alerting/register_apm_alerts.ts | 19 +++++++--- .../index.tsx | 2 +- .../select_anomaly_severity.test.tsx | 2 +- x-pack/plugins/apm/public/plugin.ts | 38 ++++++++++--------- ...action_duration_anomaly_alert_type.test.ts | 2 +- ...transaction_duration_anomaly_alert_type.ts | 2 +- .../transactions/get_anomaly_data/index.ts | 2 +- x-pack/plugins/apm/server/plugin.ts | 10 +++-- .../common/observability_rule_registry.ts | 22 ----------- .../rules/observability_rule_field_map.ts | 22 +++++++++++ .../observability_rule_registry_settings.ts | 10 +++++ .../public/pages/alerts/index.tsx | 3 +- x-pack/plugins/observability/public/plugin.ts | 24 ++++++------ .../public/rules/formatter_rule_registry.ts | 5 +++ x-pack/plugins/observability/server/plugin.ts | 10 +++-- x-pack/plugins/rule_registry/kibana.json | 5 +-- x-pack/plugins/rule_registry/public/index.ts | 4 +- x-pack/plugins/rule_registry/public/plugin.ts | 4 +- .../public/rule_registry/types.ts | 4 +- 26 files changed, 169 insertions(+), 110 deletions(-) create mode 100644 x-pack/plugins/apm/common/ml_constants.ts delete mode 100644 x-pack/plugins/apm/common/rules.ts create mode 100644 x-pack/plugins/apm/common/rules/apm_rule_field_map.ts create mode 100644 x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts delete mode 100644 x-pack/plugins/observability/common/observability_rule_registry.ts create mode 100644 x-pack/plugins/observability/common/rules/observability_rule_field_map.ts create mode 100644 x-pack/plugins/observability/common/rules/observability_rule_registry_settings.ts diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 62bd07ce6f500b..12df93d54b2964 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -6,9 +6,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ValuesType } from 'utility-types'; -import { ActionGroup } from '../../alerting/common'; -import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../../ml/common'; +import type { ValuesType } from 'utility-types'; +import type { ActionGroup } from '../../alerting/common'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './ml_constants'; export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index b9cc3de8bb5d0a..43a779407d2a49 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { ANOMALY_SEVERITY } from '../../ml/common'; +import { ANOMALY_SEVERITY } from './ml_constants'; import { getSeverityType, getSeverityColor as mlGetSeverityColor, diff --git a/x-pack/plugins/apm/common/ml_constants.ts b/x-pack/plugins/apm/common/ml_constants.ts new file mode 100644 index 00000000000000..7818299d9d883a --- /dev/null +++ b/x-pack/plugins/apm/common/ml_constants.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// copied from ml/common, to keep the bundle size small +export enum ANOMALY_SEVERITY { + CRITICAL = 'critical', + MAJOR = 'major', + MINOR = 'minor', + WARNING = 'warning', + LOW = 'low', + UNKNOWN = 'unknown', +} + +export enum ANOMALY_THRESHOLD { + CRITICAL = 75, + MAJOR = 50, + MINOR = 25, + WARNING = 3, + LOW = 0, +} diff --git a/x-pack/plugins/apm/common/rules.ts b/x-pack/plugins/apm/common/rules.ts deleted file mode 100644 index a3b60a785f5c7f..00000000000000 --- a/x-pack/plugins/apm/common/rules.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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. - */ - -const plainApmRuleRegistrySettings = { - name: 'apm', - fieldMap: { - 'service.environment': { - type: 'keyword', - }, - 'transaction.type': { - type: 'keyword', - }, - 'processor.event': { - type: 'keyword', - }, - }, -} as const; - -type APMRuleRegistrySettings = typeof plainApmRuleRegistrySettings; - -export const apmRuleRegistrySettings: APMRuleRegistrySettings = plainApmRuleRegistrySettings; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts b/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts new file mode 100644 index 00000000000000..9bbd9381c2319a --- /dev/null +++ b/x-pack/plugins/apm/common/rules/apm_rule_field_map.ts @@ -0,0 +1,20 @@ +/* + * 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 const apmRuleFieldMap = { + 'service.environment': { + type: 'keyword', + }, + 'transaction.type': { + type: 'keyword', + }, + 'processor.event': { + type: 'keyword', + }, +} as const; + +export type APMRuleFieldMap = typeof apmRuleFieldMap; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts b/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts new file mode 100644 index 00000000000000..1257db4e6a4d34 --- /dev/null +++ b/x-pack/plugins/apm/common/rules/apm_rule_registry_settings.ts @@ -0,0 +1,10 @@ +/* + * 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 const apmRuleRegistrySettings = { + name: 'apm', +}; diff --git a/x-pack/plugins/apm/common/service_health_status.ts b/x-pack/plugins/apm/common/service_health_status.ts index 71c373a48c9d5f..b5318f9333e4f2 100644 --- a/x-pack/plugins/apm/common/service_health_status.ts +++ b/x-pack/plugins/apm/common/service_health_status.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { EuiTheme } from '../../../../src/plugins/kibana_react/common'; -import { ANOMALY_SEVERITY } from '../../ml/common'; +import { ANOMALY_SEVERITY } from './ml_constants'; export enum ServiceHealthStatus { healthy = 'healthy', diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 8834cbc70e0b1a..583be94c30a345 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -7,11 +7,20 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { format } from 'url'; +import { stringify } from 'querystring'; import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; -import { asDuration, asPercent } from '../../../common/utils/formatters'; import { AlertType } from '../../../common/alert_types'; -import { ApmRuleRegistry } from '../../plugin'; +import type { ApmRuleRegistry } from '../../plugin'; + +const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { apmRuleRegistry.registerType({ @@ -71,7 +80,7 @@ export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { 'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.', } ), - format: ({ alert }) => ({ + format: ({ alert, formatters: { asDuration } }) => ({ reason: i18n.translate( 'xpack.apm.alertTypes.transactionDuration.reason', { @@ -131,7 +140,7 @@ export function registerApmAlerts(apmRuleRegistry: ApmRuleRegistry) { 'Alert when the rate of transaction errors in a service exceeds a defined threshold.', } ), - format: ({ alert }) => ({ + format: ({ alert, formatters: { asPercent } }) => ({ reason: i18n.translate( 'xpack.apm.alertTypes.transactionErrorRate.reason', { diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx index 62926796cafb4b..10d139f6ccea3d 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx @@ -8,7 +8,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { ANOMALY_SEVERITY } from '../../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../../common/ml_constants'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { ServiceAlertTrigger } from '../service_alert_trigger'; diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx index 85f48ae151e104..7b56eaa4721deb 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { IntlProvider } from 'react-intl'; -import { ANOMALY_SEVERITY } from '../../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../../common/ml_constants'; import { SelectAnomalySeverity } from './select_anomaly_severity'; function Wrapper({ children }: { children?: ReactNode }) { diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 391c54c1e24977..143076e56c831f 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { ConfigSchema } from '.'; -import { - FetchDataParams, - FormatterRuleRegistry, - HasDataParams, - ObservabilityPublicSetup, -} from '../../observability/public'; +import type { ConfigSchema } from '.'; import { AppMountParameters, CoreSetup, @@ -20,28 +14,35 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/public'; -import { +import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import { +import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import type { PluginSetupContract as AlertingPluginPublicSetup, PluginStartContract as AlertingPluginPublicStart, } from '../../alerting/public'; -import { FeaturesPluginSetup } from '../../features/public'; -import { LicensingPluginSetup } from '../../licensing/public'; -import { +import type { FeaturesPluginSetup } from '../../features/public'; +import type { LicensingPluginSetup } from '../../licensing/public'; +import type { MapsStartApi } from '../../maps/public'; +import type { MlPluginSetup, MlPluginStart } from '../../ml/public'; +import type { + FetchDataParams, + HasDataParams, + ObservabilityPublicSetup, +} from '../../observability/public'; +import { FormatterRuleRegistry } from '../../observability/public'; +import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; +import { apmRuleRegistrySettings } from '../common/rules/apm_rule_registry_settings'; +import type { APMRuleFieldMap } from '../common/rules/apm_rule_field_map'; +import { registerApmAlerts } from './components/alerting/register_apm_alerts'; import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; -import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; -import { registerApmAlerts } from './components/alerting/register_apm_alerts'; -import { MlPluginSetup, MlPluginStart } from '../../ml/public'; -import { MapsStartApi } from '../../maps/public'; -import { apmRuleRegistrySettings } from '../common/rules'; export type ApmPluginSetup = ReturnType; export type ApmRuleRegistry = ApmPluginSetup['ruleRegistry']; @@ -162,6 +163,7 @@ export class ApmPlugin implements Plugin { const apmRuleRegistry = plugins.observability.ruleRegistry.create({ ...apmRuleRegistrySettings, + fieldMap: {} as APMRuleFieldMap, ctor: FormatterRuleRegistry, }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts index b9346b2bf4649b..ad1a8fcbf6e55c 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -5,7 +5,7 @@ * 2.0. */ import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; -import { ANOMALY_SEVERITY } from '../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { Job, MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; import { createRuleTypeMocks } from './test_utils'; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 66eb7125b03700..67ff7cdb8e4e07 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -18,7 +18,7 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { asMutableArray } from '../../../common/utils/as_mutable_array'; -import { ANOMALY_SEVERITY } from '../../../../ml/common'; +import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AlertType, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index a03b1ac82e90a6..bcd279c57f4a57 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -14,7 +14,7 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { anomalySeriesFetcher } from './fetcher'; import { getMLJobIds } from '../../service_map/get_service_anomalies'; -import { ANOMALY_THRESHOLD } from '../../../../../ml/common'; +import { ANOMALY_THRESHOLD } from '../../../../common/ml_constants'; import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAnomalySeries({ diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 714b887a4008ba..d62a3e6a5d5d7e 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -42,7 +42,8 @@ import { } from './types'; import { registerRoutes } from './routes/register_routes'; import { getGlobalApmServerRouteRepository } from './routes/get_global_apm_server_route_repository'; -import { apmRuleRegistrySettings } from '../common/rules'; +import { apmRuleRegistrySettings } from '../common/rules/apm_rule_registry_settings'; +import { apmRuleFieldMap } from '../common/rules/apm_rule_field_map'; export type APMRuleRegistry = ReturnType['ruleRegistry']; @@ -151,9 +152,10 @@ export class APMPlugin config: await mergedConfig$.pipe(take(1)).toPromise(), }); - const apmRuleRegistry = plugins.observability.ruleRegistry.create( - apmRuleRegistrySettings - ); + const apmRuleRegistry = plugins.observability.ruleRegistry.create({ + ...apmRuleRegistrySettings, + fieldMap: apmRuleFieldMap, + }); registerApmAlerts({ registry: apmRuleRegistry, diff --git a/x-pack/plugins/observability/common/observability_rule_registry.ts b/x-pack/plugins/observability/common/observability_rule_registry.ts deleted file mode 100644 index 9254401fc19c4c..00000000000000 --- a/x-pack/plugins/observability/common/observability_rule_registry.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 { ecsFieldMap, pickWithPatterns } from '../../rule_registry/common'; - -export const observabilityRuleRegistrySettings = { - name: 'observability', - fieldMap: { - ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), - 'kibana.observability.evaluation.value': { - type: 'scaled_float' as const, - scaling_factor: 1000, - }, - 'kibana.observability.evaluation.threshold': { - type: 'scaled_float' as const, - scaling_factor: 1000, - }, - }, -}; diff --git a/x-pack/plugins/observability/common/rules/observability_rule_field_map.ts b/x-pack/plugins/observability/common/rules/observability_rule_field_map.ts new file mode 100644 index 00000000000000..370f5d4ef79f20 --- /dev/null +++ b/x-pack/plugins/observability/common/rules/observability_rule_field_map.ts @@ -0,0 +1,22 @@ +/* + * 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 { ecsFieldMap, pickWithPatterns } from '../../../rule_registry/common'; + +export const observabilityRuleFieldMap = { + ...pickWithPatterns(ecsFieldMap, 'host.name', 'service.name'), + 'kibana.observability.evaluation.value': { + type: 'scaled_float' as const, + scaling_factor: 1000, + }, + 'kibana.observability.evaluation.threshold': { + type: 'scaled_float' as const, + scaling_factor: 1000, + }, +}; + +export type ObservabilityRuleFieldMap = typeof observabilityRuleFieldMap; diff --git a/x-pack/plugins/observability/common/rules/observability_rule_registry_settings.ts b/x-pack/plugins/observability/common/rules/observability_rule_registry_settings.ts new file mode 100644 index 00000000000000..c901d912eb70ff --- /dev/null +++ b/x-pack/plugins/observability/common/rules/observability_rule_registry_settings.ts @@ -0,0 +1,10 @@ +/* + * 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 const observabilityRuleRegistrySettings = { + name: 'observability', +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 00894650033932..aa5fb2c32ea116 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -24,6 +24,7 @@ import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; import { callObservabilityApi } from '../../services/call_observability_api'; import { getAbsoluteDateRange } from '../../utils/date'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; import { AlertsSearchBar } from './alerts_search_bar'; import { AlertsTable } from './alerts_table'; @@ -68,7 +69,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const formatted = { link: undefined, reason: alert['rule.name'], - ...(ruleType?.format?.({ alert }) ?? {}), + ...(ruleType?.format?.({ alert, formatters: { asDuration, asPercent } }) ?? {}), }; const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 491eb36d01ac0f..1f56bdebbbb9bc 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -5,32 +5,33 @@ * 2.0. */ -import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import type { RuleRegistryPublicPluginSetupContract } from '../../rule_registry/public'; -import type { - DataPublicPluginSetup, - DataPublicPluginStart, -} from '../../../../src/plugins/data/public'; +import { BehaviorSubject } from 'rxjs'; import { AppMountParameters, AppUpdater, CoreSetup, + CoreStart, DEFAULT_APP_CATEGORIES, Plugin as PluginClass, PluginInitializerContext, - CoreStart, } from '../../../../src/core/public'; +import type { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../src/plugins/data/public'; import type { HomePublicPluginSetup, HomePublicPluginStart, } from '../../../../src/plugins/home/public'; -import { registerDataHandler } from './data_handler'; -import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; import type { LensPublicStart } from '../../lens/public'; -import { createCallObservabilityApi } from './services/call_observability_api'; -import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry'; +import type { RuleRegistryPublicPluginSetupContract } from '../../rule_registry/public'; +import type { ObservabilityRuleFieldMap } from '../common/rules/observability_rule_field_map'; +import { observabilityRuleRegistrySettings } from '../common/rules/observability_rule_registry_settings'; +import { registerDataHandler } from './data_handler'; import { FormatterRuleRegistry } from './rules/formatter_rule_registry'; +import { createCallObservabilityApi } from './services/call_observability_api'; +import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; export type ObservabilityPublicSetup = ReturnType; export type ObservabilityRuleRegistry = ObservabilityPublicSetup['ruleRegistry']; @@ -72,6 +73,7 @@ export class Plugin const observabilityRuleRegistry = pluginsSetup.ruleRegistry.registry.create({ ...observabilityRuleRegistrySettings, + fieldMap: {} as ObservabilityRuleFieldMap, ctor: FormatterRuleRegistry, }); diff --git a/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts b/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts index 87e6b3c324634b..0d0d22cf750fb9 100644 --- a/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts +++ b/x-pack/plugins/observability/public/rules/formatter_rule_registry.ts @@ -7,12 +7,17 @@ import type { RuleType } from '../../../rule_registry/public'; import type { BaseRuleFieldMap, OutputOfFieldMap } from '../../../rule_registry/common'; import { RuleRegistry } from '../../../rule_registry/public'; +import type { asDuration, asPercent } from '../../common/utils/formatters'; type AlertTypeOf = OutputOfFieldMap; type FormattableRuleType = RuleType & { format?: (options: { alert: AlertTypeOf; + formatters: { + asDuration: typeof asDuration; + asPercent: typeof asPercent; + }; }) => { reason?: string; link?: string; diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index b167600e788a44..b5208260297d0d 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -16,7 +16,8 @@ import type { RuleRegistryPluginSetupContract } from '../../rule_registry/server import { uiSettings } from './ui_settings'; import { registerRoutes } from './routes/register_routes'; import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository'; -import { observabilityRuleRegistrySettings } from '../common/observability_rule_registry'; +import { observabilityRuleRegistrySettings } from '../common/rules/observability_rule_registry_settings'; +import { observabilityRuleFieldMap } from '../common/rules/observability_rule_field_map'; export type ObservabilityPluginSetup = ReturnType; export type ObservabilityRuleRegistry = ObservabilityPluginSetup['ruleRegistry']; @@ -50,9 +51,10 @@ export class ObservabilityPlugin implements Plugin { }); } - const observabilityRuleRegistry = plugins.ruleRegistry.create( - observabilityRuleRegistrySettings - ); + const observabilityRuleRegistry = plugins.ruleRegistry.create({ + ...observabilityRuleRegistrySettings, + fieldMap: observabilityRuleFieldMap, + }); registerRoutes({ core: { diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index 1636f88a21a618..ec2b366f739e6d 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -11,8 +11,5 @@ "triggersActionsUi" ], "server": true, - "ui": true, - "extraPublicDirs": [ - "common" - ] + "ui": true } diff --git a/x-pack/plugins/rule_registry/public/index.ts b/x-pack/plugins/rule_registry/public/index.ts index 55662dbcc8bfc7..59697261ff20bc 100644 --- a/x-pack/plugins/rule_registry/public/index.ts +++ b/x-pack/plugins/rule_registry/public/index.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { PluginInitializerContext } from 'kibana/public'; +import type { PluginInitializerContext } from 'kibana/public'; import { Plugin } from './plugin'; -export { RuleRegistryPublicPluginSetupContract } from './plugin'; +export type { RuleRegistryPublicPluginSetupContract } from './plugin'; export { RuleRegistry } from './rule_registry'; export type { IRuleRegistry, RuleType } from './rule_registry/types'; diff --git a/x-pack/plugins/rule_registry/public/plugin.ts b/x-pack/plugins/rule_registry/public/plugin.ts index 66c9a4fa224a5e..7f0bceefb6797c 100644 --- a/x-pack/plugins/rule_registry/public/plugin.ts +++ b/x-pack/plugins/rule_registry/public/plugin.ts @@ -19,7 +19,7 @@ import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; -import { baseRuleFieldMap } from '../common'; +import type { BaseRuleFieldMap } from '../common'; import { RuleRegistry } from './rule_registry'; interface RuleRegistrySetupPlugins { @@ -40,7 +40,7 @@ export class Plugin public setup(core: CoreSetup, plugins: RuleRegistrySetupPlugins) { const rootRegistry = new RuleRegistry({ - fieldMap: baseRuleFieldMap, + fieldMap: {} as BaseRuleFieldMap, alertTypeRegistry: plugins.triggersActionsUi.alertTypeRegistry, }); return { diff --git a/x-pack/plugins/rule_registry/public/rule_registry/types.ts b/x-pack/plugins/rule_registry/public/rule_registry/types.ts index bb16227cbab5f6..7c186385ebd357 100644 --- a/x-pack/plugins/rule_registry/public/rule_registry/types.ts +++ b/x-pack/plugins/rule_registry/public/rule_registry/types.ts @@ -4,8 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { AlertTypeRegistryContract } from '../../../triggers_actions_ui/public'; -import { BaseRuleFieldMap, FieldMap } from '../../common'; +import type { AlertTypeRegistryContract } from '../../../triggers_actions_ui/public'; +import type { BaseRuleFieldMap, FieldMap } from '../../common'; export interface RuleRegistryConstructorOptions { fieldMap: TFieldMap; From 05bd1c0cdbed2f2b5586af517a5c2d42ae5a366b Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Sun, 18 Apr 2021 16:47:24 +0200 Subject: [PATCH 12/17] [Fleet] Finer-grained error information from install/upgrade API (#95649) * Intercept installation errors and add meta info. * Adjust mock. * Catch errors in all steps of install/upgrade. * Adjust handler for direct package upload. * Don't throw not-found errors on assets during rollback. * Correctly catch errors from _installPackage() * Propagate error from installResult in bulk install case. * Add tests for rollback. * Remove unused code. * Skipping test that doesn't test what it says. * Fix and reenable test. --- .../plugins/fleet/common/types/models/epm.ts | 2 +- .../fleet/common/types/rest_spec/epm.ts | 7 +- .../fleet/server/routes/epm/handlers.ts | 46 ++-- .../epm/packages/bulk_install_packages.ts | 50 +++-- .../ensure_installed_default_packages.test.ts | 10 +- .../server/services/epm/packages/install.ts | 208 ++++++++++-------- .../server/services/epm/packages/remove.ts | 7 +- .../fleet_api_integration/apis/epm/index.js | 1 + .../apis/epm/install_error_rollback.ts | 61 +++++ .../error_handling/0.1.0/docs/README.md | 3 + .../visualization/sample_visualization.json | 14 ++ .../error_handling/0.1.0/manifest.yml | 20 ++ .../error_handling/0.2.0/docs/README.md | 5 + .../visualization/sample_visualization.json | 14 ++ .../error_handling/0.2.0/manifest.yml | 19 ++ 15 files changed, 327 insertions(+), 140 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/kibana/visualization/sample_visualization.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/kibana/visualization/sample_visualization.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 3bc0d97d646465..1a594e77f4857f 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -30,7 +30,7 @@ export enum InstallStatus { uninstalling = 'uninstalling', } -export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install'; +export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install' | 'unknown'; export type InstallSource = 'registry' | 'upload'; export type EpmPackageInstallStatus = 'installed' | 'installing'; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts index 3c7a32265d20a6..e5c7ace420c730 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -12,6 +12,7 @@ import type { RegistrySearchResult, PackageInfo, PackageUsageStats, + InstallType, } from '../models/epm'; export interface GetCategoriesRequest { @@ -83,8 +84,10 @@ export interface IBulkInstallPackageHTTPError { } export interface InstallResult { - assets: AssetReference[]; - status: 'installed' | 'already_installed'; + assets?: AssetReference[]; + status?: 'installed' | 'already_installed'; + error?: Error; + installType: InstallType; } export interface BulkInstallPackageInfo { diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index f0d6e684273614..16d583f8a8d1f3 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -226,20 +226,21 @@ export const installPackageFromRegistryHandler: RequestHandler< const savedObjectsClient = context.core.savedObjects.client; const esClient = context.core.elasticsearch.client.asCurrentUser; const { pkgkey } = request.params; - try { - const res = await installPackage({ - installSource: 'registry', - savedObjectsClient, - pkgkey, - esClient, - force: request.body?.force, - }); + + const res = await installPackage({ + installSource: 'registry', + savedObjectsClient, + pkgkey, + esClient, + force: request.body?.force, + }); + if (!res.error) { const body: InstallPackageResponse = { - response: res.assets, + response: res.assets || [], }; return response.ok({ body }); - } catch (e) { - return await defaultIngestErrorHandler({ error: e, response }); + } else { + return await defaultIngestErrorHandler({ error: res.error, response }); } }; @@ -292,20 +293,21 @@ export const installPackageByUploadHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later const archiveBuffer = Buffer.from(request.body); - try { - const res = await installPackage({ - installSource: 'upload', - savedObjectsClient, - esClient, - archiveBuffer, - contentType, - }); + + const res = await installPackage({ + installSource: 'upload', + savedObjectsClient, + esClient, + archiveBuffer, + contentType, + }); + if (!res.error) { const body: InstallPackageResponse = { - response: res.assets, + response: res.assets || [], }; return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); + } else { + return defaultIngestErrorHandler({ error: res.error, response }); } }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts index 7323263d4a70f5..baaaaf6c6b0cfe 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts @@ -32,22 +32,27 @@ export async function bulkInstallPackages({ ); logger.debug(`kicking off bulk install of ${packagesToInstall.join(', ')} from registry`); - const installResults = await Promise.allSettled( + const bulkInstallResults = await Promise.allSettled( latestPackagesResults.map(async (result, index) => { const packageName = packagesToInstall[index]; if (result.status === 'fulfilled') { const latestPackage = result.value; - return { - name: packageName, - version: latestPackage.version, - result: await installPackage({ - savedObjectsClient, - esClient, - pkgkey: Registry.pkgToPkgKey(latestPackage), - installSource, - skipPostInstall: true, - }), - }; + const installResult = await installPackage({ + savedObjectsClient, + esClient, + pkgkey: Registry.pkgToPkgKey(latestPackage), + installSource, + skipPostInstall: true, + }); + if (installResult.error) { + return { name: packageName, error: installResult.error }; + } else { + return { + name: packageName, + version: latestPackage.version, + result: installResult, + }; + } } return { name: packageName, error: result.reason }; }) @@ -56,18 +61,27 @@ export async function bulkInstallPackages({ // only install index patterns if we completed install for any package-version for the // first time, aka fresh installs or upgrades if ( - installResults.find( - (result) => result.status === 'fulfilled' && result.value.result?.status === 'installed' + bulkInstallResults.find( + (result) => + result.status === 'fulfilled' && + !result.value.result?.error && + result.value.result?.status === 'installed' ) ) { await installIndexPatterns({ savedObjectsClient, esClient, installSource }); } - return installResults.map((result, index) => { + return bulkInstallResults.map((result, index) => { const packageName = packagesToInstall[index]; - return result.status === 'fulfilled' - ? result.value - : { name: packageName, error: result.reason }; + if (result.status === 'fulfilled') { + if (result.value && result.value.error) { + return { name: packageName, error: result.value.error }; + } else { + return result.value; + } + } else { + return { name: packageName, error: result.reason }; + } }); } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts index fa2ea9e2209edf..f8c91e55fbbb69 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -77,7 +77,7 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: mockInstallation.attributes.name, - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, @@ -95,13 +95,13 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: 'success one', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, { name: 'success two', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, @@ -111,7 +111,7 @@ describe('ensureInstalledDefaultPackages', () => { }, { name: 'success three', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, @@ -134,7 +134,7 @@ describe('ensureInstalledDefaultPackages', () => { return [ { name: 'undefined package', - result: { assets: [], status: 'installed' }, + result: { assets: [], status: 'installed', installType: 'install' }, version: '', statusCode: 200, }, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 4373251a969bc4..31d07320967905 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -201,54 +201,62 @@ async function installPackageFromRegistry({ // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); - // get the currently installed package - const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); - const installType = getInstallType({ pkgVersion, installedPkg }); - - // get latest package version - const latestPackage = await Registry.fetchFindLatestPackage(pkgName); - - // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update - const installOutOfDateVersionOk = - force || ['reinstall', 'reupdate', 'rollback'].includes(installType); + // if an error happens during getInstallType, report that we don't know + let installType: InstallType = 'unknown'; - // if the requested version is the same as installed version, check if we allow it based on - // current installed package status and force flag, if we don't allow it, - // just return the asset references from the existing installation - if ( - installedPkg?.attributes.version === pkgVersion && - installedPkg?.attributes.install_status === 'installed' - ) { - if (!force) { - logger.debug(`${pkgkey} is already installed, skipping installation`); - return { - assets: [ - ...installedPkg.attributes.installed_es, - ...installedPkg.attributes.installed_kibana, - ], - status: 'already_installed', - }; + try { + // get the currently installed package + const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); + installType = getInstallType({ pkgVersion, installedPkg }); + + // get latest package version + const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + + // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update + const installOutOfDateVersionOk = + force || ['reinstall', 'reupdate', 'rollback'].includes(installType); + + // if the requested version is the same as installed version, check if we allow it based on + // current installed package status and force flag, if we don't allow it, + // just return the asset references from the existing installation + if ( + installedPkg?.attributes.version === pkgVersion && + installedPkg?.attributes.install_status === 'installed' + ) { + if (!force) { + logger.debug(`${pkgkey} is already installed, skipping installation`); + return { + assets: [ + ...installedPkg.attributes.installed_es, + ...installedPkg.attributes.installed_kibana, + ], + status: 'already_installed', + installType, + }; + } } - } - // if the requested version is out-of-date of the latest package version, check if we allow it - // if we don't allow it, return an error - if (semverLt(pkgVersion, latestPackage.version)) { - if (!installOutOfDateVersionOk) { - throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`); + // if the requested version is out-of-date of the latest package version, check if we allow it + // if we don't allow it, return an error + if (semverLt(pkgVersion, latestPackage.version)) { + if (!installOutOfDateVersionOk) { + throw new PackageOutdatedError( + `${pkgkey} is out-of-date and cannot be installed or updated` + ); + } + logger.debug( + `${pkgkey} is out-of-date, installing anyway due to ${ + force ? 'force flag' : `install type ${installType}` + }` + ); } - logger.debug( - `${pkgkey} is out-of-date, installing anyway due to ${ - force ? 'force flag' : `install type ${installType}` - }` - ); - } - // get package info - const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); + // get package info + const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion); - // try installing the package, if there was an error, call error handler and rethrow - try { + // try installing the package, if there was an error, call error handler and rethrow + // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status + // @ts-ignore return _installPackage({ savedObjectsClient, esClient, @@ -257,19 +265,26 @@ async function installPackageFromRegistry({ packageInfo, installType, installSource: 'registry', - }).then((assets) => { - return { assets, status: 'installed' }; - }); + }) + .then((assets) => { + return { assets, status: 'installed', installType }; + }) + .catch(async (err: Error) => { + await handleInstallPackageFailure({ + savedObjectsClient, + error: err, + pkgName, + pkgVersion, + installedPkg, + esClient, + }); + return { error: err, installType }; + }); } catch (e) { - await handleInstallPackageFailure({ - savedObjectsClient, + return { error: e, - pkgName, - pkgVersion, - installedPkg, - esClient, - }); - throw e; + installType, + }; } } @@ -286,46 +301,57 @@ async function installPackageByUpload({ archiveBuffer, contentType, }: InstallUploadedArchiveParams): Promise { - const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType); - - const installedPkg = await getInstallationObject({ - savedObjectsClient, - pkgName: packageInfo.name, - }); + // if an error happens during getInstallType, report that we don't know + let installType: InstallType = 'unknown'; + try { + const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType); - const installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg }); - if (installType !== 'install') { - throw new PackageOperationNotSupportedError( - `Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.` - ); - } + const installedPkg = await getInstallationObject({ + savedObjectsClient, + pkgName: packageInfo.name, + }); - const installSource = 'upload'; - const paths = await unpackBufferToCache({ - name: packageInfo.name, - version: packageInfo.version, - installSource, - archiveBuffer, - contentType, - }); + installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg }); + if (installType !== 'install') { + throw new PackageOperationNotSupportedError( + `Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.` + ); + } - setPackageInfo({ - name: packageInfo.name, - version: packageInfo.version, - packageInfo, - }); + const installSource = 'upload'; + const paths = await unpackBufferToCache({ + name: packageInfo.name, + version: packageInfo.version, + installSource, + archiveBuffer, + contentType, + }); - return _installPackage({ - savedObjectsClient, - esClient, - installedPkg, - paths, - packageInfo, - installType, - installSource, - }).then((assets) => { - return { assets, status: 'installed' }; - }); + setPackageInfo({ + name: packageInfo.name, + version: packageInfo.version, + packageInfo, + }); + // TODO: without the ts-ignore, TS complains about the type of the value of the returned InstallResult.status + // @ts-ignore + return _installPackage({ + savedObjectsClient, + esClient, + installedPkg, + paths, + packageInfo, + installType, + installSource, + }) + .then((assets) => { + return { assets, status: 'installed', installType }; + }) + .catch(async (err: Error) => { + return { error: err, installType }; + }); + } catch (e) { + return { error: e, installType }; + } } export type InstallPackageParams = { @@ -352,7 +378,7 @@ export async function installPackage(args: InstallPackageParams) { esClient, force, }).then(async (installResult) => { - if (skipPostInstall) { + if (skipPostInstall || installResult.error) { return installResult; } logger.debug(`install of ${pkgkey} finished, running post-install`); @@ -374,7 +400,7 @@ export async function installPackage(args: InstallPackageParams) { archiveBuffer, contentType, }).then(async (installResult) => { - if (skipPostInstall) { + if (skipPostInstall || installResult.error) { return installResult; } logger.debug(`install of uploaded package finished, running post-install`); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index de798e822b0298..706f1bbbaaf35b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -79,6 +79,7 @@ export async function removeInstallation(options: { return installedAssets; } +// TODO: this is very much like deleteKibanaSavedObjectsAssets below function deleteKibanaAssets( installedObjects: KibanaAssetReference[], savedObjectsClient: SavedObjectsClientContract @@ -136,6 +137,7 @@ async function deleteTemplate(esClient: ElasticsearchClient, name: string): Prom } } +// TODO: this is very much like deleteKibanaAssets above export async function deleteKibanaSavedObjectsAssets( savedObjectsClient: SavedObjectsClientContract, installedRefs: AssetReference[] @@ -153,6 +155,9 @@ export async function deleteKibanaSavedObjectsAssets( try { await Promise.all(deletePromises); } catch (err) { - logger.warn(err); + // in the rollback case, partial installs are likely, so missing assets are not an error + if (!savedObjectsClient.errors.isNotFoundError(err)) { + logger.error(err); + } } } diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 009e1a2dad5f15..445d9706bb9a93 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -24,5 +24,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./update_assets')); loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); + loadTestFile(require.resolve('./install_error_rollback')); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts b/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts new file mode 100644 index 00000000000000..6e2ea3b96aa582 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts @@ -0,0 +1,61 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const goodPackage = 'error_handling-0.1.0'; + const badPackage = 'error_handling-0.2.0'; + + const installPackage = async (pkgkey: string) => { + await supertest + .post(`/api/fleet/epm/packages/${pkgkey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); + }; + + const getPackageInfo = async (pkgkey: string) => { + return await supertest.get(`/api/fleet/epm/packages/${pkgkey}`).set('kbn-xsrf', 'xxxx'); + }; + + describe('package installation error handling and rollback', async () => { + skipIfNoDockerRegistry(providerContext); + beforeEach(async () => { + await esArchiver.load('empty_kibana'); + }); + afterEach(async () => { + await esArchiver.unload('empty_kibana'); + }); + + it('on a fresh install, it should uninstall a broken package during rollback', async function () { + await supertest + .post(`/api/fleet/epm/packages/${badPackage}`) + .set('kbn-xsrf', 'xxxx') + .expect(422); // the broken package contains a broken visualization triggering a 422 from Kibana + + const pkgInfoResponse = await getPackageInfo(badPackage); + expect(JSON.parse(pkgInfoResponse.text).response.status).to.be('not_installed'); + }); + + it('on an upgrade, it should fall back to the previous good version during rollback', async function () { + await installPackage(goodPackage); + await supertest + .post(`/api/fleet/epm/packages/${badPackage}`) + .set('kbn-xsrf', 'xxxx') + .expect(422); // the broken package contains a broken visualization triggering a 422 from Kibana + + const goodPkgInfoResponse = await getPackageInfo(goodPackage); + expect(JSON.parse(goodPkgInfoResponse.text).response.status).to.be('installed'); + expect(JSON.parse(goodPkgInfoResponse.text).response.version).to.be('0.1.0'); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/docs/README.md new file mode 100644 index 00000000000000..260499f4b00785 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/docs/README.md @@ -0,0 +1,3 @@ +This package should install without errors. + +Version 0.2.0 of this package should fail during installation. We need this good version to test rollback. \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/kibana/visualization/sample_visualization.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/kibana/visualization/sample_visualization.json new file mode 100644 index 00000000000000..01afe600853efa --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/kibana/visualization/sample_visualization.json @@ -0,0 +1,14 @@ +{ + "attributes": { + "description": "sample visualization", + "title": "sample vis title", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customLabel\":\"Log Level\",\"field\":\"log.level\",\"order\":\"desc\",\"orderBy\":\"1\",\"size\":5},\"schema\":\"group\",\"type\":\"terms\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per day\"},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Count\"},\"type\":\"value\"}]},\"title\":\"Log levels over time [Logs Kafka] ECS\",\"type\":\"histogram\"}" + }, + "id": "sample_visualization", + "type": "visualization", + "migrationVersion": { + "visualization": "7.7.0" + } +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml new file mode 100644 index 00000000000000..bba1a6a4c347d1 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.1.0/manifest.yml @@ -0,0 +1,20 @@ +format_version: 1.0.0 +name: error_handling +title: Error handling +description: tests error handling and rollback +version: 0.1.0 +categories: [] +release: beta +type: integration +license: basic + +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' + +icons: + - src: '/img/logo_overrides_64_color.svg' + size: '16x16' + type: 'image/svg+xml' \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/docs/README.md new file mode 100644 index 00000000000000..c348f801b17801 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/docs/README.md @@ -0,0 +1,5 @@ +This package should fail during installation. + +Version 0.1.0 of this package should install without errors, and be rolled back to without errors. + +This package contains one Kibana visualization that requires a non-existent version of Kibana in order to trigger an error during installation. \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/kibana/visualization/sample_visualization.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/kibana/visualization/sample_visualization.json new file mode 100644 index 00000000000000..0a4867cfe1c119 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/kibana/visualization/sample_visualization.json @@ -0,0 +1,14 @@ +{ + "attributes": { + "description": "sample visualization", + "title": "sample vis title", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customLabel\":\"Log Level\",\"field\":\"log.level\",\"order\":\"desc\",\"orderBy\":\"1\",\"size\":5},\"schema\":\"group\",\"type\":\"terms\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per day\"},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Count\"},\"type\":\"value\"}]},\"title\":\"Log levels over time [Logs Kafka] ECS\",\"type\":\"histogram\"}" + }, + "id": "sample_visualization", + "type": "visualization", + "migrationVersion": { + "visualization": "12.7.0" + } +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml new file mode 100644 index 00000000000000..2eb6a41a77ede8 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml @@ -0,0 +1,19 @@ +format_version: 1.0.0 +name: error_handling +title: Error handling +description: tests error handling and rollback +version: 0.2.0 +categories: [] +release: beta +type: integration +license: basic + +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' + +icons: + - src: '/img/logo_overrides_64_color.svg' + size: '16x16' \ No newline at end of file From f8838e3b89abbcf155c9a2381ad631af69cc4864 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Sun, 18 Apr 2021 20:42:07 +0200 Subject: [PATCH 13/17] Remove legacy ES client usages in `home` and `xpack_legacy` (#97359) * Home plugin: remove usages of the legacy ES client * remove legacy es client usage in xpack_legacy --- .../services/sample_data/routes/install.ts | 20 +++++++++---------- .../services/sample_data/routes/list.ts | 20 +++++++++---------- .../services/sample_data/routes/uninstall.ts | 10 ++++------ .../services/sample_data/usage/collector.ts | 17 ++++++---------- .../server/routes/settings.test.ts | 14 ++----------- .../xpack_legacy/server/routes/settings.ts | 2 -- 6 files changed, 30 insertions(+), 53 deletions(-) diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index a20c3e350222f3..e5ff33d5c199dd 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, Logger, RequestHandlerContext } from 'src/core/server'; +import { IRouter, Logger, IScopedClusterClient } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { @@ -22,7 +22,7 @@ const insertDataIntoIndex = ( dataIndexConfig: any, index: string, nowReference: string, - context: RequestHandlerContext, + esClient: IScopedClusterClient, logger: Logger ) => { function updateTimestamps(doc: any) { @@ -51,9 +51,11 @@ const insertDataIntoIndex = ( bulk.push(insertCmd); bulk.push(updateTimestamps(doc)); }); - const resp = await context.core.elasticsearch.legacy.client.callAsCurrentUser('bulk', { + + const { body: resp } = await esClient.asCurrentUser.bulk({ body: bulk, }); + if (resp.errors) { const errMsg = `sample_data install errors while bulk inserting. Elasticsearch response: ${JSON.stringify( resp, @@ -100,7 +102,7 @@ export function createInstallRoute( // clean up any old installation of dataset try { - await context.core.elasticsearch.legacy.client.callAsCurrentUser('indices.delete', { + await context.core.elasticsearch.client.asCurrentUser.indices.delete({ index, }); } catch (err) { @@ -108,17 +110,13 @@ export function createInstallRoute( } try { - const createIndexParams = { + await context.core.elasticsearch.client.asCurrentUser.indices.create({ index, body: { settings: { index: { number_of_shards: 1, auto_expand_replicas: '0-1' } }, mappings: { properties: dataIndexConfig.fields }, }, - }; - await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'indices.create', - createIndexParams - ); + }); } catch (err) { const errMsg = `Unable to create sample data index "${index}", error: ${err.message}`; logger.warn(errMsg); @@ -130,7 +128,7 @@ export function createInstallRoute( dataIndexConfig, index, nowReference, - context, + context.core.elasticsearch.client, logger ); (counts as any)[index] = count; diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 86e286644f9368..72d8c31cbafd74 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -36,22 +36,20 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc const dataIndexConfig = sampleDataset.dataIndices[i]; const index = createIndexName(sampleDataset.id, dataIndexConfig.id); try { - const indexExists = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'indices.exists', - { index } - ); + const { + body: indexExists, + } = await context.core.elasticsearch.client.asCurrentUser.indices.exists({ + index, + }); if (!indexExists) { sampleDataset.status = NOT_INSTALLED; return; } - const { count } = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'count', - { - index, - } - ); - if (count === 0) { + const { body: count } = await context.core.elasticsearch.client.asCurrentUser.count({ + index, + }); + if (count.count === 0) { sampleDataset.status = NOT_INSTALLED; return; } diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index aa8ed67cf840a2..3108c06492dd80 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -28,11 +28,7 @@ export function createUninstallRoute( async ( { core: { - elasticsearch: { - legacy: { - client: { callAsCurrentUser }, - }, - }, + elasticsearch: { client: esClient }, savedObjects: { getClient: getSavedObjectsClient, typeRegistry }, }, }, @@ -50,7 +46,9 @@ export function createUninstallRoute( const index = createIndexName(sampleDataset.id, dataIndexConfig.id); try { - await callAsCurrentUser('indices.delete', { index }); + await esClient.asCurrentUser.indices.delete({ + index, + }); } catch (err) { return response.customError({ statusCode: err.status, diff --git a/src/plugins/home/server/services/sample_data/usage/collector.ts b/src/plugins/home/server/services/sample_data/usage/collector.ts index 81958a2e3c8784..df7d485c1f6fa1 100644 --- a/src/plugins/home/server/services/sample_data/usage/collector.ts +++ b/src/plugins/home/server/services/sample_data/usage/collector.ts @@ -6,22 +6,17 @@ * Side Public License, v 1. */ -import { PluginInitializerContext } from 'kibana/server'; -import { first } from 'rxjs/operators'; +import type { PluginInitializerContext } from 'kibana/server'; +import type { UsageCollectionSetup } from '../../../../../usage_collection/server'; import { fetchProvider, TelemetryResponse } from './collector_fetch'; -import { UsageCollectionSetup } from '../../../../../usage_collection/server'; -export async function makeSampleDataUsageCollector( +export function makeSampleDataUsageCollector( usageCollection: UsageCollectionSetup, context: PluginInitializerContext ) { - let index: string; - try { - const config = await context.config.legacy.globalConfig$.pipe(first()).toPromise(); - index = config.kibana.index; - } catch (err) { - return; // kibana plugin is not enabled (test environment) - } + const config = context.config.legacy.get(); + const index = config.kibana.index; + const collector = usageCollection.makeUsageCollector({ type: 'sample-data', fetch: fetchProvider(index), diff --git a/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts b/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts index 08b5a0f60521c6..2034a4e5b74bab 100644 --- a/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts +++ b/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts @@ -9,11 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { UnwrapPromise } from '@kbn/utility-types'; import supertest from 'supertest'; -import { - LegacyAPICaller, - ServiceStatus, - ServiceStatusLevels, -} from '../../../../../src/core/server'; +import { ServiceStatus, ServiceStatusLevels } from '../../../../../src/core/server'; import { contextServiceMock, elasticsearchServiceMock, @@ -31,24 +27,18 @@ export function mockGetClusterInfo(clusterInfo: any) { esClient.info.mockResolvedValue({ body: { ...clusterInfo } }); return esClient; } + describe('/api/settings', () => { let server: HttpService; let httpSetup: HttpSetup; let overallStatus$: BehaviorSubject; - let mockApiCaller: jest.Mocked; beforeEach(async () => { - mockApiCaller = jest.fn(); server = createHttpServer(); httpSetup = await server.setup({ context: contextServiceMock.createSetupContract({ core: { elasticsearch: { - legacy: { - client: { - callAsCurrentUser: mockApiCaller, - }, - }, client: { asCurrentUser: mockGetClusterInfo({ cluster_uuid: 'yyy-yyyyy' }), }, diff --git a/x-pack/plugins/xpack_legacy/server/routes/settings.ts b/x-pack/plugins/xpack_legacy/server/routes/settings.ts index 9117637b70bee6..b9052ca0c84e3d 100644 --- a/x-pack/plugins/xpack_legacy/server/routes/settings.ts +++ b/x-pack/plugins/xpack_legacy/server/routes/settings.ts @@ -42,9 +42,7 @@ export function registerSettingsRoute({ validate: false, }, async (context, req, res) => { - const { callAsCurrentUser } = context.core.elasticsearch.legacy.client; const collectorFetchContext = { - callCluster: callAsCurrentUser, esClient: context.core.elasticsearch.client.asCurrentUser, soClient: context.core.savedObjects.client, }; From cb2cf67609f54a6e43a08b24f11694217255cdc3 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Sun, 18 Apr 2021 20:49:35 +0200 Subject: [PATCH 14/17] Add description as title on tag badge (#97109) --- .../public/components/base/tag_badge.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx index 6bc9e659d93469..e8af661d6921d4 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_badge.tsx @@ -17,5 +17,9 @@ export interface TagBadgeProps { * The badge representation of a Tag, which is the default display to be used for them. */ export const TagBadge: FC = ({ tag }) => { - return {tag.name}; + return ( + + {tag.name} + + ); }; From 787b4934032b6989195aedf3aac4c871bf7ca11f Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Sun, 18 Apr 2021 23:07:36 +0200 Subject: [PATCH 15/17] Avoid mutating KQL query when validating it (#97081) --- .../service/lib/filter_utils.test.ts | 17 +++++++++++++++++ .../saved_objects/service/lib/filter_utils.ts | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index 956a60b23809d3..2ef5219ccfff16 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { cloneDeep } from 'lodash'; // @ts-expect-error no ts import { esKuery } from '../../es_query'; @@ -105,6 +106,22 @@ describe('Filter Utils', () => { ) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); }); + + test('does not mutate the input KueryNode', () => { + const input = esKuery.nodeTypes.function.buildNode( + 'is', + `foo.attributes.title`, + 'best', + true + ); + + const inputCopy = cloneDeep(input); + + validateConvertFilterToKueryNode(['foo'], input, mockMappings); + + expect(input).toEqual(inputCopy); + }); + test('Validate a simple KQL expression filter', () => { expect( validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index b3bcef9a62e130..a41a25a27b70dd 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -7,11 +7,12 @@ */ import { set } from '@elastic/safer-lodash-set'; -import { get } from 'lodash'; +import { get, cloneDeep } from 'lodash'; import { SavedObjectsErrorHelpers } from './errors'; import { IndexMapping } from '../../mappings'; // @ts-expect-error no ts import { esKuery } from '../../es_query'; + type KueryNode = any; const astFunctionType = ['is', 'range', 'nested']; @@ -23,7 +24,7 @@ export const validateConvertFilterToKueryNode = ( ): KueryNode | undefined => { if (filter && indexMapping) { const filterKueryNode = - typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : filter; + typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : cloneDeep(filter); const validationFilterKuery = validateFilterKueryNode({ astFilter: filterKueryNode, From 681bd642fb54396b2ee27c982b8dc128e98bfb02 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Sun, 18 Apr 2021 20:28:13 -0700 Subject: [PATCH 16/17] Fix typo in license_api_guard README name and import http server mocks from public interface (#97334) * Reject basic minimum licenses. --- docs/developer/plugin-list.asciidoc | 4 +- x-pack/plugins/license_api_guard/READM.md | 3 - x-pack/plugins/license_api_guard/README.md | 3 + .../license_api_guard/server/license.test.ts | 135 +++++++++++------- .../license_api_guard/server/license.ts | 6 + 5 files changed, 96 insertions(+), 55 deletions(-) delete mode 100644 x-pack/plugins/license_api_guard/READM.md create mode 100644 x-pack/plugins/license_api_guard/README.md diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index de7253e34d103d..c7fffb09248e92 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -444,8 +444,8 @@ the infrastructure monitoring use-case within Kibana. |Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. -|{kib-repo}blob/{branch}/x-pack/plugins/license_api_guard[licenseApiGuard] -|WARNING: Missing README. +|{kib-repo}blob/{branch}/x-pack/plugins/license_api_guard/README.md[licenseApiGuard] +|This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. |{kib-repo}blob/{branch}/x-pack/plugins/license_management/README.md[licenseManagement] diff --git a/x-pack/plugins/license_api_guard/READM.md b/x-pack/plugins/license_api_guard/READM.md deleted file mode 100644 index 767223125b12c3..00000000000000 --- a/x-pack/plugins/license_api_guard/READM.md +++ /dev/null @@ -1,3 +0,0 @@ -# License API guard plugin - -This plugin is used by ES UI plugins to reject API requests to plugins that are unsupported by the user's license. \ No newline at end of file diff --git a/x-pack/plugins/license_api_guard/README.md b/x-pack/plugins/license_api_guard/README.md new file mode 100644 index 00000000000000..bf2a9fdff71221 --- /dev/null +++ b/x-pack/plugins/license_api_guard/README.md @@ -0,0 +1,3 @@ +# License API guard plugin + +This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. \ No newline at end of file diff --git a/x-pack/plugins/license_api_guard/server/license.test.ts b/x-pack/plugins/license_api_guard/server/license.test.ts index e9da393f534786..400af7261ff871 100644 --- a/x-pack/plugins/license_api_guard/server/license.test.ts +++ b/x-pack/plugins/license_api_guard/server/license.test.ts @@ -6,18 +6,38 @@ */ import { of } from 'rxjs'; -import type { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { httpServerMock } from 'src/core/server/http/http_server.mocks'; - +import type { Logger, KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; import { License } from './license'; -import { LicenseCheckState, licensingMock } from './shared_imports'; +import { LicenseCheckState, licensingMock, LicenseType } from './shared_imports'; describe('License API guard', () => { const pluginName = 'testPlugin'; - const currentLicenseType = 'basic'; - const testRoute = ({ licenseState }: { licenseState: string }) => { + const mockLicensingService = ({ + licenseType, + licenseState, + }: { + licenseType: LicenseType; + licenseState: LicenseCheckState; + }) => { + const licenseMock = licensingMock.createLicenseMock(); + licenseMock.type = licenseType; + licenseMock.check('test', 'gold'); // Flush default mocked state + licenseMock.check.mockReturnValue({ state: licenseState }); // Replace with new mocked state + + return { + license$: of(licenseMock), + }; + }; + + const testRoute = ({ + licenseType, + licenseState, + }: { + licenseType: LicenseType; + licenseState: LicenseCheckState; + }) => { const license = new License(); const logger = { @@ -25,19 +45,11 @@ describe('License API guard', () => { }; license.setup({ pluginName, logger }); - - const licenseMock = licensingMock.createLicenseMock(); - licenseMock.type = currentLicenseType; - licenseMock.check('test', 'basic'); // Flush default mocked state - licenseMock.check.mockReturnValue({ state: licenseState as LicenseCheckState }); // Replace with new mocked state - - const licensing = { - license$: of(licenseMock), - }; + const licensing = mockLicensingService({ licenseType, licenseState }); license.start({ pluginId: 'id', - minimumLicenseType: 'basic', + minimumLicenseType: 'gold', licensing, }); @@ -61,44 +73,67 @@ describe('License API guard', () => { }; }; - describe('valid license', () => { - it('the original route is called and nothing is logged', () => { - const { errorResponse, logMesssage, route } = testRoute({ licenseState: 'valid' }); - - expect(errorResponse).toBeUndefined(); - expect(logMesssage).toBeUndefined(); - expect(route).toHaveBeenCalled(); + describe('basic minimum license', () => { + it('is rejected', () => { + const license = new License(); + license.setup({ pluginName, logger: {} as Logger }); + expect(() => { + license.start({ + pluginId: pluginName, + minimumLicenseType: 'basic', + licensing: mockLicensingService({ licenseType: 'gold', licenseState: 'valid' }), + }); + }).toThrowError( + `Basic licenses don't restrict the use of plugins. Please don't use license_api_guard in the ${pluginName} plugin, or provide a more restrictive minimumLicenseType.` + ); }); }); - [ - { - licenseState: 'invalid', - expectedMessage: `Your ${currentLicenseType} license does not support ${pluginName}. Please upgrade your license.`, - }, - { - licenseState: 'expired', - expectedMessage: `You cannot use ${pluginName} because your ${currentLicenseType} license has expired.`, - }, - { - licenseState: 'unavailable', - expectedMessage: `You cannot use ${pluginName} because license information is not available at this time.`, - }, - ].forEach(({ licenseState, expectedMessage }) => { - describe(`${licenseState} license`, () => { - it('replies with and logs the error message', () => { - const { errorResponse, logMesssage, route } = testRoute({ licenseState }); - - // We depend on the call to `response.forbidden()` to generate the 403 status code, - // so we can't assert for it here. - expect(errorResponse).toEqual({ - body: { - message: expectedMessage, - }, + describe('non-basic minimum license', () => { + const licenseType = 'gold'; + + describe('when valid', () => { + it('the original route is called and nothing is logged', () => { + const { errorResponse, logMesssage, route } = testRoute({ + licenseType, + licenseState: 'valid', }); - expect(logMesssage).toBe(expectedMessage); - expect(route).not.toHaveBeenCalled(); + expect(errorResponse).toBeUndefined(); + expect(logMesssage).toBeUndefined(); + expect(route).toHaveBeenCalled(); + }); + }); + + [ + { + licenseState: 'invalid' as LicenseCheckState, + expectedMessage: `Your ${licenseType} license does not support ${pluginName}. Please upgrade your license.`, + }, + { + licenseState: 'expired' as LicenseCheckState, + expectedMessage: `You cannot use ${pluginName} because your ${licenseType} license has expired.`, + }, + { + licenseState: 'unavailable' as LicenseCheckState, + expectedMessage: `You cannot use ${pluginName} because license information is not available at this time.`, + }, + ].forEach(({ licenseState, expectedMessage }) => { + describe(`when ${licenseState}`, () => { + it('replies with and logs the error message', () => { + const { errorResponse, logMesssage, route } = testRoute({ licenseType, licenseState }); + + // We depend on the call to `response.forbidden()` to generate the 403 status code, + // so we can't assert for it here. + expect(errorResponse).toEqual({ + body: { + message: expectedMessage, + }, + }); + + expect(logMesssage).toBe(expectedMessage); + expect(route).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/x-pack/plugins/license_api_guard/server/license.ts b/x-pack/plugins/license_api_guard/server/license.ts index 3b0fbc8422d637..66e47f02b6e289 100644 --- a/x-pack/plugins/license_api_guard/server/license.ts +++ b/x-pack/plugins/license_api_guard/server/license.ts @@ -44,6 +44,12 @@ export class License { } start({ pluginId, minimumLicenseType, licensing }: StartSettings) { + if (minimumLicenseType === 'basic') { + throw Error( + `Basic licenses don't restrict the use of plugins. Please don't use license_api_guard in the ${pluginId} plugin, or provide a more restrictive minimumLicenseType.` + ); + } + licensing.license$.subscribe((license: ILicense) => { this.licenseType = license.type; this.licenseCheckState = license.check(pluginId, minimumLicenseType!).state; From 1bc7e5462f821feff24f0470d5921d456670f646 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 19 Apr 2021 09:28:44 +0200 Subject: [PATCH 17/17] [Exploratory view] integerate page views to exploratory view (#97258) --- .../app/RumDashboard/PageViewsTrend/index.tsx | 48 +++++++++++++++++-- .../components/empty_view.tsx | 25 ++++++++-- .../exploratory_view/exploratory_view.tsx | 4 +- .../series_builder/series_builder.tsx | 9 ++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index 85df933bcb9e29..52668cf712b8c8 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -6,25 +6,37 @@ */ import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { I18LABELS } from '../translations'; import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageViewsChart } from '../Charts/PageViewsChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { createExploratoryViewUrl } from '../../../../../../observability/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; export function PageViewsTrend() { + const { + services: { http }, + } = useKibana(); + const { urlParams, uiFilters } = useUrlParams(); + const { serviceName } = uiFilters; - const { start, end, searchTerm } = urlParams; + const { start, end, searchTerm, rangeTo, rangeFrom } = urlParams; const [breakdown, setBreakdown] = useState(null); const { data, status } = useFetcher( (callApmApi) => { - const { serviceName } = uiFilters; - if (start && end && serviceName) { return callApmApi({ endpoint: 'GET /api/apm/rum-client/page-view-trends', @@ -45,7 +57,21 @@ export function PageViewsTrend() { } return Promise.resolve(undefined); }, - [end, start, uiFilters, breakdown, searchTerm] + [start, end, serviceName, uiFilters, searchTerm, breakdown] + ); + + const exploratoryViewLink = createExploratoryViewUrl( + { + [`${serviceName}-page-views`]: { + reportType: 'kpi', + time: { from: rangeFrom!, to: rangeTo! }, + reportDefinitions: { + 'service.name': serviceName?.[0] as string, + }, + ...(breakdown ? { breakdown: breakdown.fieldName } : {}), + }, + }, + http?.basePath.get() ); return ( @@ -63,6 +89,18 @@ export function PageViewsTrend() { dataTestSubj={'pvBreakdownFilter'} />
+ + + + + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx index 17f1b039667d0b..69b8b6eb89e468 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx @@ -6,27 +6,44 @@ */ import React from 'react'; -import { EuiImage } from '@elastic/eui'; +import { EuiImage, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { INITIATING_VIEW } from '../series_builder/series_builder'; -export function EmptyView() { +export function EmptyView({ loading }: { loading: boolean }) { const { services: { http }, } = useKibana(); return ( - + )} + + + {INITIATING_VIEW} ); } +const ImageWrap = styled(EuiImage)` + opacity: 0.4; +`; + const Wrapper = styled.div` text-align: center; - opacity: 0.4; height: 550px; + position: relative; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 7b5dde852cf904..6bc91be876cf71 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -27,7 +27,7 @@ export function ExploratoryView() { null ); - const { loadIndexPattern } = useAppIndexPatternContext(); + const { loadIndexPattern, loading } = useAppIndexPatternContext(); const LensComponent = lens?.EmbeddableComponent; @@ -61,7 +61,7 @@ export function ExploratoryView() { attributes={lensAttributes} /> ) : ( - + )} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 5831b8be04c38c..db6e075cc90fba 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -228,9 +228,12 @@ export function SeriesBuilder() { ); } -const INITIATING_VIEW = i18n.translate('xpack.observability.expView.seriesBuilder.initView', { - defaultMessage: 'Initiating view ...', -}); +export const INITIATING_VIEW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.initView', + { + defaultMessage: 'Initiating view ...', + } +); const SELECT_REPORT_TYPE = i18n.translate( 'xpack.observability.expView.seriesBuilder.selectReportType',