diff --git a/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.getkibanabuildnumber.md b/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.getkibanabuildnumber.md new file mode 100644 index 0000000000000..e5a88b65b5620 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.getkibanabuildnumber.md @@ -0,0 +1,11 @@ + + +[Home](./index) > [kibana-plugin-public](./kibana-plugin-public.md) > [InjectedMetadataSetup](./kibana-plugin-public.injectedmetadatasetup.md) > [getKibanaBuildNumber](./kibana-plugin-public.injectedmetadatasetup.getkibanabuildnumber.md) + +## InjectedMetadataSetup.getKibanaBuildNumber property + +Signature: + +```typescript +getKibanaBuildNumber: () => number; +``` diff --git a/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.md b/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.md index 4265c55162289..f6f621cdada45 100644 --- a/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.md +++ b/docs/development/core/public/kibana-plugin-public.injectedmetadatasetup.md @@ -20,6 +20,7 @@ export interface InjectedMetadataSetup | [getCspConfig](./kibana-plugin-public.injectedmetadatasetup.getcspconfig.md) | () => {`

` warnLegacyBrowsers: boolean;`

` } | | | [getInjectedVar](./kibana-plugin-public.injectedmetadatasetup.getinjectedvar.md) | (name: string, defaultValue?: any) => unknown | | | [getInjectedVars](./kibana-plugin-public.injectedmetadatasetup.getinjectedvars.md) | () => {`

` [key: string]: unknown;`

` } | | +| [getKibanaBuildNumber](./kibana-plugin-public.injectedmetadatasetup.getkibanabuildnumber.md) | () => number | | | [getKibanaVersion](./kibana-plugin-public.injectedmetadatasetup.getkibanaversion.md) | () => string | | | [getLegacyMetadata](./kibana-plugin-public.injectedmetadatasetup.getlegacymetadata.md) | () => {`

` app: unknown;`

` translations: unknown;`

` bundleId: string;`

` nav: LegacyNavLink[];`

` version: string;`

` branch: string;`

` buildNum: number;`

` buildSha: string;`

` basePath: string;`

` serverName: string;`

` devMode: boolean;`

` uiSettings: {`

` defaults: UiSettingsState;`

` user?: UiSettingsState | undefined;`

` };`

` } | | | [getPlugins](./kibana-plugin-public.injectedmetadatasetup.getplugins.md) | () => Array<{`

` id: string;`

` plugin: DiscoveredPlugin;`

` }> | An array of frontend plugins in topological order. | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.md index d423cfb5f4dea..f7f7707b5657e 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -8,7 +8,7 @@ Signature: ```typescript -export declare class KibanaRequest +export declare class KibanaRequest ``` ## Properties @@ -27,4 +27,5 @@ export declare class KibanaRequest | --- | --- | --- | | [from(req, routeSchemas)](./kibana-plugin-server.kibanarequest.from.md) | static | Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. | | [getFilteredHeaders(headersToKeep)](./kibana-plugin-server.kibanarequest.getfilteredheaders.md) | | | +| [unstable\_getIncomingMessage()](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md) | | | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md new file mode 100644 index 0000000000000..d4f3c1b54a6cd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md @@ -0,0 +1,15 @@ + + +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [unstable\_getIncomingMessage](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md) + +## KibanaRequest.unstable\_getIncomingMessage() method + +Signature: + +```typescript +unstable_getIncomingMessage(): import("http").IncomingMessage; +``` +Returns: + +`import("http").IncomingMessage` + diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md index cd88ef8fc5dda..e6a79a13dd436 100644 --- a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.md @@ -19,4 +19,5 @@ export interface OnRequestToolkit | [next](./kibana-plugin-server.onrequesttoolkit.next.md) | () => OnRequestResult | To pass request to the next handler | | [redirected](./kibana-plugin-server.onrequesttoolkit.redirected.md) | (url: string) => OnRequestResult | To interrupt request handling and redirect to a configured url | | [rejected](./kibana-plugin-server.onrequesttoolkit.rejected.md) | (error: Error, options?: {`

` statusCode?: number;`

` }) => OnRequestResult | Fail the request with specified error. | +| [setUrl](./kibana-plugin-server.onrequesttoolkit.seturl.md) | (newUrl: string | Url) => void | Change url for an incoming request. | diff --git a/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.seturl.md b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.seturl.md new file mode 100644 index 0000000000000..0f20cbdb18d96 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onrequesttoolkit.seturl.md @@ -0,0 +1,13 @@ + + +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) > [setUrl](./kibana-plugin-server.onrequesttoolkit.seturl.md) + +## OnRequestToolkit.setUrl property + +Change url for an incoming request. + +Signature: + +```typescript +setUrl: (newUrl: string | Url) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md index 65acef7b54774..0ca7fa2a88294 100644 --- a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md +++ b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.http.md @@ -10,5 +10,7 @@ http: { registerAuth: HttpServiceSetup['registerAuth']; registerOnRequest: HttpServiceSetup['registerOnRequest']; + getBasePathFor: HttpServiceSetup['getBasePathFor']; + setBasePathFor: HttpServiceSetup['setBasePathFor']; }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md index 4aa13c2cda9f1..8878edb18230f 100644 --- a/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md +++ b/docs/development/core/server/kibana-plugin-server.pluginsetupcontext.md @@ -17,5 +17,5 @@ export interface PluginSetupContext | Property | Type | Description | | --- | --- | --- | | [elasticsearch](./kibana-plugin-server.pluginsetupcontext.elasticsearch.md) | {`

` adminClient$: Observable<ClusterClient>;`

` dataClient$: Observable<ClusterClient>;`

` } | | -| [http](./kibana-plugin-server.pluginsetupcontext.http.md) | {`

` registerAuth: HttpServiceSetup['registerAuth'];`

` registerOnRequest: HttpServiceSetup['registerOnRequest'];`

` } | | +| [http](./kibana-plugin-server.pluginsetupcontext.http.md) | {`

` registerAuth: HttpServiceSetup['registerAuth'];`

` registerOnRequest: HttpServiceSetup['registerOnRequest'];`

` getBasePathFor: HttpServiceSetup['getBasePathFor'];`

` setBasePathFor: HttpServiceSetup['setBasePathFor'];`

` } | | diff --git a/package.json b/package.json index f92c6a0af54e1..7fffad48ce437 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@babel/core": "^7.3.4", "@babel/polyfill": "^7.2.5", "@babel/register": "^7.0.0", + "@elastic/charts": "^3.11.2", "@elastic/datemath": "5.0.2", "@elastic/eui": "10.4.0", "@elastic/filesaver": "1.1.2", @@ -124,7 +125,7 @@ "@types/recompose": "^0.30.5", "JSONStream": "1.1.1", "abortcontroller-polyfill": "^1.1.9", - "angular": "npm:@elastic/angular@1.6.9-kibana.0", + "angular": "1.6.9", "angular-aria": "1.6.6", "angular-elastic": "2.5.0", "angular-recursion": "^1.0.5", diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 8db9a83081c17..b0d37f0103df0 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -131,13 +131,12 @@ describe('constructor', () => { expect(FatalErrorsServiceConstructor).toHaveBeenCalledTimes(1); - expect(FatalErrorsServiceConstructor).toHaveBeenLastCalledWith({ + expect(FatalErrorsServiceConstructor).toHaveBeenLastCalledWith( rootDomElement, - injectedMetadata: MockInjectedMetadataService, - stopCoreSystem: expect.any(Function), - }); + expect.any(Function) + ); - const [{ stopCoreSystem }] = FatalErrorsServiceConstructor.mock.calls[0]; + const [, stopCoreSystem] = FatalErrorsServiceConstructor.mock.calls[0]; expect(coreSystem.stop).not.toHaveBeenCalled(); stopCoreSystem(); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 6ec62a4a14ff4..ccec571493b08 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -22,7 +22,7 @@ import './core.css'; import { CoreSetup, CoreStart } from '.'; import { BasePathService } from './base_path'; import { ChromeService } from './chrome'; -import { FatalErrorsService } from './fatal_errors'; +import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; import { HttpService } from './http'; import { I18nService } from './i18n'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; @@ -69,6 +69,7 @@ export class CoreSystem { private readonly rootDomElement: HTMLElement; private readonly overlayTargetDomElement: HTMLDivElement; + private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { const { @@ -87,12 +88,9 @@ export class CoreSystem { injectedMetadata, }); - this.fatalErrors = new FatalErrorsService({ - rootDomElement, - injectedMetadata: this.injectedMetadata, - stopCoreSystem: () => { - this.stop(); - }, + this.fatalErrors = new FatalErrorsService(rootDomElement, () => { + // Stop Core before rendering any fatal errors into the DOM + this.stop(); }); this.notifications = new NotificationsService(); @@ -115,11 +113,17 @@ export class CoreSystem { public async setup() { try { + // Setup FatalErrorsService and it's dependencies first so that we're + // able to render any errors. const i18n = this.i18n.setup(); const injectedMetadata = this.injectedMetadata.setup(); - const fatalErrors = this.fatalErrors.setup({ i18n }); - const http = this.http.setup({ fatalErrors }); + this.fatalErrorsSetup = this.fatalErrors.setup({ injectedMetadata, i18n }); const basePath = this.basePath.setup({ injectedMetadata }); + const http = this.http.setup({ + basePath, + injectedMetadata, + fatalErrors: this.fatalErrorsSetup, + }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata, @@ -136,7 +140,7 @@ export class CoreSystem { application, basePath, chrome, - fatalErrors, + fatalErrors: this.fatalErrorsSetup, http, i18n, injectedMetadata, @@ -148,9 +152,15 @@ export class CoreSystem { await this.plugins.setup(core); await this.legacyPlatform.setup({ core }); - return { fatalErrors }; + return { fatalErrors: this.fatalErrorsSetup }; } catch (error) { - this.fatalErrors.add(error); + if (this.fatalErrorsSetup) { + this.fatalErrorsSetup.add(error); + } else { + // If the FatalErrorsService has not yet been setup, log error to console + // eslint-disable-next-line no-console + console.log(error); + } } } @@ -191,7 +201,13 @@ export class CoreSystem { await this.plugins.start(core); await this.legacyPlatform.start({ core, targetDomElement: legacyPlatformTargetDomElement }); } catch (error) { - this.fatalErrors.add(error); + if (this.fatalErrorsSetup) { + this.fatalErrorsSetup.add(error); + } else { + // If the FatalErrorsService has not yet been setup, log error to console + // eslint-disable-next-line no-console + console.error(error); + } } } diff --git a/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap b/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap index 59f3de3e2b907..df60b228972fc 100644 --- a/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap +++ b/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap @@ -1,32 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`#add() deletes all children of rootDomElement and renders into it: fatal error screen component 1`] = ` -Array [ - Array [ - - - , -

, - ], -] -`; - -exports[`#add() deletes all children of rootDomElement and renders into it: fatal error screen container 1`] = ` -
-
-
-`; - -exports[`setup.add() deletes all children of rootDomElement and renders into it: fatal error screen component 1`] = ` Array [ Array [ @@ -36,7 +14,7 @@ Array [ ] `; -exports[`setup.add() deletes all children of rootDomElement and renders into it: fatal error screen container 1`] = ` +exports[`#add() deletes all children of rootDomElement and renders into it: fatal error screen container 1`] = `
diff --git a/src/core/public/fatal_errors/fatal_errors_service.mock.ts b/src/core/public/fatal_errors/fatal_errors_service.mock.ts index 89bcc46f0372f..dd7702a7ee7dd 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.mock.ts +++ b/src/core/public/fatal_errors/fatal_errors_service.mock.ts @@ -31,7 +31,6 @@ type FatalErrorsServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { setup: jest.fn(), - add: jest.fn(() => undefined as never), }; mocked.setup.mockReturnValue(createSetupContractMock()); diff --git a/src/core/public/fatal_errors/fatal_errors_service.test.ts b/src/core/public/fatal_errors/fatal_errors_service.test.ts index b1ad92c8c2f62..373b0efddc2cf 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.test.ts +++ b/src/core/public/fatal_errors/fatal_errors_service.test.ts @@ -25,16 +25,14 @@ expect.addSnapshotSerializer({ }); import { mockRender } from './fatal_errors_service.test.mocks'; +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { FatalErrorsService } from './fatal_errors_service'; function setupService() { const rootDomElement = document.createElement('div'); - const injectedMetadata = { - getKibanaBuildNumber: jest.fn().mockReturnValue('kibanaBuildNumber'), - getKibanaVersion: jest.fn().mockReturnValue('kibanaVersion'), - }; + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); const stopCoreSystem = jest.fn(); @@ -44,16 +42,13 @@ function setupService() { }, }; + const fatalErrorsService = new FatalErrorsService(rootDomElement, stopCoreSystem); + return { rootDomElement, injectedMetadata, - i18n, stopCoreSystem, - fatalErrors: new FatalErrorsService({ - injectedMetadata: injectedMetadata as any, - rootDomElement, - stopCoreSystem, - }), + fatalErrors: fatalErrorsService.setup({ injectedMetadata, i18n }), }; } @@ -91,46 +86,12 @@ describe('#add()', () => { }); }); -describe('setup.add()', () => { - it('exposes a function that passes its two arguments to fatalErrors.add()', () => { - const { fatalErrors, i18n } = setupService(); - - jest.spyOn(fatalErrors, 'add').mockImplementation(() => undefined as never); - - expect(fatalErrors.add).not.toHaveBeenCalled(); - const { add } = fatalErrors.setup({ i18n }); - add('foo', 'bar'); - expect(fatalErrors.add).toHaveBeenCalledTimes(1); - expect(fatalErrors.add).toHaveBeenCalledWith('foo', 'bar'); - }); - - it('deletes all children of rootDomElement and renders into it', () => { - const { fatalErrors, i18n, rootDomElement } = setupService(); - - rootDomElement.innerHTML = ` -

Loading...

-
- `; - - expect(mockRender).not.toHaveBeenCalled(); - expect(rootDomElement.children).toHaveLength(2); - - const { add } = fatalErrors.setup({ i18n }); - - expect(() => add(new Error('foo'))).toThrowError(); - expect(rootDomElement).toMatchSnapshot('fatal error screen container'); - expect(mockRender.mock.calls).toMatchSnapshot('fatal error screen component'); - }); -}); - describe('setup.get$()', () => { it('provides info about the errors passed to fatalErrors.add()', () => { - const { fatalErrors, i18n } = setupService(); - - const setup = fatalErrors.setup({ i18n }); + const { fatalErrors } = setupService(); const onError = jest.fn(); - setup.get$().subscribe(onError); + fatalErrors.get$().subscribe(onError); expect(onError).not.toHaveBeenCalled(); expect(() => { diff --git a/src/core/public/fatal_errors/fatal_errors_service.tsx b/src/core/public/fatal_errors/fatal_errors_service.tsx index e263c8012f477..f26210684b536 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.tsx +++ b/src/core/public/fatal_errors/fatal_errors_service.tsx @@ -23,18 +23,13 @@ import * as Rx from 'rxjs'; import { first, tap } from 'rxjs/operators'; import { I18nSetup } from '../i18n'; -import { InjectedMetadataService } from '../injected_metadata'; +import { InjectedMetadataSetup } from '../'; import { FatalErrorsScreen } from './fatal_errors_screen'; import { ErrorInfo, getErrorInfo } from './get_error_info'; -export interface FatalErrorsParams { - rootDomElement: HTMLElement; - injectedMetadata: InjectedMetadataService; - stopCoreSystem: () => void; -} - interface Deps { i18n: I18nSetup; + injectedMetadata: InjectedMetadataSetup; } /** @@ -62,41 +57,45 @@ export interface FatalErrorsSetup { /** @interal */ export class FatalErrorsService { private readonly errorInfo$ = new Rx.ReplaySubject(); - private i18n?: I18nSetup; - constructor(private params: FatalErrorsParams) { + /** + * + * @param rootDomElement + * @param onFirstErrorCb - Callback function that gets executed after the first error, + * but before the FatalErrorsService renders the error to the DOM. + */ + constructor(private rootDomElement: HTMLElement, private onFirstErrorCb: () => void) {} + + public setup({ i18n, injectedMetadata }: Deps) { this.errorInfo$ .pipe( first(), - tap(() => this.onFirstError()) + tap(() => { + this.onFirstErrorCb(); + this.renderError(injectedMetadata, i18n); + }) ) .subscribe({ error: error => { // eslint-disable-next-line no-console - console.error('Uncaught error in fatal error screen internals', error); + console.error('Uncaught error in fatal error service internals', error); }, }); - } - - public add: FatalErrorsSetup['add'] = (error, source?) => { - const errorInfo = getErrorInfo(error, source); - this.errorInfo$.next(errorInfo); - - if (error instanceof Error) { - // make stack traces clickable by putting whole error in the console - // eslint-disable-next-line no-console - console.error(error); - } + const fatalErrorsSetup: FatalErrorsSetup = { + add: (error, source?) => { + const errorInfo = getErrorInfo(error, source); - throw error; - }; + this.errorInfo$.next(errorInfo); - public setup({ i18n }: Deps) { - this.i18n = i18n; + if (error instanceof Error) { + // make stack traces clickable by putting whole error in the console + // eslint-disable-next-line no-console + console.error(error); + } - const fatalErrorsSetup: FatalErrorsSetup = { - add: this.add, + throw error; + }, get$: () => { return this.errorInfo$.asObservable(); }, @@ -105,30 +104,22 @@ export class FatalErrorsService { return fatalErrorsSetup; } - private onFirstError() { - // stop the core systems so that things like the legacy platform are stopped - // and angular/react components are unmounted; - this.params.stopCoreSystem(); - + private renderError(injectedMetadata: InjectedMetadataSetup, i18n: I18nSetup) { // delete all content in the rootDomElement - this.params.rootDomElement.textContent = ''; + this.rootDomElement.textContent = ''; // create and mount a container for the const container = document.createElement('div'); - this.params.rootDomElement.appendChild(container); - - // If error occurred before I18nService has been set up we don't have any - // i18n context to provide. - const I18nContext = this.i18n ? this.i18n.Context : React.Fragment; + this.rootDomElement.appendChild(container); render( - + - , + , container ); } diff --git a/src/core/public/http/_import_objects.ndjson b/src/core/public/http/_import_objects.ndjson new file mode 100644 index 0000000000000..3511fb44cdfb2 --- /dev/null +++ b/src/core/public/http/_import_objects.ndjson @@ -0,0 +1 @@ +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Log Agents","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"082f1d60-a2e7-11e7-bb30-233be9be6a15","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1} diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts new file mode 100644 index 0000000000000..9bfc13820cb55 --- /dev/null +++ b/src/core/public/http/fetch.ts @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { merge } from 'lodash'; +import { format } from 'url'; + +import { HttpFetchOptions, HttpBody, Deps } from './types'; +import { HttpFetchError } from './http_fetch_error'; + +const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; +const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; + +export const setup = ({ basePath, injectedMetadata }: Deps) => { + async function fetch(path: string, options: HttpFetchOptions = {}): Promise { + const { query, prependBasePath, ...fetchOptions } = merge( + { + method: 'GET', + credentials: 'same-origin', + prependBasePath: true, + headers: { + 'kbn-version': injectedMetadata.getKibanaVersion(), + 'Content-Type': 'application/json', + }, + }, + options + ); + const url = format({ + pathname: prependBasePath ? basePath.addToPath(path) : path, + query, + }); + + if ( + options.headers && + 'Content-Type' in options.headers && + options.headers['Content-Type'] === undefined + ) { + delete fetchOptions.headers['Content-Type']; + } + + let response; + let body = null; + + try { + response = await window.fetch(url, fetchOptions as RequestInit); + } catch (err) { + throw new HttpFetchError(err.message); + } + + const contentType = response.headers.get('Content-Type') || ''; + + try { + if (NDJSON_CONTENT.test(contentType)) { + body = await response.blob(); + } else if (JSON_CONTENT.test(contentType)) { + body = await response.json(); + } else { + body = await response.text(); + } + } catch (err) { + throw new HttpFetchError(err.message, response, body); + } + + if (!response.ok) { + throw new HttpFetchError(response.statusText, response, body); + } + + return body; + } + + function shorthand(method: string) { + return (path: string, options: HttpFetchOptions = {}) => fetch(path, { ...options, method }); + } + + return { fetch, shorthand }; +}; diff --git a/src/legacy/ui/public/kfetch/kfetch_abortable.ts b/src/core/public/http/http_fetch_error.ts similarity index 57% rename from src/legacy/ui/public/kfetch/kfetch_abortable.ts rename to src/core/public/http/http_fetch_error.ts index 11057054f330f..a73fb7e3ffbd4 100644 --- a/src/legacy/ui/public/kfetch/kfetch_abortable.ts +++ b/src/core/public/http/http_fetch_error.ts @@ -17,29 +17,14 @@ * under the License. */ -import { kfetch, KFetchKibanaOptions, KFetchOptions } from './kfetch'; +export class HttpFetchError extends Error { + constructor(message: string, public readonly response?: Response, public readonly body?: any) { + super(message); -type Omit = Pick>; - -function createAbortable() { - const abortController = new AbortController(); - const { signal, abort } = abortController; - - return { - signal, - abort: abort.bind(abortController), - }; -} - -export function kfetchAbortable( - fetchOptions?: Omit, - kibanaOptions?: KFetchKibanaOptions -) { - const { signal, abort } = createAbortable(); - const fetching = kfetch({ ...fetchOptions, signal }, kibanaOptions); - - return { - fetching, - abort, - }; + // captureStackTrace is only available in the V8 engine, so any browser using + // a different JS engine won't have access to this method. + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HttpFetchError); + } + } } diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 3146ad4d2217f..1683af857d7ae 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -16,25 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import { HttpService, HttpSetup } from './http_service'; -const createSetupContractMock = () => { - const setupContract: jest.Mocked = { - addLoadingCount: jest.fn(), - getLoadingCount$: jest.fn(), - }; - return setupContract; -}; +import { HttpService, HttpSetup, HttpStart } from './http_service'; -type HttpServiceContract = PublicMethodsOf; -const createMock = () => { - const mocked: jest.Mocked = { - setup: jest.fn(), - stop: jest.fn(), - }; - mocked.setup.mockReturnValue(createSetupContractMock()); - return mocked; -}; +const createSetupContractMock = (): jest.Mocked => ({ + fetch: jest.fn(), + get: jest.fn(), + head: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + options: jest.fn(), + addLoadingCount: jest.fn(), + getLoadingCount$: jest.fn(), +}); +const createStartContractMock = (): jest.Mocked => undefined; +const createMock = (): jest.Mocked> => ({ + setup: jest.fn().mockReturnValue(createSetupContractMock()), + start: jest.fn().mockReturnValue(createStartContractMock()), + stop: jest.fn(), +}); export const httpServiceMock = { create: createMock, diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index 35b674aca9b9f..51f4af44d313d 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -19,35 +19,254 @@ import * as Rx from 'rxjs'; import { toArray } from 'rxjs/operators'; +// @ts-ignore +import fetchMock from 'fetch-mock/es5/client'; +import { BasePathService } from '../base_path/base_path_service'; import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.mock'; +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { HttpService } from './http_service'; +import { readFileSync } from 'fs'; +import { join } from 'path'; function setupService() { - const service = new HttpService(); + const httpService = new HttpService(); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); - const setup = service.setup({ fatalErrors }); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); - return { service, fatalErrors, setup }; + injectedMetadata.getBasePath.mockReturnValueOnce('http://localhost/myBase'); + + const basePath = new BasePathService().setup({ injectedMetadata }); + const http = httpService.setup({ basePath, fatalErrors, injectedMetadata }); + + return { httpService, fatalErrors, http }; } +describe('http requests', async () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('should use supplied request method', async () => { + const { http } = setupService(); + + fetchMock.post('*', {}); + await http.fetch('/my/path', { method: 'POST' }); + + expect(fetchMock.lastOptions()!.method).toBe('POST'); + }); + + it('should use supplied Content-Type', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('/my/path', { headers: { 'Content-Type': 'CustomContentType' } }); + + expect(fetchMock.lastOptions()!.headers).toMatchObject({ + 'Content-Type': 'CustomContentType', + }); + }); + + it('should use supplied pathname and querystring', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('/my/path', { query: { a: 'b' } }); + + expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path?a=b'); + }); + + it('should use supplied headers', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('/my/path', { + headers: { myHeader: 'foo' }, + }); + + expect(fetchMock.lastOptions()!.headers).toEqual({ + 'Content-Type': 'application/json', + 'kbn-version': 'kibanaVersion', + myHeader: 'foo', + }); + }); + + it('should return response', async () => { + const { http } = setupService(); + + fetchMock.get('*', { foo: 'bar' }); + + const json = await http.fetch('/my/path'); + + expect(json).toEqual({ foo: 'bar' }); + }); + + it('should prepend url with basepath by default', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('/my/path'); + + expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path'); + }); + + it('should not prepend url with basepath when disabled', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('my/path', { prependBasePath: false }); + + expect(fetchMock.lastUrl()).toBe('/my/path'); + }); + + it('should make request with defaults', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.fetch('/my/path'); + + expect(fetchMock.lastOptions()!).toMatchObject({ + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'kbn-version': 'kibanaVersion', + }, + }); + }); + + it('should reject on network error', async () => { + const { http } = setupService(); + + expect.assertions(1); + fetchMock.get('*', { status: 500 }); + + await expect(http.fetch('/my/path')).rejects.toThrow(/Internal Server Error/); + }); + + it('should contain error message when throwing response', async () => { + const { http } = setupService(); + + fetchMock.get('*', { status: 404, body: { foo: 'bar' } }); + + await expect(http.fetch('/my/path')).rejects.toMatchObject({ + message: 'Not Found', + body: { + foo: 'bar', + }, + response: { + status: 404, + url: 'http://localhost/myBase/my/path', + }, + }); + }); + + it('should support get() helper', async () => { + const { http } = setupService(); + + fetchMock.get('*', {}); + await http.get('/my/path', { method: 'POST' }); + + expect(fetchMock.lastOptions()!.method).toBe('GET'); + }); + + it('should support head() helper', async () => { + const { http } = setupService(); + + fetchMock.head('*', {}); + await http.head('/my/path', { method: 'GET' }); + + expect(fetchMock.lastOptions()!.method).toBe('HEAD'); + }); + + it('should support post() helper', async () => { + const { http } = setupService(); + + fetchMock.post('*', {}); + await http.post('/my/path', { method: 'GET', body: '{}' }); + + expect(fetchMock.lastOptions()!.method).toBe('POST'); + }); + + it('should support put() helper', async () => { + const { http } = setupService(); + + fetchMock.put('*', {}); + await http.put('/my/path', { method: 'GET', body: '{}' }); + + expect(fetchMock.lastOptions()!.method).toBe('PUT'); + }); + + it('should support patch() helper', async () => { + const { http } = setupService(); + + fetchMock.patch('*', {}); + await http.patch('/my/path', { method: 'GET', body: '{}' }); + + expect(fetchMock.lastOptions()!.method).toBe('PATCH'); + }); + + it('should support delete() helper', async () => { + const { http } = setupService(); + + fetchMock.delete('*', {}); + await http.delete('/my/path', { method: 'GET' }); + + expect(fetchMock.lastOptions()!.method).toBe('DELETE'); + }); + + it('should support options() helper', async () => { + const { http } = setupService(); + + fetchMock.mock('*', { method: 'OPTIONS' }); + await http.options('/my/path', { method: 'GET' }); + + expect(fetchMock.lastOptions()!.method).toBe('OPTIONS'); + }); + + it('should make requests for NDJSON content', async () => { + const { http } = setupService(); + const content = readFileSync(join(__dirname, '_import_objects.ndjson'), { encoding: 'utf-8' }); + const body = new FormData(); + + body.append('file', content); + fetchMock.post('*', { + body: content, + headers: { 'Content-Type': 'application/ndjson' }, + }); + + const data = await http.post('/my/path', { + body, + headers: { + 'Content-Type': undefined, + }, + }); + + expect(data).toBeInstanceOf(Blob); + + const ndjson = await new Response(data).text(); + + expect(ndjson).toEqual(content); + }); +}); + describe('addLoadingCount()', async () => { it('subscribes to passed in sources, unsubscribes on stop', () => { - const { service, setup } = setupService(); + const { httpService, http } = setupService(); const unsubA = jest.fn(); const subA = jest.fn().mockReturnValue(unsubA); - setup.addLoadingCount(new Rx.Observable(subA)); + http.addLoadingCount(new Rx.Observable(subA)); expect(subA).toHaveBeenCalledTimes(1); expect(unsubA).not.toHaveBeenCalled(); const unsubB = jest.fn(); const subB = jest.fn().mockReturnValue(unsubB); - setup.addLoadingCount(new Rx.Observable(subB)); + http.addLoadingCount(new Rx.Observable(subB)); expect(subB).toHaveBeenCalledTimes(1); expect(unsubB).not.toHaveBeenCalled(); - service.stop(); + httpService.stop(); expect(subA).toHaveBeenCalledTimes(1); expect(unsubA).toHaveBeenCalledTimes(1); @@ -56,35 +275,35 @@ describe('addLoadingCount()', async () => { }); it('adds a fatal error if source observables emit an error', async () => { - const { setup, fatalErrors } = setupService(); + const { http, fatalErrors } = setupService(); - setup.addLoadingCount(Rx.throwError(new Error('foo bar'))); + http.addLoadingCount(Rx.throwError(new Error('foo bar'))); expect(fatalErrors.add.mock.calls).toMatchSnapshot(); }); it('adds a fatal error if source observable emits a negative number', async () => { - const { setup, fatalErrors } = setupService(); + const { http, fatalErrors } = setupService(); - setup.addLoadingCount(Rx.of(1, 2, 3, 4, -9)); + http.addLoadingCount(Rx.of(1, 2, 3, 4, -9)); expect(fatalErrors.add.mock.calls).toMatchSnapshot(); }); }); describe('getLoadingCount$()', async () => { it('emits 0 initially, the right count when sources emit their own count, and ends with zero', async () => { - const { service, setup } = setupService(); + const { httpService, http } = setupService(); const countA$ = new Rx.Subject(); const countB$ = new Rx.Subject(); const countC$ = new Rx.Subject(); - const promise = setup + const promise = http .getLoadingCount$() .pipe(toArray()) .toPromise(); - setup.addLoadingCount(countA$); - setup.addLoadingCount(countB$); - setup.addLoadingCount(countC$); + http.addLoadingCount(countA$); + http.addLoadingCount(countB$); + http.addLoadingCount(countC$); countA$.next(100); countB$.next(10); @@ -94,20 +313,20 @@ describe('getLoadingCount$()', async () => { countC$.complete(); countB$.next(0); - service.stop(); + httpService.stop(); expect(await promise).toMatchSnapshot(); }); it('only emits when loading count changes', async () => { - const { service, setup } = setupService(); + const { httpService, http } = setupService(); const count$ = new Rx.Subject(); - const promise = setup + const promise = http .getLoadingCount$() .pipe(toArray()) .toPromise(); - setup.addLoadingCount(count$); + http.addLoadingCount(count$); count$.next(0); count$.next(0); count$.next(0); @@ -115,7 +334,7 @@ describe('getLoadingCount$()', async () => { count$.next(0); count$.next(1); count$.next(1); - service.stop(); + httpService.stop(); expect(await promise).toMatchSnapshot(); }); diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 4121181cf80ec..5a73ade939365 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -28,19 +28,26 @@ import { tap, } from 'rxjs/operators'; -import { FatalErrorsSetup } from '../fatal_errors'; - -interface Deps { - fatalErrors: FatalErrorsSetup; -} +import { Deps } from './types'; +import { setup } from './fetch'; /** @internal */ export class HttpService { private readonly loadingCount$ = new Rx.BehaviorSubject(0); private readonly stop$ = new Rx.Subject(); - public setup({ fatalErrors }: Deps) { + public setup(deps: Deps) { + const { fetch, shorthand } = setup(deps); + return { + fetch, + delete: shorthand('DELETE'), + get: shorthand('GET'), + head: shorthand('HEAD'), + options: shorthand('OPTIONS'), + patch: shorthand('PATCH'), + post: shorthand('POST'), + put: shorthand('PUT'), addLoadingCount: (count$: Rx.Observable) => { count$ .pipe( @@ -67,7 +74,7 @@ export class HttpService { this.loadingCount$.next(this.loadingCount$.getValue() + delta); }, error: error => { - fatalErrors.add(error); + deps.fatalErrors.add(error); }, }); }, @@ -78,6 +85,9 @@ export class HttpService { }; } + // eslint-disable-next-line no-unused-params + public start(deps: Deps) {} + public stop() { this.stop$.next(); this.loadingCount$.complete(); @@ -86,3 +96,4 @@ export class HttpService { /** @public */ export type HttpSetup = ReturnType; +export type HttpStart = ReturnType; diff --git a/src/core/public/http/index.ts b/src/core/public/http/index.ts index 24ba49a4dfcac..ee17c225c711b 100644 --- a/src/core/public/http/index.ts +++ b/src/core/public/http/index.ts @@ -18,3 +18,4 @@ */ export { HttpService, HttpSetup } from './http_service'; +export { HttpFetchError } from './http_fetch_error'; diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts new file mode 100644 index 0000000000000..05f6ab502246d --- /dev/null +++ b/src/core/public/http/types.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BasePathSetup } from '../base_path'; +import { InjectedMetadataSetup } from '../injected_metadata'; +import { FatalErrorsSetup } from '../fatal_errors'; + +export interface HttpHeadersInit { + [name: string]: any; +} +export interface HttpRequestInit { + body?: BodyInit | null; + cache?: RequestCache; + credentials?: RequestCredentials; + headers?: HttpHeadersInit; + integrity?: string; + keepalive?: boolean; + method?: string; + mode?: RequestMode; + redirect?: RequestRedirect; + referrer?: string; + referrerPolicy?: ReferrerPolicy; + signal?: AbortSignal | null; + window?: any; +} +export interface Deps { + basePath: BasePathSetup; + injectedMetadata: InjectedMetadataSetup; + fatalErrors: FatalErrorsSetup; +} +export interface HttpFetchQuery { + [key: string]: string | number | boolean | undefined; +} +export interface HttpFetchOptions extends HttpRequestInit { + query?: HttpFetchQuery; + prependBasePath?: boolean; + headers?: HttpHeadersInit; +} +export type HttpBody = BodyInit | null; diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 55d959e69d173..39c40cb8a4bfd 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -27,6 +27,7 @@ const createSetupContractMock = () => { getPlugins: jest.fn(), getInjectedVar: jest.fn(), getInjectedVars: jest.fn(), + getKibanaBuildNumber: jest.fn(), }; setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); @@ -47,8 +48,6 @@ type InjectedMetadataServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ setup: jest.fn().mockReturnValue(createSetupContractMock()), start: jest.fn().mockReturnValue(createStartContractMock()), - getKibanaVersion: jest.fn(), - getKibanaBuildNumber: jest.fn(), }); export const injectedMetadataServiceMock = { diff --git a/src/core/public/injected_metadata/injected_metadata_service.test.ts b/src/core/public/injected_metadata/injected_metadata_service.test.ts index 74e860d16a2b2..ef35fd2aa78ac 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -20,42 +20,29 @@ import { DiscoveredPlugin } from '../../server'; import { InjectedMetadataService } from './injected_metadata_service'; -describe('#getKibanaVersion', () => { - it('returns version from injectedMetadata', () => { - const injectedMetadata = new InjectedMetadataService({ - injectedMetadata: { - version: 'foo', - }, - } as any); - - expect(injectedMetadata.getKibanaVersion()).toBe('foo'); - }); -}); - -describe('#getKibanaBuildNumber', () => { +describe('setup.getKibanaBuildNumber()', () => { it('returns buildNumber from injectedMetadata', () => { - const injectedMetadata = new InjectedMetadataService({ + const setup = new InjectedMetadataService({ injectedMetadata: { buildNumber: 'foo', }, - } as any); + } as any).setup(); - expect(injectedMetadata.getKibanaBuildNumber()).toBe('foo'); + expect(setup.getKibanaBuildNumber()).toBe('foo'); }); }); describe('setup.getCspConfig()', () => { it('returns injectedMetadata.csp', () => { - const injectedMetadata = new InjectedMetadataService({ + const setup = new InjectedMetadataService({ injectedMetadata: { csp: { warnLegacyBrowsers: true, }, }, - } as any); + } as any).setup(); - const contract = injectedMetadata.setup(); - expect(contract.getCspConfig()).toEqual({ + expect(setup.getCspConfig()).toEqual({ warnLegacyBrowsers: true, }); }); diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 625f29d695274..de7ad1595ed36 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -83,6 +83,10 @@ export class InjectedMetadataService { constructor(private readonly params: InjectedMetadataParams) {} + public start(): InjectedMetadataStart { + return this.setup(); + } + public setup(): InjectedMetadataSetup { return { getBasePath: () => { @@ -90,7 +94,7 @@ export class InjectedMetadataService { }, getKibanaVersion: () => { - return this.getKibanaVersion(); + return this.state.version; }, getCspConfig: () => { @@ -112,19 +116,11 @@ export class InjectedMetadataService { getInjectedVars: () => { return this.state.vars; }, - }; - } - - public start(): InjectedMetadataStart { - return this.setup(); - } - public getKibanaVersion() { - return this.state.version; - } - - public getKibanaBuildNumber() { - return this.state.buildNumber; + getKibanaBuildNumber: () => { + return this.state.buildNumber; + }, + }; } } @@ -135,6 +131,7 @@ export class InjectedMetadataService { */ export interface InjectedMetadataSetup { getBasePath: () => string; + getKibanaBuildNumber: () => number; getKibanaVersion: () => string; getCspConfig: () => { warnLegacyBrowsers: boolean; diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index bc1aff696ed63..6e6f256497426 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -70,6 +70,7 @@ export class LegacyPlatformService { require('ui/metadata').__newPlatformSetup__(injectedMetadata.getLegacyMetadata()); require('ui/i18n').__newPlatformSetup__(i18n.Context); require('ui/notify/fatal_error').__newPlatformSetup__(fatalErrors); + require('ui/kfetch').__newPlatformSetup__(http); require('ui/notify/toasts').__newPlatformSetup__(notifications.toasts); require('ui/chrome/api/loading_count').__newPlatformSetup__(http); require('ui/chrome/api/base_path').__newPlatformSetup__(basePath); diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 01df16eb11719..57b0a01e98137 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -162,7 +162,7 @@ export class CoreSystem { constructor(params: Params); // (undocumented) setup(): Promise<{ - fatalErrors: import(".").FatalErrorsSetup; + fatalErrors: FatalErrorsSetup; } | undefined>; // (undocumented) start(): Promise; @@ -253,6 +253,8 @@ export interface InjectedMetadataSetup { [key: string]: unknown; }; // (undocumented) + getKibanaBuildNumber: () => number; + // (undocumented) getKibanaVersion: () => string; // (undocumented) getLegacyMetadata: () => { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 75547074bedb6..21e1193471972 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -30,6 +30,7 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { HttpConfig, Router } from '.'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { HttpServer } from './http_server'; +import { KibanaRequest } from './router'; const chance = new Chance(); @@ -613,3 +614,97 @@ test('throws an error if starts without set up', async () => { `"Http server is not setup up yet"` ); }); + +test('#getBasePathFor() returns base path associated with an incoming request', async () => { + const { + getBasePathFor, + setBasePathFor, + registerRouter, + server: innerServer, + registerOnRequest, + } = await server.setup(config); + + const path = '/base-path'; + registerOnRequest((req, t) => { + setBasePathFor(req, path); + return t.next(); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, (req, res) => res.ok({ key: getBasePathFor(req) })); + registerRouter(router); + + await server.start(config); + await supertest(innerServer.listener) + .get('/') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: path }); + }); +}); + +test('#getBasePathFor() is based on server base path', async () => { + const configWithBasePath = { + ...config, + basePath: '/bar', + }; + const { + getBasePathFor, + setBasePathFor, + registerRouter, + server: innerServer, + registerOnRequest, + } = await server.setup(configWithBasePath); + + const path = '/base-path'; + registerOnRequest((req, t) => { + setBasePathFor(req, path); + return t.next(); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, async (req, res) => + res.ok({ key: getBasePathFor(req) }) + ); + registerRouter(router); + + await server.start(configWithBasePath); + await supertest(innerServer.listener) + .get('/') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: `${configWithBasePath.basePath}${path}` }); + }); +}); + +test('#setBasePathFor() cannot be set twice for one request', async () => { + const incomingMessage = { + url: '/', + }; + const kibanaRequestFactory = { + from() { + return KibanaRequest.from( + { + headers: {}, + path: '/', + raw: { + req: incomingMessage, + }, + } as any, + undefined + ); + }, + }; + jest.doMock('./router/request', () => ({ + KibanaRequest: jest.fn(() => kibanaRequestFactory), + })); + + const { setBasePathFor } = await server.setup(config); + + const setPath = () => setBasePathFor(kibanaRequestFactory.from(), '/path'); + + setPath(); + expect(setPath).toThrowErrorMatchingInlineSnapshot( + `"Request basePath was previously set. Setting multiple times is not supported."` + ); +}); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 1e7361c8df535..6dbae8a14d601 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Server, ServerOptions } from 'hapi'; +import { Request, Server, ServerOptions } from 'hapi'; import { modifyUrl } from '../../utils'; import { Logger } from '../logging'; @@ -25,8 +25,7 @@ import { HttpConfig } from './http_config'; import { createServer, getServerOptions } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnRequestFormat, OnRequestHandler } from './lifecycle/on_request'; -import { Router } from './router'; - +import { Router, KibanaRequest } from './router'; import { SessionStorageCookieOptions, createCookieSessionStorageFactory, @@ -50,12 +49,18 @@ export interface HttpServerSetup { * Can register any number of OnRequestHandlers, which are called in sequence (from the first registered to the last) */ registerOnRequest: (requestHandler: OnRequestHandler) => void; + getBasePathFor: (request: KibanaRequest | Request) => string; + setBasePathFor: (request: KibanaRequest | Request, basePath: string) => void; } export class HttpServer { private server?: Server; private registeredRouters = new Set(); private authRegistered = false; + private basePathCache = new WeakMap< + ReturnType, + string + >(); constructor(private readonly log: Logger) {} @@ -72,6 +77,28 @@ export class HttpServer { this.registeredRouters.add(router); } + // passing hapi Request works for BWC. can be deleted once we remove legacy server. + private getBasePathFor(config: HttpConfig, request: KibanaRequest | Request) { + const incomingMessage = + request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req; + + const requestScopePath = this.basePathCache.get(incomingMessage) || ''; + const serverBasePath = config.basePath || ''; + return `${serverBasePath}${requestScopePath}`; + } + + // should work only for KibanaRequest as soon as spaces migrate to NP + private setBasePathFor(request: KibanaRequest | Request, basePath: string) { + const incomingMessage = + request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req; + if (this.basePathCache.has(incomingMessage)) { + throw new Error( + 'Request basePath was previously set. Setting multiple times is not supported.' + ); + } + this.basePathCache.set(incomingMessage, basePath); + } + public setup(config: HttpConfig): HttpServerSetup { const serverOptions = getServerOptions(config); this.server = createServer(serverOptions); @@ -84,6 +111,8 @@ export class HttpServer { fn: AuthenticationHandler, cookieOptions: SessionStorageCookieOptions ) => this.registerAuth(fn, cookieOptions, config.basePath), + getBasePathFor: this.getBasePathFor.bind(this, config), + setBasePathFor: this.setBasePathFor.bind(this), // Return server instance with the connection options so that we can properly // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return the instance from this method. diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index fe9b8a082ace5..289eae0990531 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -26,6 +26,8 @@ const createSetupContractMock = () => { registerAuth: jest.fn(), registerOnRequest: jest.fn(), registerRouter: jest.fn(), + getBasePathFor: jest.fn(), + setBasePathFor: jest.fn(), // we can mock some hapi server method when we need it server: {} as Server, }; diff --git a/src/core/server/http/integration_tests/http_service.test.ts b/src/core/server/http/integration_tests/http_service.test.ts index ac2e5ebbe5cbe..c635429159fe3 100644 --- a/src/core/server/http/integration_tests/http_service.test.ts +++ b/src/core/server/http/integration_tests/http_service.test.ts @@ -18,6 +18,8 @@ */ import path from 'path'; +import { parse } from 'url'; + import request from 'request'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; import { Router } from '../router'; @@ -27,12 +29,12 @@ import { url as onReqUrl } from './__fixtures__/plugins/dummy_on_request/server/ describe('http service', () => { describe('setup contract', () => { describe('#registerAuth()', () => { - const dummySecurityPlugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_security'); + const plugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_security'); let root: ReturnType; beforeAll(async () => { root = kbnTestServer.createRoot( { - plugins: { paths: [dummySecurityPlugin] }, + plugins: { paths: [plugin] }, }, { dev: true, @@ -109,15 +111,12 @@ describe('http service', () => { }); describe('#registerOnRequest()', () => { - const dummyOnRequestPlugin = path.resolve( - __dirname, - './__fixtures__/plugins/dummy_on_request' - ); + const plugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_on_request'); let root: ReturnType; - beforeAll(async () => { + beforeEach(async () => { root = kbnTestServer.createRoot( { - plugins: { paths: [dummyOnRequestPlugin] }, + plugins: { paths: [plugin] }, }, { dev: true, @@ -136,7 +135,7 @@ describe('http service', () => { await root.start(); }, 30000); - afterAll(async () => await root.shutdown()); + afterEach(async () => await root.shutdown()); it('Should support passing request through to the route handler', async () => { await kbnTestServer.request.get(root, onReqUrl.root).expect(200, { content: 'ok' }); }); @@ -160,5 +159,79 @@ describe('http service', () => { await kbnTestServer.request.get(root, onReqUrl.independentReq).expect(200); }); }); + + describe('#registerOnRequest() toolkit', () => { + let root: ReturnType; + beforeEach(async () => { + root = kbnTestServer.createRoot(); + }, 30000); + + afterEach(async () => await root.shutdown()); + it('supports Url change on the flight', async () => { + const { http } = await root.setup(); + http.registerOnRequest((req, t) => { + t.setUrl(parse('/new-url')); + return t.next(); + }); + + const router = new Router('/'); + router.get({ path: '/new-url', validate: false }, async (req, res) => + res.ok({ key: 'new-url-reached' }) + ); + http.registerRouter(router); + + await root.start(); + + await kbnTestServer.request.get(root, '/').expect(200, { key: 'new-url-reached' }); + }); + + it('url re-write works for legacy server as well', async () => { + const { http } = await root.setup(); + const newUrl = '/new-url'; + http.registerOnRequest((req, t) => { + t.setUrl(newUrl); + return t.next(); + }); + + await root.start(); + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: newUrl, + handler: () => 'ok-from-legacy', + }); + + await kbnTestServer.request.get(root, '/').expect(200, 'ok-from-legacy'); + }); + }); + + describe('#getBasePathFor()/#setBasePathFor()', () => { + let root: ReturnType; + beforeEach(async () => { + root = kbnTestServer.createRoot(); + }, 30000); + + afterEach(async () => await root.shutdown()); + it('basePath information for an incoming request is available in legacy server', async () => { + const reqBasePath = '/requests-specific-base-path'; + const { http } = await root.setup(); + http.registerOnRequest((req, t) => { + http.setBasePathFor(req, reqBasePath); + return t.next(); + }); + + await root.start(); + + const legacyUrl = '/legacy'; + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: legacyUrl, + handler: kbnServer.newPlatform.setup.core.http.getBasePathFor, + }); + + await kbnTestServer.request.get(root, legacyUrl).expect(200, reqBasePath); + }); + }); }); }); diff --git a/src/core/server/http/lifecycle/on_request.ts b/src/core/server/http/lifecycle/on_request.ts index 6192a1dae682c..168b4f513400f 100644 --- a/src/core/server/http/lifecycle/on_request.ts +++ b/src/core/server/http/lifecycle/on_request.ts @@ -17,6 +17,7 @@ * under the License. */ +import { Url } from 'url'; import Boom from 'boom'; import { Lifecycle, Request, ResponseToolkit } from 'hapi'; import { KibanaRequest } from '../router'; @@ -64,14 +65,10 @@ export interface OnRequestToolkit { redirected: (url: string) => OnRequestResult; /** Fail the request with specified error. */ rejected: (error: Error, options?: { statusCode?: number }) => OnRequestResult; + /** Change url for an incoming request. */ + setUrl: (newUrl: string | Url) => void; } -const toolkit: OnRequestToolkit = { - next: OnRequestResult.next, - redirected: OnRequestResult.redirected, - rejected: OnRequestResult.rejected, -}; - /** @public */ export type OnRequestHandler = ( req: KibanaRequest, @@ -86,11 +83,20 @@ export type OnRequestHandler = ( */ export function adoptToHapiOnRequestFormat(fn: OnRequestHandler) { return async function interceptRequest( - req: Request, + request: Request, h: ResponseToolkit ): Promise { try { - const result = await fn(KibanaRequest.from(req, undefined), toolkit); + const result = await fn(KibanaRequest.from(request, undefined), { + next: OnRequestResult.next, + redirected: OnRequestResult.redirected, + rejected: OnRequestResult.rejected, + setUrl: (newUrl: string | Url) => { + request.setUrl(newUrl); + // We should update raw request as well since it can be proxied to the old platform + request.raw.req.url = typeof newUrl === 'string' ? newUrl : newUrl.href; + }, + }); if (OnRequestResult.isValidResult(result)) { if (result.isNext()) { return h.continue; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 69de94e5fc6da..03b62f4948306 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -24,7 +24,7 @@ import { filterHeaders, Headers } from './headers'; import { RouteSchemas } from './route'; /** @public */ -export class KibanaRequest { +export class KibanaRequest { /** * Factory for creating requests. Validates the request before creating an * instance of a KibanaRequest. @@ -71,12 +71,22 @@ export class KibanaRequest { public readonly headers: Headers; public readonly path: string; - constructor(req: Request, readonly params: Params, readonly query: Query, readonly body: Body) { - this.headers = req.headers; - this.path = req.path; + constructor( + private readonly request: Request, + readonly params: Params, + readonly query: Query, + readonly body: Body + ) { + this.headers = request.headers; + this.path = request.path; } public getFilteredHeaders(headersToKeep: string[]) { return filterHeaders(this.headers, headersToKeep); } + + // eslint-disable-next-line @typescript-eslint/camelcase + public unstable_getIncomingMessage() { + return this.request.raw.req; + } } diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index e75045007e4fd..a640a413fd81b 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -171,4 +171,4 @@ export class Router { export type RequestHandler

= ( req: KibanaRequest, TypeOf, TypeOf>, createResponse: ResponseFactory -) => Promise>; +) => KibanaResponse | Promise>; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index b738e192896dd..f216ae6d6fb5e 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -58,6 +58,8 @@ export interface PluginSetupContext { http: { registerAuth: HttpServiceSetup['registerAuth']; registerOnRequest: HttpServiceSetup['registerOnRequest']; + getBasePathFor: HttpServiceSetup['getBasePathFor']; + setBasePathFor: HttpServiceSetup['setBasePathFor']; }; } @@ -148,6 +150,8 @@ export function createPluginSetupContext( http: { registerAuth: deps.http.registerAuth, registerOnRequest: deps.http.registerOnRequest, + getBasePathFor: deps.http.getBasePathFor, + setBasePathFor: deps.http.setBasePathFor, }, }; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 0d51fce8f88f5..fab525ac31718 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -16,6 +16,7 @@ import { Server } from 'hapi'; import { ServerOptions } from 'hapi'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { Url } from 'url'; // @public (undocumented) export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; @@ -143,8 +144,8 @@ export interface HttpServiceStart { } // @public (undocumented) -export class KibanaRequest { - constructor(req: Request, params: Params, query: Query, body: Body); +export class KibanaRequest { + constructor(request: Request, params: Params, query: Query, body: Body); // (undocumented) readonly body: Body; // Warning: (ae-forgotten-export) The symbol "RouteSchemas" needs to be exported by the entry point index.d.ts @@ -159,6 +160,8 @@ export class KibanaRequest { readonly path: string; // (undocumented) readonly query: Query; + // (undocumented) + unstable_getIncomingMessage(): import("http").IncomingMessage; } // @public @@ -242,6 +245,7 @@ export interface OnRequestToolkit { rejected: (error: Error, options?: { statusCode?: number; }) => OnRequestResult; + setUrl: (newUrl: string | Url) => void; } // @public @@ -286,6 +290,8 @@ export interface PluginSetupContext { http: { registerAuth: HttpServiceSetup['registerAuth']; registerOnRequest: HttpServiceSetup['registerOnRequest']; + getBasePathFor: HttpServiceSetup['getBasePathFor']; + setBasePathFor: HttpServiceSetup['setBasePathFor']; }; } diff --git a/src/dev/build/lib/runner.js b/src/dev/build/lib/runner.js index b4ef4921ba397..363cfbe97afad 100644 --- a/src/dev/build/lib/runner.js +++ b/src/dev/build/lib/runner.js @@ -24,7 +24,7 @@ import { isErrorLogged, markErrorLogged } from './errors'; import { createBuild } from './build'; export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { - async function execTask(desc, fn, ...args) { + async function execTask(desc, task, ...args) { log.info(desc); log.indent(4); @@ -37,7 +37,7 @@ export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { }; try { - await fn(config, log, ...args); + await task.run(config, log, ...args); log.success(chalk.green('✓'), time()); } catch (error) { if (!isErrorLogged(error)) { @@ -82,10 +82,10 @@ export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { */ return async function run(task) { if (task.global) { - await execTask(chalk`{dim [ global ]} ${task.description}`, task.run, builds); + await execTask(chalk`{dim [ global ]} ${task.description}`, task, builds); } else { for (const build of builds) { - await execTask(`${build.getLogTag()} ${task.description}`, task.run, build); + await execTask(`${build.getLogTag()} ${task.description}`, task, build); } } }; diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.js b/src/dev/build/tasks/nodejs/extract_node_builds_task.js index ce8719911e4ca..f8e767ac79a4b 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.js +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.js @@ -19,7 +19,7 @@ import { dirname, resolve } from 'path'; import fs from 'fs'; -import { promisify } from 'bluebird'; +import { promisify } from 'util'; import mkdirp from 'mkdirp'; import { untar } from '../../lib'; @@ -29,7 +29,7 @@ const statAsync = promisify(fs.stat); const mkdirpAsync = promisify(mkdirp); const copyFileAsync = promisify(fs.copyFile); -const ExtractNodeBuildsTask = { +export const ExtractNodeBuildsTask = { global: true, description: 'Extracting node.js builds for all platforms', async run(config) { @@ -55,7 +55,3 @@ const ExtractNodeBuildsTask = { return await copyFileAsync(source, destination, fs.constants.COPYFILE_FICLONE); }, }; - -ExtractNodeBuildsTask.run = ExtractNodeBuildsTask.run.bind(ExtractNodeBuildsTask); - -export { ExtractNodeBuildsTask }; diff --git a/src/dev/eslint/pick_files_to_lint.js b/src/dev/eslint/pick_files_to_lint.js index 2d3ec9a58e70f..e3212c00d9e0d 100644 --- a/src/dev/eslint/pick_files_to_lint.js +++ b/src/dev/eslint/pick_files_to_lint.js @@ -30,7 +30,7 @@ export function pickFilesToLint(log, files) { const cli = new CLIEngine(); return files.filter(file => { - if (!file.isJs()) { + if (!file.isJs() && !file.isTypescript()) { return; } diff --git a/src/dev/license_checker/config.js b/src/dev/license_checker/config.js index 04b0ceedb4168..6c72c918279d7 100644 --- a/src/dev/license_checker/config.js +++ b/src/dev/license_checker/config.js @@ -52,6 +52,7 @@ export const LICENSE_WHITELIST = [ 'CC-BY-3.0', 'CC-BY-4.0', 'Eclipse Distribution License - v 1.0', + 'FreeBSD', 'ISC', 'ISC*', 'MIT OR GPL-2.0', diff --git a/src/dev/precommit_hook/check_file_casing.js b/src/dev/precommit_hook/check_file_casing.js index 943e2428a0922..d4781bb78ecf3 100644 --- a/src/dev/precommit_hook/check_file_casing.js +++ b/src/dev/precommit_hook/check_file_casing.js @@ -119,14 +119,14 @@ async function checkForSnakeCase(log, files) { const ignored = matchesAnyGlob(path, IGNORE_FILE_GLOBS); if (ignored) { - log.debug('%j ignored', file); + log.debug('[casing] %j ignored', file); return; } const pathToValidate = getPathWithoutIgnoredParents(file); const invalid = NON_SNAKE_CASE_RE.test(pathToValidate); if (!invalid) { - log.debug('%j uses valid casing', file); + log.debug('[casing] %j uses valid casing', file); } else { const ignoredParent = file.getRelativePath().slice(0, -pathToValidate.length); errorPaths.push(`${dim(ignoredParent)}${pathToValidate}`); diff --git a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts index ac1399addb70c..0f0d5e1591b17 100644 --- a/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts +++ b/src/legacy/core_plugins/data/public/query_bar/components/query_bar.test.mocks.ts @@ -17,6 +17,9 @@ * under the License. */ +import { createKfetch } from 'ui/kfetch/kfetch'; +import { setup } from '../../../../../../test_utils/public/kfetch_test_setup'; + const mockChromeFactory = jest.fn(() => { return { getBasePath: () => `foo`, @@ -47,6 +50,7 @@ export const mockPersistedLogFactory = jest.fn Promise.resolve([])); const mockAutocompleteProvider = jest.fn(() => mockGetAutocompleteSuggestions); export const mockGetAutocompleteProvider = jest.fn(() => mockAutocompleteProvider); +const mockKfetch = jest.fn(() => createKfetch(setup().http)); jest.mock('ui/chrome', () => mockChromeFactory()); jest.mock('ui/kfetch', () => ({ @@ -63,6 +67,10 @@ jest.mock('ui/metadata', () => ({ jest.mock('ui/autocomplete_providers', () => ({ getAutocompleteProvider: mockGetAutocompleteProvider, })); +jest.mock('ui/kfetch', () => ({ + __newPlatformSetup__: jest.fn(), + kfetch: mockKfetch, +})); import _ from 'lodash'; // Using doMock to avoid hoisting so that I can override only the debounce method in lodash diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 61e6051634863..d58ebec0be334 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -291,6 +291,7 @@ export default function (kibana) { kibana: { settings: true, index_patterns: true, + objects: true, }, } }; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js index 94ffff0c5c6c4..dc135fb19587a 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js @@ -87,7 +87,8 @@ function destroyObjectsTable() { uiRoutes .when('/management/kibana/objects', { template: objectIndexHTML, - k7Breadcrumbs: getIndexBreadcrumbs + k7Breadcrumbs: getIndexBreadcrumbs, + requireUICapability: 'management.kibana.objects', }) .when('/management/kibana/objects/:service', { redirectTo: '/management/kibana/objects' diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js index 085edf4b17956..f3d2010fd0c37 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js @@ -39,7 +39,8 @@ const location = 'SavedObject view'; uiRoutes .when('/management/kibana/objects/:service/:id', { template: objectViewHTML, - k7Breadcrumbs: getViewBreadcrumbs + k7Breadcrumbs: getViewBreadcrumbs, + requireUICapability: 'management.kibana.objects', }); uiModules.get('apps/management', ['monospaced.elastic']) @@ -89,7 +90,7 @@ uiModules.get('apps/management', ['monospaced.elastic']) field.type = 'boolean'; field.value = field.value; } else if (_.isPlainObject(field.value)) { - // do something recursive + // do something recursive return _.reduce(field.value, _.partialRight(createField, parents), memo); } diff --git a/src/legacy/server/http/index.js b/src/legacy/server/http/index.js index 5feab9c89493f..0b9c660742209 100644 --- a/src/legacy/server/http/index.js +++ b/src/legacy/server/http/index.js @@ -31,7 +31,7 @@ export default async function (kbnServer, server, config) { kbnServer.server = new Hapi.Server(kbnServer.newPlatform.params.serverOptions); server = kbnServer.server; - setupBasePathProvider(server, config); + setupBasePathProvider(kbnServer); await registerHapiPlugins(server); diff --git a/src/legacy/server/http/setup_base_path_provider.js b/src/legacy/server/http/setup_base_path_provider.js index caba48c765b02..8cf6cc1fde512 100644 --- a/src/legacy/server/http/setup_base_path_provider.js +++ b/src/legacy/server/http/setup_base_path_provider.js @@ -17,22 +17,14 @@ * under the License. */ -export function setupBasePathProvider(server, config) { - - server.decorate('request', 'setBasePath', function (basePath) { +export function setupBasePathProvider(kbnServer) { + kbnServer.server.decorate('request', 'setBasePath', function (basePath) { const request = this; - if (request.app._basePath) { - throw new Error(`Request basePath was previously set. Setting multiple times is not supported.`); - } - request.app._basePath = basePath; + kbnServer.newPlatform.setup.core.http.setBasePathFor(request, basePath); }); - server.decorate('request', 'getBasePath', function () { + kbnServer.server.decorate('request', 'getBasePath', function () { const request = this; - - const serverBasePath = config.get('server.basePath'); - const requestBasePath = request.app._basePath || ''; - - return `${serverBasePath}${requestBasePath}`; + return kbnServer.newPlatform.setup.core.http.getBasePathFor(request); }); } diff --git a/src/legacy/ui/public/chrome/api/sub_url_hooks.js b/src/legacy/ui/public/chrome/api/sub_url_hooks.js index 5ab17e296cf90..856240d70d103 100644 --- a/src/legacy/ui/public/chrome/api/sub_url_hooks.js +++ b/src/legacy/ui/public/chrome/api/sub_url_hooks.js @@ -17,6 +17,8 @@ * under the License. */ +import url from 'url'; + import { getUnhashableStatesProvider, unhashUrl, @@ -24,7 +26,7 @@ import { import { onStart } from '../../new_platform'; export function registerSubUrlHooks(angularModule, internals) { - angularModule.run(($rootScope, Private) => { + angularModule.run(($rootScope, Private, $location) => { const getUnhashableStates = Private(getUnhashableStatesProvider); const subUrlRouteFilter = Private(SubUrlRouteFilterProvider); @@ -40,6 +42,23 @@ export function registerSubUrlHooks(angularModule, internals) { } } + $rootScope.$on('$locationChangeStart', (e, newUrl) => { + // This handler fixes issue #31238 where browser back navigation + // fails due to angular 1.6 parsing url encoded params wrong. + const parsedAbsUrl = url.parse($location.absUrl()); + const absUrlHash = parsedAbsUrl.hash ? parsedAbsUrl.hash.slice(1) : ''; + const decodedAbsUrlHash = decodeURIComponent(absUrlHash); + + const parsedNewUrl = url.parse(newUrl); + const newHash = parsedNewUrl.hash ? parsedNewUrl.hash.slice(1) : ''; + const decodedHash = decodeURIComponent(newHash); + + if (absUrlHash !== newHash && decodedHash === decodedAbsUrlHash) { + // replace the urlencoded hash with the version that angular sees. + $location.url(absUrlHash).replace(); + } + }); + $rootScope.$on('$routeChangeSuccess', onRouteChange); $rootScope.$on('$routeUpdate', onRouteChange); updateSubUrls(); // initialize sub urls diff --git a/src/legacy/ui/public/error_auto_create_index/error_auto_create_index.test.js b/src/legacy/ui/public/error_auto_create_index/error_auto_create_index.test.js index 291eff2454c29..6fcbacede2a65 100644 --- a/src/legacy/ui/public/error_auto_create_index/error_auto_create_index.test.js +++ b/src/legacy/ui/public/error_auto_create_index/error_auto_create_index.test.js @@ -17,20 +17,18 @@ * under the License. */ -jest.mock('../chrome', () => ({ - addBasePath: path => `myBase/${path}`, -})); -jest.mock('../metadata', () => ({ - metadata: { - version: 'my-version', - }, -})); - +// @ts-ignore import fetchMock from 'fetch-mock/es5/client'; -import { kfetch } from 'ui/kfetch'; +import { __newPlatformSetup__, kfetch } from '../kfetch'; +import { setup } from '../../../../test_utils/public/kfetch_test_setup'; + import { isAutoCreateIndexError } from './error_auto_create_index'; describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch', () => { + beforeAll(() => { + __newPlatformSetup__(setup().http); + }); + describe('404', () => { beforeEach(() => { fetchMock.post({ @@ -45,7 +43,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch' test('should return false', async () => { expect.assertions(1); try { - await kfetch({ method: 'POST', pathname: 'my/path' }); + await kfetch({ method: 'POST', pathname: '/my/path' }); } catch (kfetchError) { expect(isAutoCreateIndexError(kfetchError)).toBe(false); } @@ -66,7 +64,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch' test('should return false', async () => { expect.assertions(1); try { - await kfetch({ method: 'POST', pathname: 'my/path' }); + await kfetch({ method: 'POST', pathname: '/my/path' }); } catch (kfetchError) { expect(isAutoCreateIndexError(kfetchError)).toBe(false); } @@ -90,7 +88,7 @@ describe('isAutoCreateIndexError correctly handles KFetchError thrown by kfetch' test('should return true', async () => { expect.assertions(1); try { - await kfetch({ method: 'POST', pathname: 'my/path' }); + await kfetch({ method: 'POST', pathname: '/my/path' }); } catch (kfetchError) { expect(isAutoCreateIndexError(kfetchError)).toBe(true); } diff --git a/src/legacy/ui/public/kfetch/_import_objects.ndjson b/src/legacy/ui/public/kfetch/_import_objects.ndjson new file mode 100644 index 0000000000000..3511fb44cdfb2 --- /dev/null +++ b/src/legacy/ui/public/kfetch/_import_objects.ndjson @@ -0,0 +1 @@ +{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Log Agents","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"082f1d60-a2e7-11e7-bb30-233be9be6a15","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1} diff --git a/src/legacy/ui/public/kfetch/index.ts b/src/legacy/ui/public/kfetch/index.ts index 234304b5950aa..011321c83c4cd 100644 --- a/src/legacy/ui/public/kfetch/index.ts +++ b/src/legacy/ui/public/kfetch/index.ts @@ -17,5 +17,23 @@ * under the License. */ -export { kfetch, addInterceptor, KFetchOptions, KFetchQuery } from './kfetch'; -export { kfetchAbortable } from './kfetch_abortable'; +import { createKfetch, KFetchKibanaOptions, KFetchOptions } from './kfetch'; +export { addInterceptor, KFetchOptions, KFetchQuery } from './kfetch'; + +import { HttpSetup } from '../../../../core/public'; + +let http: HttpSetup; +let kfetchInstance: (options: KFetchOptions, kfetchOptions?: KFetchKibanaOptions) => any; + +export function __newPlatformSetup__(httpSetup: HttpSetup) { + if (http) { + throw new Error('ui/kfetch already initialized with New Platform APIs'); + } + + http = httpSetup; + kfetchInstance = createKfetch(http); +} + +export const kfetch = (options: KFetchOptions, kfetchOptions?: KFetchKibanaOptions) => { + return kfetchInstance(options, kfetchOptions); +}; diff --git a/src/legacy/ui/public/kfetch/kfetch.test.ts b/src/legacy/ui/public/kfetch/kfetch.test.ts index 8f8cc807911a3..79bca9da1d273 100644 --- a/src/legacy/ui/public/kfetch/kfetch.test.ts +++ b/src/legacy/ui/public/kfetch/kfetch.test.ts @@ -17,28 +17,20 @@ * under the License. */ -jest.mock('../chrome', () => ({ - addBasePath: (path: string) => `http://localhost/myBase/${path}`, -})); - -jest.mock('../metadata', () => ({ - metadata: { - version: 'my-version', - }, -})); - // @ts-ignore import fetchMock from 'fetch-mock/es5/client'; -import { - addInterceptor, - Interceptor, - kfetch, - resetInterceptors, - withDefaultOptions, -} from './kfetch'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { __newPlatformSetup__, addInterceptor, kfetch, KFetchOptions } from '.'; +import { Interceptor, resetInterceptors, withDefaultOptions } from './kfetch'; import { KFetchError } from './kfetch_error'; +import { setup } from '../../../../test_utils/public/kfetch_test_setup'; describe('kfetch', () => { + beforeAll(() => { + __newPlatformSetup__(setup().http); + }); + afterEach(() => { fetchMock.restore(); resetInterceptors(); @@ -46,13 +38,13 @@ describe('kfetch', () => { it('should use supplied request method', async () => { fetchMock.post('*', {}); - await kfetch({ pathname: 'my/path', method: 'POST' }); + await kfetch({ pathname: '/my/path', method: 'POST' }); expect(fetchMock.lastOptions()!.method).toBe('POST'); }); it('should use supplied Content-Type', async () => { fetchMock.get('*', {}); - await kfetch({ pathname: 'my/path', headers: { 'Content-Type': 'CustomContentType' } }); + await kfetch({ pathname: '/my/path', headers: { 'Content-Type': 'CustomContentType' } }); expect(fetchMock.lastOptions()!.headers).toMatchObject({ 'Content-Type': 'CustomContentType', }); @@ -60,64 +52,88 @@ describe('kfetch', () => { it('should use supplied pathname and querystring', async () => { fetchMock.get('*', {}); - await kfetch({ pathname: 'my/path', query: { a: 'b' } }); + await kfetch({ pathname: '/my/path', query: { a: 'b' } }); expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path?a=b'); }); it('should use supplied headers', async () => { fetchMock.get('*', {}); await kfetch({ - pathname: 'my/path', + pathname: '/my/path', headers: { myHeader: 'foo' }, }); expect(fetchMock.lastOptions()!.headers).toEqual({ 'Content-Type': 'application/json', - 'kbn-version': 'my-version', + 'kbn-version': 'kibanaVersion', myHeader: 'foo', }); }); it('should return response', async () => { fetchMock.get('*', { foo: 'bar' }); - const res = await kfetch({ pathname: 'my/path' }); + const res = await kfetch({ pathname: '/my/path' }); expect(res).toEqual({ foo: 'bar' }); }); it('should prepend url with basepath by default', async () => { fetchMock.get('*', {}); - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); expect(fetchMock.lastUrl()).toBe('http://localhost/myBase/my/path'); }); it('should not prepend url with basepath when disabled', async () => { fetchMock.get('*', {}); - await kfetch({ pathname: 'my/path' }, { prependBasePath: false }); + await kfetch({ pathname: '/my/path' }, { prependBasePath: false }); expect(fetchMock.lastUrl()).toBe('/my/path'); }); it('should make request with defaults', async () => { fetchMock.get('*', {}); - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); - expect(fetchMock.lastOptions()!).toEqual({ + expect(fetchMock.lastOptions()!).toMatchObject({ method: 'GET', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', - 'kbn-version': 'my-version', + 'kbn-version': 'kibanaVersion', }, }); }); + it('should make requests for NDJSON content', async () => { + const content = readFileSync(join(__dirname, '_import_objects.ndjson'), { encoding: 'utf-8' }); + + fetchMock.post('*', { + body: content, + headers: { 'Content-Type': 'application/ndjson' }, + }); + + const data = await kfetch({ + method: 'POST', + pathname: '/my/path', + body: content, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + expect(data).toBeInstanceOf(Blob); + + const ndjson = await new Response(data).text(); + + expect(ndjson).toEqual(content); + }); + it('should reject on network error', async () => { expect.assertions(1); - fetchMock.get('*', { throws: new Error('Network issue') }); + fetchMock.get('*', { status: 500 }); try { - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); } catch (e) { - expect(e.message).toBe('Network issue'); + expect(e.message).toBe('Internal Server Error'); } }); @@ -126,7 +142,7 @@ describe('kfetch', () => { beforeEach(async () => { fetchMock.get('*', { status: 404, body: { foo: 'bar' } }); try { - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); } catch (e) { error = e; } @@ -154,7 +170,7 @@ describe('kfetch', () => { fetchMock.get('*', { foo: 'bar' }); interceptorCalls = mockInterceptorCalls([{}, {}, {}]); - resp = await kfetch({ pathname: 'my/path' }); + resp = await kfetch({ pathname: '/my/path' }); }); it('should call interceptors in correct order', () => { @@ -185,12 +201,12 @@ describe('kfetch', () => { fetchMock.get('*', { foo: 'bar' }); interceptorCalls = mockInterceptorCalls([ - { requestError: () => ({}) }, + { requestError: () => ({ pathname: '/my/path' } as KFetchOptions) }, { request: () => Promise.reject(new Error('Error in request')) }, {}, ]); - resp = await kfetch({ pathname: 'my/path' }); + resp = await kfetch({ pathname: '/my/path' }); }); it('should call interceptors in correct order', () => { @@ -227,7 +243,7 @@ describe('kfetch', () => { ]); try { - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); } catch (e) { error = e; } @@ -267,7 +283,7 @@ describe('kfetch', () => { ]); try { - await kfetch({ pathname: 'my/path' }); + await kfetch({ pathname: '/my/path' }); } catch (e) { error = e; } @@ -313,7 +329,7 @@ describe('kfetch', () => { {}, ]); - resp = await kfetch({ pathname: 'my/path' }); + resp = await kfetch({ pathname: '/my/path' }); }); it('should call in correct order', () => { @@ -351,7 +367,7 @@ describe('kfetch', () => { }), }); - resp = await kfetch({ pathname: 'my/path' }); + resp = await kfetch({ pathname: '/my/path' }); }); it('should modify request', () => { @@ -386,7 +402,7 @@ describe('kfetch', () => { }), }); - resp = await kfetch({ pathname: 'my/path' }); + resp = await kfetch({ pathname: '/my/path' }); }); it('should modify request', () => { @@ -453,6 +469,7 @@ function mockInterceptorCalls(interceptors: Interceptor[]) { describe('withDefaultOptions', () => { it('should remove undefined query params', () => { const { query } = withDefaultOptions({ + pathname: '/withDefaultOptions', query: { foo: 'bar', param1: (undefined as any) as string, @@ -464,9 +481,10 @@ describe('withDefaultOptions', () => { }); it('should add default options', () => { - expect(withDefaultOptions({})).toEqual({ + expect(withDefaultOptions({ pathname: '/addDefaultOptions' })).toEqual({ + pathname: '/addDefaultOptions', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', 'kbn-version': 'my-version' }, + headers: { 'Content-Type': 'application/json' }, method: 'GET', }); }); diff --git a/src/legacy/ui/public/kfetch/kfetch.ts b/src/legacy/ui/public/kfetch/kfetch.ts index 93d736bd8666e..cb96e03eb1328 100644 --- a/src/legacy/ui/public/kfetch/kfetch.ts +++ b/src/legacy/ui/public/kfetch/kfetch.ts @@ -19,17 +19,18 @@ import { merge } from 'lodash'; // @ts-ignore not really worth typing -import { metadata } from 'ui/metadata'; -import url from 'url'; -import chrome from '../chrome'; import { KFetchError } from './kfetch_error'; +import { HttpSetup } from '../../../../core/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { HttpRequestInit } from '../../../../core/public/http/types'; + export interface KFetchQuery { [key: string]: string | number | boolean | undefined; } -export interface KFetchOptions extends RequestInit { - pathname?: string; +export interface KFetchOptions extends HttpRequestInit { + pathname: string; query?: KFetchQuery; } @@ -48,32 +49,21 @@ const interceptors: Interceptor[] = []; export const resetInterceptors = () => (interceptors.length = 0); export const addInterceptor = (interceptor: Interceptor) => interceptors.push(interceptor); -export async function kfetch( - options: KFetchOptions, - { prependBasePath = true }: KFetchKibanaOptions = {} -) { - const combinedOptions = withDefaultOptions(options); - const promise = requestInterceptors(combinedOptions).then( - ({ pathname, query, ...restOptions }) => { - const fullUrl = url.format({ - pathname: prependBasePath ? chrome.addBasePath(pathname) : pathname, - query, - }); - - return window.fetch(fullUrl, restOptions).then(async res => { - if (!res.ok) { - throw new KFetchError(res, await getBodyAsJson(res)); - } - const contentType = res.headers.get('content-type'); - if (contentType && contentType.split(';')[0] === 'application/ndjson') { - return await getBodyAsBlob(res); - } - return await getBodyAsJson(res); - }); - } - ); - - return responseInterceptors(promise); +export function createKfetch(http: HttpSetup) { + return function kfetch( + options: KFetchOptions, + { prependBasePath = true }: KFetchKibanaOptions = {} + ) { + return responseInterceptors( + requestInterceptors(withDefaultOptions(options)) + .then(({ pathname, ...restOptions }) => + http.fetch(pathname, { ...restOptions, prependBasePath }) + ) + .catch(err => { + throw new KFetchError(err.response || { statusText: err.message }, err.body); + }) + ); + }; } // Request/response interceptors are called in opposite orders. @@ -91,36 +81,29 @@ function responseInterceptors(responsePromise: Promise) { }, responsePromise); } -async function getBodyAsJson(res: Response) { - try { - return await res.json(); - } catch (e) { - return null; - } -} - -async function getBodyAsBlob(res: Response) { - try { - return await res.blob(); - } catch (e) { - return null; - } -} - export function withDefaultOptions(options?: KFetchOptions): KFetchOptions { - return merge( + const withDefaults = merge( { method: 'GET', credentials: 'same-origin', headers: { - ...(options && options.headers && options.headers.hasOwnProperty('Content-Type') - ? {} - : { - 'Content-Type': 'application/json', - }), - 'kbn-version': metadata.version, + 'Content-Type': 'application/json', }, }, options - ); + ) as KFetchOptions; + + if ( + options && + options.headers && + 'Content-Type' in options.headers && + options.headers['Content-Type'] === undefined + ) { + // TS thinks headers could be undefined here, but that isn't possible because + // of the merge above. + // @ts-ignore + withDefaults.headers['Content-Type'] = undefined; + } + + return withDefaults; } diff --git a/src/legacy/ui/public/kfetch/kfetch_abortable.test.ts b/src/legacy/ui/public/kfetch/kfetch_abortable.test.ts deleted file mode 100644 index bb1c5ac072524..0000000000000 --- a/src/legacy/ui/public/kfetch/kfetch_abortable.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('../chrome', () => ({ - addBasePath: (path: string) => `http://localhost/myBase/${path}`, -})); - -jest.mock('../metadata', () => ({ - metadata: { - version: 'my-version', - }, -})); - -import { kfetchAbortable } from './kfetch_abortable'; - -describe('kfetchAbortable', () => { - it('should return an object with a fetching promise and an abort callback', () => { - const { fetching, abort } = kfetchAbortable({ pathname: 'my/path' }); - expect(typeof fetching.then).toBe('function'); - expect(typeof fetching.catch).toBe('function'); - expect(typeof abort).toBe('function'); - }); -}); diff --git a/src/test_utils/public/kfetch_test_setup.ts b/src/test_utils/public/kfetch_test_setup.ts new file mode 100644 index 0000000000000..a102ceb89faf2 --- /dev/null +++ b/src/test_utils/public/kfetch_test_setup.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import { HttpService } from '../../core/public/http'; +import { BasePathService } from '../../core/public/base_path'; +import { fatalErrorsServiceMock } from '../../core/public/fatal_errors/fatal_errors_service.mock'; +import { injectedMetadataServiceMock } from '../../core/public/injected_metadata/injected_metadata_service.mock'; +/* eslint-enable @kbn/eslint/no-restricted-paths */ + +export function setup() { + const httpService = new HttpService(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + + injectedMetadata.getBasePath.mockReturnValue('http://localhost/myBase'); + + const basePath = new BasePathService().setup({ injectedMetadata }); + const http = httpService.setup({ basePath, fatalErrors, injectedMetadata }); + + return { httpService, fatalErrors, http }; +} diff --git a/x-pack/package.json b/x-pack/package.json index 773e642dd4ed1..6012327c9a776 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -178,6 +178,7 @@ "@kbn/i18n": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/ui-framework": "1.0.0", + "@mapbox/mapbox-gl-draw": "^1.1.1", "@samverschueren/stream-to-observable": "^0.3.0", "@scant/router": "^0.1.0", "@slack/client": "^4.8.0", @@ -255,6 +256,7 @@ "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", "mapbox-gl": "0.52.0", + "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^8.4.1", "mime": "^2.2.2", "mkdirp": "0.5.1", diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 8e78fe94763b9..219b9803a2d9f 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -12,6 +12,20 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Error METRIC_JAVA_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Error METRIC_JAVA_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Error METRIC_JAVA_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Error METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Error METRIC_JAVA_NON_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Error METRIC_JAVA_NON_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Error METRIC_JAVA_THREAD_COUNT 1`] = `undefined`; + exports[`Error METRIC_PROCESS_CPU_PERCENT 1`] = `undefined`; exports[`Error METRIC_SYSTEM_CPU_PERCENT 1`] = `undefined`; @@ -74,6 +88,20 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Span METRIC_JAVA_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Span METRIC_JAVA_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Span METRIC_JAVA_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Span METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Span METRIC_JAVA_NON_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Span METRIC_JAVA_NON_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Span METRIC_JAVA_THREAD_COUNT 1`] = `undefined`; + exports[`Span METRIC_PROCESS_CPU_PERCENT 1`] = `undefined`; exports[`Span METRIC_SYSTEM_CPU_PERCENT 1`] = `undefined`; @@ -136,6 +164,20 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; +exports[`Transaction METRIC_JAVA_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_NON_HEAP_MEMORY_MAX 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_NON_HEAP_MEMORY_USED 1`] = `undefined`; + +exports[`Transaction METRIC_JAVA_THREAD_COUNT 1`] = `undefined`; + exports[`Transaction METRIC_PROCESS_CPU_PERCENT 1`] = `undefined`; exports[`Transaction METRIC_SYSTEM_CPU_PERCENT 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 6644df2e163a1..ebccac0d704f7 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -43,3 +43,12 @@ export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total'; export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct'; export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct'; + +export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max'; +export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed'; +export const METRIC_JAVA_HEAP_MEMORY_USED = 'jvm.memory.heap.used'; +export const METRIC_JAVA_NON_HEAP_MEMORY_MAX = 'jvm.memory.non_heap.max'; +export const METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED = + 'jvm.memory.non_heap.committed'; +export const METRIC_JAVA_NON_HEAP_MEMORY_USED = 'jvm.memory.non_heap.used'; +export const METRIC_JAVA_THREAD_COUNT = 'jvm.thread.count'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/CPUUsageChart.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/CPUUsageChart.tsx deleted file mode 100644 index e4e130bce570a..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/CPUUsageChart.tsx +++ /dev/null @@ -1,48 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Coordinate } from '../../../../typings/timeseries'; -import { CPUMetricSeries } from '../../../selectors/chartSelectors'; -import { asPercent } from '../../../utils/formatters'; -// @ts-ignore -import CustomPlot from '../../shared/charts/CustomPlot'; -import { HoverXHandlers } from '../../shared/charts/SyncChartGroup'; - -interface Props { - data: CPUMetricSeries; - hoverXHandlers: HoverXHandlers; -} - -const tickFormatY = (y: number | null) => `${(y || 0) * 100}%`; -const formatTooltipValue = (c: Coordinate) => asPercent(c.y || 0, 1); - -export function CPUUsageChart({ data, hoverXHandlers }: Props) { - return ( - - - - {i18n.translate( - 'xpack.apm.serviceDetails.metrics.cpuUsageChartTitle', - { - defaultMessage: 'CPU usage' - } - )} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/MemoryUsageChart.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/MemoryUsageChart.tsx deleted file mode 100644 index f452cadef20d8..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/MemoryUsageChart.tsx +++ /dev/null @@ -1,48 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Coordinate } from '../../../../typings/timeseries'; -import { MemoryMetricSeries } from '../../../selectors/chartSelectors'; -import { asPercent } from '../../../utils/formatters'; -// @ts-ignore -import CustomPlot from '../../shared/charts/CustomPlot'; -import { HoverXHandlers } from '../../shared/charts/SyncChartGroup'; - -interface Props { - data: MemoryMetricSeries; - hoverXHandlers: HoverXHandlers; -} - -const tickFormatY = (y: number | null) => `${(y || 0) * 100}%`; -const formatTooltipValue = (c: Coordinate) => asPercent(c.y || 0, 1); - -export function MemoryUsageChart({ data, hoverXHandlers }: Props) { - return ( - - - - {i18n.translate( - 'xpack.apm.serviceDetails.metrics.memoryUsageChartTitle', - { - defaultMessage: 'Memory usage' - } - )} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/MetricsChart.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/MetricsChart.tsx new file mode 100644 index 0000000000000..363cfa0ccf65c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/MetricsChart.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { GenericMetricsChart } from '../../../../server/lib/metrics/transform_metrics_chart'; +// @ts-ignore +import CustomPlot from '../../shared/charts/CustomPlot'; +import { HoverXHandlers } from '../../shared/charts/SyncChartGroup'; +import { + asDynamicBytes, + asPercent, + getFixedByteFormatter, + asDecimal +} from '../../../utils/formatters'; +import { Coordinate } from '../../../../typings/timeseries'; + +interface Props { + chart: GenericMetricsChart; + hoverXHandlers: HoverXHandlers; +} + +export function MetricsChart({ chart, hoverXHandlers }: Props) { + const formatYValue = getYTickFormatter(chart); + const formatTooltip = getTooltipFormatter(chart); + + const transformedSeries = chart.series.map(series => ({ + ...series, + legendValue: formatYValue(series.overallValue) + })); + + return ( + + + {chart.title} + + + + ); +} + +function getYTickFormatter(chart: GenericMetricsChart) { + switch (chart.yUnit) { + case 'bytes': { + const max = Math.max( + ...chart.series.flatMap(series => + series.data.map(coord => coord.y || 0) + ) + ); + return getFixedByteFormatter(max); + } + case 'percent': { + return (y: number | null) => asPercent(y || 0, 1); + } + default: { + return (y: number | null) => (y === null ? y : asDecimal(y)); + } + } +} + +function getTooltipFormatter({ yUnit }: GenericMetricsChart) { + switch (yUnit) { + case 'bytes': { + return (c: Coordinate) => asDynamicBytes(c.y); + } + case 'percent': { + return (c: Coordinate) => asPercent(c.y || 0, 1); + } + default: { + return (c: Coordinate) => (c.y === null ? c.y : asDecimal(c.y)); + } + } +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx index d3c1e59bce745..45775d4e51cac 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx @@ -17,12 +17,14 @@ interface Props { transactionTypes: string[]; urlParams: IUrlParams; isRumAgent?: boolean; + agentName: string; } export function ServiceDetailTabs({ transactionTypes, urlParams, - isRumAgent + isRumAgent, + agentName }: Props) { const location = useLocation(); const { serviceName } = urlParams; @@ -56,7 +58,7 @@ export function ServiceDetailTabs({ defaultMessage: 'Metrics' }), path: `/${serviceName}/metrics`, - render: () => + render: () => }; const tabs = isRumAgent ? [transactionsTab, errorsTab] diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceMetrics.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceMetrics.tsx index 4d892be9b5d9d..7194de37dc352 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceMetrics.tsx @@ -4,101 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiSpacer -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; +import { EuiFlexGrid, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; -import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts'; -import { loadErrorDistribution } from '../../../services/rest/apm/error_groups'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { SyncChartGroup } from '../../shared/charts/SyncChartGroup'; -import { TransactionCharts } from '../../shared/charts/TransactionCharts'; -import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; -import { CPUUsageChart } from './CPUUsageChart'; -import { MemoryUsageChart } from './MemoryUsageChart'; +import { MetricsChart } from './MetricsChart'; interface ServiceMetricsProps { urlParams: IUrlParams; - location: Location; + agentName: string; } -export function ServiceMetrics({ urlParams, location }: ServiceMetricsProps) { - const { serviceName, start, end, kuery } = urlParams; - const { data: errorDistributionData } = useFetcher( - () => { - if (serviceName && start && end) { - return loadErrorDistribution({ serviceName, start, end, kuery }); - } - }, - [serviceName, start, end, kuery] - ); - - const { data: transactionOverviewChartsData } = useTransactionOverviewCharts( - urlParams - ); - - const { data: serviceMetricChartData } = useServiceMetricCharts(urlParams); - - if (!errorDistributionData) { - return null; - } - +export function ServiceMetrics({ urlParams, agentName }: ServiceMetricsProps) { + const { data } = useServiceMetricCharts(urlParams, agentName); return ( - - - - - - - - - - - - - - ( - - - - - - - - - - + {data.charts.map(chart => ( + + + + + + ))} )} /> diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx index 7f93c8078bbd0..5ca0cae04d1ed 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -30,7 +30,7 @@ export function ServiceDetails() { return null; } - const isRumAgent = isRumAgentName(serviceDetailsData.agentName || ''); + const isRumAgent = isRumAgentName(serviceDetailsData.agentName); return ( @@ -56,6 +56,7 @@ export function ServiceDetails() { urlParams={urlParams} transactionTypes={serviceDetailsData.types} isRumAgent={isRumAgent} + agentName={serviceDetailsData.agentName} /> ); diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js index 28773913c03e2..260b53abca76b 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js @@ -174,7 +174,7 @@ export class Typeahead extends Component { 'Search transactions and errors… (E.g. {queryExample})', values: { queryExample: - 'transaction.duration.us > 300000 AND context.response.status_code >= 400' + 'transaction.duration.us > 300000 AND http.response.status_code >= 400' } } )} diff --git a/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts index 2b38a404ebe10..dbf6052fa38bd 100644 --- a/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts +++ b/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts @@ -4,53 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useMemo } from 'react'; -import { MetricsChartAPIResponse } from '../../server/lib/metrics/get_all_metrics_chart_data'; -import { MemoryChartAPIResponse } from '../../server/lib/metrics/get_memory_chart_data'; -import { loadMetricsChartDataForService } from '../services/rest/apm/metrics'; -import { getCPUSeries, getMemorySeries } from '../selectors/chartSelectors'; +import { MetricsChartsByAgentAPIResponse } from '../../server/lib/metrics/get_metrics_chart_data_by_agent'; +import { loadMetricsChartData } from '../services/rest/apm/metrics'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; -const memory: MemoryChartAPIResponse = { - series: { - memoryUsedAvg: [], - memoryUsedMax: [] - }, - overallValues: { - memoryUsedAvg: null, - memoryUsedMax: null - }, - totalHits: 0 +const INITIAL_DATA: MetricsChartsByAgentAPIResponse = { + charts: [] }; -const INITIAL_DATA: MetricsChartAPIResponse = { - memory, - cpu: { - series: { - systemCPUAverage: [], - systemCPUMax: [], - processCPUAverage: [], - processCPUMax: [] - }, - overallValues: { - systemCPUAverage: null, - systemCPUMax: null, - processCPUAverage: null, - processCPUMax: null - }, - totalHits: 0 - } -}; - -export function useServiceMetricCharts(urlParams: IUrlParams) { +export function useServiceMetricCharts( + urlParams: IUrlParams, + agentName: string +) { const { serviceName, start, end, kuery } = urlParams; - const { data = INITIAL_DATA, error, status } = useFetcher( + const { data = INITIAL_DATA, error, status } = useFetcher< + MetricsChartsByAgentAPIResponse + >( () => { if (serviceName && start && end) { - return loadMetricsChartDataForService({ + return loadMetricsChartData({ serviceName, + agentName, start, end, kuery @@ -60,16 +36,8 @@ export function useServiceMetricCharts(urlParams: IUrlParams) { [serviceName, start, end, kuery] ); - const memoizedData = useMemo( - () => ({ - memory: getMemorySeries(urlParams, data.memory), - cpu: getCPUSeries(data.cpu) - }), - [data] - ); - return { - data: memoizedData, + data, status, error }; diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index 6f9ff73dbaae6..b69fe68522aad 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -10,12 +10,11 @@ import d3 from 'd3'; import { difference, memoize, zipObject } from 'lodash'; import mean from 'lodash.mean'; import { rgba } from 'polished'; -import { MetricsChartAPIResponse } from '../../server/lib/metrics/get_all_metrics_chart_data'; import { TimeSeriesAPIResponse } from '../../server/lib/transactions/charts'; import { ApmTimeSeriesResponse } from '../../server/lib/transactions/charts/get_timeseries_data/transform'; import { StringMap } from '../../typings/common'; import { Coordinate, RectCoordinate } from '../../typings/timeseries'; -import { asDecimal, asMillis, asPercent, tpmUnit } from '../utils/formatters'; +import { asDecimal, asMillis, tpmUnit } from '../utils/formatters'; import { IUrlParams } from '../context/UrlParamsContext/types'; export const getEmptySerie = memoize( @@ -92,96 +91,6 @@ export function getTransactionCharts( }; } -export type MemoryMetricSeries = ReturnType; - -export function getMemorySeries( - { start, end }: IUrlParams, - memoryChartResponse: MetricsChartAPIResponse['memory'] -) { - const { series, overallValues, totalHits } = memoryChartResponse; - const seriesList = - totalHits === 0 - ? getEmptySerie(start, end) - : [ - { - title: i18n.translate( - 'xpack.apm.chart.memorySeries.systemMaxLabel', - { - defaultMessage: 'System max' - } - ), - data: series.memoryUsedMax, - type: 'linemark', - color: theme.euiColorVis1, - legendValue: asPercent(overallValues.memoryUsedMax || 0, 1) - }, - { - title: i18n.translate( - 'xpack.apm.chart.memorySeries.systemAverageLabel', - { - defaultMessage: 'System average' - } - ), - data: series.memoryUsedAvg, - type: 'linemark', - color: theme.euiColorVis0, - legendValue: asPercent(overallValues.memoryUsedAvg || 0, 1) - } - ]; - - return { - totalHits: memoryChartResponse.totalHits, - series: seriesList - }; -} - -export type CPUMetricSeries = ReturnType; - -export function getCPUSeries(CPUChartResponse: MetricsChartAPIResponse['cpu']) { - const { series, overallValues } = CPUChartResponse; - - const seriesList: TimeSerie[] = [ - { - title: i18n.translate('xpack.apm.chart.cpuSeries.systemMaxLabel', { - defaultMessage: 'System max' - }), - data: series.systemCPUMax, - type: 'linemark', - color: theme.euiColorVis1, - legendValue: asPercent(overallValues.systemCPUMax || 0, 1) - }, - { - title: i18n.translate('xpack.apm.chart.cpuSeries.systemAverageLabel', { - defaultMessage: 'System average' - }), - data: series.systemCPUAverage, - type: 'linemark', - color: theme.euiColorVis0, - legendValue: asPercent(overallValues.systemCPUAverage || 0, 1) - }, - { - title: i18n.translate('xpack.apm.chart.cpuSeries.processMaxLabel', { - defaultMessage: 'Process max' - }), - data: series.processCPUMax, - type: 'linemark', - color: theme.euiColorVis7, - legendValue: asPercent(overallValues.processCPUMax || 0, 1) - }, - { - title: i18n.translate('xpack.apm.chart.cpuSeries.processAverageLabel', { - defaultMessage: 'Process average' - }), - data: series.processCPUAverage, - type: 'linemark', - color: theme.euiColorVis5, - legendValue: asPercent(overallValues.processCPUAverage || 0, 1) - } - ]; - - return { totalHits: CPUChartResponse.totalHits, series: seriesList }; -} - interface TimeSerie { title: string; titleShort?: string; diff --git a/x-pack/plugins/apm/public/services/rest/apm/metrics.ts b/x-pack/plugins/apm/public/services/rest/apm/metrics.ts index 33407a963d676..e33e4a6802875 100644 --- a/x-pack/plugins/apm/public/services/rest/apm/metrics.ts +++ b/x-pack/plugins/apm/public/services/rest/apm/metrics.ts @@ -4,27 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MetricsChartAPIResponse } from '../../../../server/lib/metrics/get_all_metrics_chart_data'; +import { MetricsChartsByAgentAPIResponse } from '../../../../server/lib/metrics/get_metrics_chart_data_by_agent'; import { callApi } from '../callApi'; import { getEncodedEsQuery } from './apm'; -export async function loadMetricsChartDataForService({ +export async function loadMetricsChartData({ serviceName, + agentName, start, end, kuery }: { serviceName: string; + agentName: string; start: string; end: string; kuery: string | undefined; }) { - return callApi({ + return callApi({ pathname: `/api/apm/services/${serviceName}/metrics/charts`, query: { start, end, - esFilterQuery: await getEncodedEsQuery(kuery) + esFilterQuery: await getEncodedEsQuery(kuery), + agentName } }); } diff --git a/x-pack/plugins/apm/public/utils/__test__/formatters.test.ts b/x-pack/plugins/apm/public/utils/__test__/formatters.test.ts index 43aa096c38102..678ab9009edd7 100644 --- a/x-pack/plugins/apm/public/utils/__test__/formatters.test.ts +++ b/x-pack/plugins/apm/public/utils/__test__/formatters.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { asPercent, asTime } from '../formatters'; +import { + asPercent, + asTime, + getFixedByteFormatter, + asDynamicBytes +} from '../formatters'; describe('formatters', () => { describe('asTime', () => { @@ -50,4 +55,77 @@ describe('formatters', () => { expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a'); }); }); + + describe('byte formatting', () => { + const bytes = 10; + const kb = 1000 + 1; + const mb = 1e6 + 1; + const gb = 1e9 + 1; + const tb = 1e12 + 1; + + test('dynamic', () => { + expect(asDynamicBytes(bytes)).toEqual('10.0 B'); + expect(asDynamicBytes(kb)).toEqual('1.0 KB'); + expect(asDynamicBytes(mb)).toEqual('1.0 MB'); + expect(asDynamicBytes(gb)).toEqual('1.0 GB'); + expect(asDynamicBytes(tb)).toEqual('1.0 TB'); + expect(asDynamicBytes(null)).toEqual(''); + expect(asDynamicBytes(NaN)).toEqual(''); + }); + + describe('fixed', () => { + test('in bytes', () => { + const formatInBytes = getFixedByteFormatter(bytes); + expect(formatInBytes(bytes)).toEqual('10.0 B'); + expect(formatInBytes(kb)).toEqual('1,001.0 B'); + expect(formatInBytes(mb)).toEqual('1,000,001.0 B'); + expect(formatInBytes(gb)).toEqual('1,000,000,001.0 B'); + expect(formatInBytes(tb)).toEqual('1,000,000,000,001.0 B'); + expect(formatInBytes(null)).toEqual(''); + expect(formatInBytes(NaN)).toEqual(''); + }); + + test('in kb', () => { + const formatInKB = getFixedByteFormatter(kb); + expect(formatInKB(bytes)).toEqual('0.0 KB'); + expect(formatInKB(kb)).toEqual('1.0 KB'); + expect(formatInKB(mb)).toEqual('1,000.0 KB'); + expect(formatInKB(gb)).toEqual('1,000,000.0 KB'); + expect(formatInKB(tb)).toEqual('1,000,000,000.0 KB'); + }); + + test('in mb', () => { + const formatInMB = getFixedByteFormatter(mb); + expect(formatInMB(bytes)).toEqual('0.0 MB'); + expect(formatInMB(kb)).toEqual('0.0 MB'); + expect(formatInMB(mb)).toEqual('1.0 MB'); + expect(formatInMB(gb)).toEqual('1,000.0 MB'); + expect(formatInMB(tb)).toEqual('1,000,000.0 MB'); + expect(formatInMB(null)).toEqual(''); + expect(formatInMB(NaN)).toEqual(''); + }); + + test('in gb', () => { + const formatInGB = getFixedByteFormatter(gb); + expect(formatInGB(bytes)).toEqual('1e-8 GB'); + expect(formatInGB(kb)).toEqual('0.0 GB'); + expect(formatInGB(mb)).toEqual('0.0 GB'); + expect(formatInGB(gb)).toEqual('1.0 GB'); + expect(formatInGB(tb)).toEqual('1,000.0 GB'); + expect(formatInGB(null)).toEqual(''); + expect(formatInGB(NaN)).toEqual(''); + }); + + test('in tb', () => { + const formatInTB = getFixedByteFormatter(tb); + expect(formatInTB(bytes)).toEqual('1e-11 TB'); + expect(formatInTB(kb)).toEqual('1.001e-9 TB'); + expect(formatInTB(mb)).toEqual('0.0 TB'); + expect(formatInTB(gb)).toEqual('0.0 TB'); + expect(formatInTB(tb)).toEqual('1.0 TB'); + expect(formatInTB(null)).toEqual(''); + expect(formatInTB(NaN)).toEqual(''); + }); + }); + }); }); diff --git a/x-pack/plugins/apm/public/utils/formatters.ts b/x-pack/plugins/apm/public/utils/formatters.ts index 820a58c1b4b7a..f1d3053fcf12e 100644 --- a/x-pack/plugins/apm/public/utils/formatters.ts +++ b/x-pack/plugins/apm/public/utils/formatters.ts @@ -141,3 +141,71 @@ export function asPercent( const decimal = numerator / denominator; return numeral(decimal).format('0.0%'); } + +type ByteFormatter = (value: number | null) => string; + +function asKilobytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return `${asDecimal(value / 1000)} KB`; +} + +function asMegabytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return `${asDecimal(value / 1e6)} MB`; +} + +function asGigabytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return `${asDecimal(value / 1e9)} GB`; +} + +function asTerabytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return `${asDecimal(value / 1e12)} TB`; +} + +export function asBytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return `${asDecimal(value)} B`; +} + +export function asDynamicBytes(value: number | null) { + if (value === null || isNaN(value)) { + return ''; + } + return unmemoizedFixedByteFormatter(value)(value); +} + +type GetByteFormatter = (max: number) => ByteFormatter; + +const unmemoizedFixedByteFormatter: GetByteFormatter = max => { + if (max > 1e12) { + return asTerabytes; + } + + if (max > 1e9) { + return asGigabytes; + } + + if (max > 1e6) { + return asMegabytes; + } + + if (max > 1000) { + return asKilobytes; + } + + return asBytes; +}; + +export const getFixedByteFormatter = memoize(unmemoizedFixedByteFormatter); diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts new file mode 100644 index 0000000000000..df2d6dcf523bf --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getBucketSize } from './get_bucket_size'; + +export function getMetricsDateHistogramParams(start: number, end: number) { + const { bucketSize } = getBucketSize(start, end, 'auto'); + return { + field: '@timestamp', + + // ensure minimum bucket size of 30s since this is the default resolution for metric data + interval: `${Math.max(bucketSize, 30)}s`, + + min_doc_count: 0, + extended_bounds: { min: start, max: end } + }; +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts new file mode 100644 index 0000000000000..f55d3320c7f4e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup } from '../../helpers/setup_request'; +import { getCPUChartData } from './shared/cpu'; +import { getMemoryChartData } from './shared/memory'; + +export async function getDefaultMetricsCharts( + setup: Setup, + serviceName: string +) { + const charts = await Promise.all([ + getCPUChartData(setup, serviceName), + getMemoryChartData(setup, serviceName) + ]); + + return { charts }; +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/fetcher.ts new file mode 100644 index 0000000000000..8d3a06a3c5a37 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/fetcher.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { ESFilter } from 'elasticsearch'; +import { + SERVICE_AGENT_NAME, + PROCESSOR_EVENT, + SERVICE_NAME, + METRIC_JAVA_HEAP_MEMORY_MAX, + METRIC_JAVA_HEAP_MEMORY_COMMITTED, + METRIC_JAVA_HEAP_MEMORY_USED +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from '../../../types'; +import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; +import { rangeFilter } from '../../../../helpers/range_filter'; + +export interface HeapMemoryMetrics extends MetricSeriesKeys { + heapMemoryMax: AggValue; + heapMemoryCommitted: AggValue; + heapMemoryUsed: AggValue; +} + +export async function fetch(setup: Setup, serviceName: string) { + const { start, end, esFilterQuery, client, config } = setup; + const filters: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { term: { [SERVICE_AGENT_NAME]: 'java' } }, + { + range: rangeFilter(start, end) + } + ]; + + if (esFilterQuery) { + filters.push(esFilterQuery); + } + + const aggs = { + heapMemoryMax: { avg: { field: METRIC_JAVA_HEAP_MEMORY_MAX } }, + heapMemoryCommitted: { + avg: { field: METRIC_JAVA_HEAP_MEMORY_COMMITTED } + }, + heapMemoryUsed: { avg: { field: METRIC_JAVA_HEAP_MEMORY_USED } } + }; + + const params = { + index: config.get('apm_oss.metricsIndices'), + body: { + size: 0, + query: { bool: { filter: filters } }, + aggs: { + timeseriesData: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs + }, + ...aggs + } + } + }; + + return client>('search', params); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts new file mode 100644 index 0000000000000..eb6ffdb35561f --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import { Setup } from '../../../../helpers/setup_request'; +import { fetch, HeapMemoryMetrics } from './fetcher'; +import { ChartBase } from '../../../types'; +import { transformDataToMetricsChart } from '../../../transform_metrics_chart'; + +// TODO: i18n for titles + +const chartBase: ChartBase = { + title: i18n.translate('xpack.apm.agentMetrics.java.heapMemoryChartTitle', { + defaultMessage: 'Heap Memory' + }), + key: 'heap_memory_area_chart', + type: 'area', + yUnit: 'bytes', + series: { + heapMemoryUsed: { + title: i18n.translate( + 'xpack.apm.agentMetrics.java.heapMemorySeriesUsed', + { + defaultMessage: 'Used' + } + ), + color: theme.euiColorVis0 + }, + heapMemoryCommitted: { + title: i18n.translate( + 'xpack.apm.agentMetrics.java.heapMemorySeriesCommitted', + { + defaultMessage: 'Committed' + } + ), + color: theme.euiColorVis1 + }, + heapMemoryMax: { + title: i18n.translate( + 'xpack.apm.agentMetrics.java.nonHeapMemorySeriesMax', + { + defaultMessage: 'Max' + } + ), + color: theme.euiColorVis2 + } + } +}; + +export async function getHeapMemoryChart(setup: Setup, serviceName: string) { + const result = await fetch(setup, serviceName); + return transformDataToMetricsChart(result, chartBase); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts new file mode 100644 index 0000000000000..6aacf24102694 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getHeapMemoryChart } from './heap_memory'; +import { Setup } from '../../../helpers/setup_request'; +import { getNonHeapMemoryChart } from './non_heap_memory'; +import { getThreadCountChart } from './thread_count'; + +export async function getJavaMetricsCharts(setup: Setup, serviceName: string) { + const charts = await Promise.all([ + getHeapMemoryChart(setup, serviceName), + getNonHeapMemoryChart(setup, serviceName), + getThreadCountChart(setup, serviceName) + ]); + + return { charts }; +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/fetcher.ts new file mode 100644 index 0000000000000..f719993c819f3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/fetcher.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ESFilter } from 'elasticsearch'; +import { + SERVICE_AGENT_NAME, + PROCESSOR_EVENT, + SERVICE_NAME, + METRIC_JAVA_NON_HEAP_MEMORY_MAX, + METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED, + METRIC_JAVA_NON_HEAP_MEMORY_USED +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from '../../../types'; +import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; +import { rangeFilter } from '../../../../helpers/range_filter'; + +export interface NonHeapMemoryMetrics extends MetricSeriesKeys { + nonHeapMemoryCommitted: AggValue; + nonHeapMemoryUsed: AggValue; +} + +export async function fetch(setup: Setup, serviceName: string) { + const { start, end, esFilterQuery, client, config } = setup; + const filters: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { term: { [SERVICE_AGENT_NAME]: 'java' } }, + { + range: rangeFilter(start, end) + } + ]; + + if (esFilterQuery) { + filters.push(esFilterQuery); + } + + const aggs = { + nonHeapMemoryMax: { avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_MAX } }, + nonHeapMemoryCommitted: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED } + }, + nonHeapMemoryUsed: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_USED } + } + }; + + const params = { + index: config.get('apm_oss.metricsIndices'), + body: { + size: 0, + query: { + bool: { + filter: filters + } + }, + aggs: { + timeseriesData: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs + }, + ...aggs + } + } + }; + + return client>('search', params); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts new file mode 100644 index 0000000000000..2ddbd5f23e19c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import { Setup } from '../../../../helpers/setup_request'; +import { fetch, NonHeapMemoryMetrics } from './fetcher'; +import { ChartBase } from '../../../types'; +import { transformDataToMetricsChart } from '../../../transform_metrics_chart'; + +const chartBase: ChartBase = { + title: i18n.translate('xpack.apm.agentMetrics.java.nonHeapMemoryChartTitle', { + defaultMessage: 'Non-Heap Memory' + }), + key: 'non_heap_memory_area_chart', + type: 'area', + yUnit: 'bytes', + series: { + nonHeapMemoryUsed: { + title: i18n.translate( + 'xpack.apm.agentMetrics.java.nonHeapMemorySeriesUsed', + { + defaultMessage: 'Used' + } + ), + color: theme.euiColorVis0 + }, + nonHeapMemoryCommitted: { + title: i18n.translate( + 'xpack.apm.agentMetrics.java.nonHeapMemorySeriesCommitted', + { + defaultMessage: 'Committed' + } + ), + color: theme.euiColorVis1 + } + } +}; + +export async function getNonHeapMemoryChart(setup: Setup, serviceName: string) { + const result = await fetch(setup, serviceName); + return transformDataToMetricsChart(result, chartBase); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/fetcher.ts new file mode 100644 index 0000000000000..328864025d956 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/fetcher.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ESFilter } from 'elasticsearch'; +import { + SERVICE_AGENT_NAME, + PROCESSOR_EVENT, + SERVICE_NAME, + METRIC_JAVA_THREAD_COUNT +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from '../../../types'; +import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; +import { rangeFilter } from '../../../../helpers/range_filter'; + +export interface ThreadCountMetrics extends MetricSeriesKeys { + threadCount: AggValue; +} + +export async function fetch(setup: Setup, serviceName: string) { + const { start, end, esFilterQuery, client, config } = setup; + const filters: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { term: { [SERVICE_AGENT_NAME]: 'java' } }, + { + range: rangeFilter(start, end) + } + ]; + + if (esFilterQuery) { + filters.push(esFilterQuery); + } + + const aggs = { + threadCount: { avg: { field: METRIC_JAVA_THREAD_COUNT } } + }; + + const params = { + index: config.get('apm_oss.metricsIndices'), + body: { + size: 0, + query: { bool: { filter: filters } }, + aggs: { + timeseriesData: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs + }, + ...aggs + } + } + }; + + return client>('search', params); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts new file mode 100644 index 0000000000000..9b1e441065361 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import { Setup } from '../../../../helpers/setup_request'; +import { fetch, ThreadCountMetrics } from './fetcher'; +import { ChartBase } from '../../../types'; +import { transformDataToMetricsChart } from '../../../transform_metrics_chart'; + +const chartBase: ChartBase = { + title: i18n.translate('xpack.apm.agentMetrics.java.threadCountChartTitle', { + defaultMessage: 'Thread Count' + }), + key: 'thread_count_line_chart', + type: 'linemark', + yUnit: 'number', + series: { + threadCount: { + title: i18n.translate('xpack.apm.agentMetrics.java.threadCount', { + defaultMessage: 'Count' + }), + color: theme.euiColorVis0 + } + } +}; + +export async function getThreadCountChart(setup: Setup, serviceName: string) { + const result = await fetch(setup, serviceName); + return transformDataToMetricsChart(result, chartBase); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/fetcher.ts new file mode 100644 index 0000000000000..e45c69c5f8760 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/fetcher.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESFilter } from 'elasticsearch'; +import { + METRIC_PROCESS_CPU_PERCENT, + METRIC_SYSTEM_CPU_PERCENT, + PROCESSOR_EVENT, + SERVICE_NAME +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from '../../../types'; +import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; +import { rangeFilter } from '../../../../helpers/range_filter'; + +export interface CPUMetrics extends MetricSeriesKeys { + systemCPUAverage: AggValue; + systemCPUMax: AggValue; + processCPUAverage: AggValue; + processCPUMax: AggValue; +} + +export async function fetch(setup: Setup, serviceName: string) { + const { start, end, esFilterQuery, client, config } = setup; + const filters: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { + range: rangeFilter(start, end) + } + ]; + + if (esFilterQuery) { + filters.push(esFilterQuery); + } + + const aggs = { + systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, + systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, + processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, + processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } } + }; + + const params = { + index: config.get('apm_oss.metricsIndices'), + body: { + size: 0, + query: { bool: { filter: filters } }, + aggs: { + timeseriesData: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs + }, + ...aggs + } + } + }; + + return client>('search', params); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts new file mode 100644 index 0000000000000..f7fe92d578e93 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import { Setup } from '../../../../helpers/setup_request'; +import { fetch, CPUMetrics } from './fetcher'; +import { ChartBase } from '../../../types'; +import { transformDataToMetricsChart } from '../../../transform_metrics_chart'; + +const chartBase: ChartBase = { + title: i18n.translate('xpack.apm.serviceDetails.metrics.cpuUsageChartTitle', { + defaultMessage: 'CPU usage' + }), + key: 'cpu_usage_chart', + type: 'linemark', + yUnit: 'percent', + series: { + systemCPUMax: { + title: i18n.translate('xpack.apm.chart.cpuSeries.systemMaxLabel', { + defaultMessage: 'System max' + }), + color: theme.euiColorVis1 + }, + systemCPUAverage: { + title: i18n.translate('xpack.apm.chart.cpuSeries.systemAverageLabel', { + defaultMessage: 'System average' + }), + color: theme.euiColorVis0 + }, + processCPUMax: { + title: i18n.translate('xpack.apm.chart.cpuSeries.processMaxLabel', { + defaultMessage: 'Process max' + }), + color: theme.euiColorVis7 + }, + processCPUAverage: { + title: i18n.translate('xpack.apm.chart.cpuSeries.processAverageLabel', { + defaultMessage: 'Process average' + }), + color: theme.euiColorVis5 + } + } +}; + +export async function getCPUChartData(setup: Setup, serviceName: string) { + const result = await fetch(setup, serviceName); + return transformDataToMetricsChart(result, chartBase); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/fetcher.ts new file mode 100644 index 0000000000000..a4d378b6ebaca --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/fetcher.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESFilter } from 'elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + METRIC_SYSTEM_FREE_MEMORY, + METRIC_SYSTEM_TOTAL_MEMORY +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from '../../../types'; +import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; +import { rangeFilter } from '../../../../helpers/range_filter'; + +export interface MemoryMetrics extends MetricSeriesKeys { + memoryUsedAvg: AggValue; + memoryUsedMax: AggValue; +} + +const percentUsedScript = { + lang: 'expression', + source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']` +}; + +export async function fetch(setup: Setup, serviceName: string) { + const { start, end, esFilterQuery, client, config } = setup; + const filters: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { + range: rangeFilter(start, end) + } + ]; + + if (esFilterQuery) { + filters.push(esFilterQuery); + } + + const aggs = { + memoryUsedAvg: { avg: { script: percentUsedScript } }, + memoryUsedMax: { max: { script: percentUsedScript } } + }; + + const params = { + index: config.get('apm_oss.metricsIndices'), + body: { + size: 0, + query: { bool: { filter: filters } }, + aggs: { + timeseriesData: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs + }, + ...aggs + } + } + }; + + return client>('search', params); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts new file mode 100644 index 0000000000000..f5976972e6d5e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { Setup } from '../../../../helpers/setup_request'; +import { fetch, MemoryMetrics } from './fetcher'; +import { ChartBase } from '../../../types'; +import { transformDataToMetricsChart } from '../../../transform_metrics_chart'; + +const chartBase: ChartBase = { + title: i18n.translate( + 'xpack.apm.serviceDetails.metrics.memoryUsageChartTitle', + { + defaultMessage: 'Memory usage' + } + ), + key: 'memory_usage_chart', + type: 'linemark', + yUnit: 'percent', + series: { + memoryUsedMax: { + title: i18n.translate('xpack.apm.chart.memorySeries.systemMaxLabel', { + defaultMessage: 'System max' + }) + }, + memoryUsedAvg: { + title: i18n.translate('xpack.apm.chart.memorySeries.systemAverageLabel', { + defaultMessage: 'System average' + }) + } + } +}; + +export async function getMemoryChartData(setup: Setup, serviceName: string) { + const result = await fetch(setup, serviceName); + return transformDataToMetricsChart(result, chartBase); +} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_all_metrics_chart_data.ts b/x-pack/plugins/apm/server/lib/metrics/get_all_metrics_chart_data.ts deleted file mode 100644 index b16c3e16d330c..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_all_metrics_chart_data.ts +++ /dev/null @@ -1,24 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PromiseReturnType } from '../../../typings/common'; -import { getCPUChartData } from './get_cpu_chart_data'; -import { getMemoryChartData } from './get_memory_chart_data'; -import { MetricsRequestArgs } from './query_types'; - -export type MetricsChartAPIResponse = PromiseReturnType< - typeof getAllMetricsChartData ->; -export async function getAllMetricsChartData(args: MetricsRequestArgs) { - const [memoryChartData, cpuChartData] = await Promise.all([ - getMemoryChartData(args), - getCPUChartData(args) - ]); - return { - memory: memoryChartData, - cpu: cpuChartData - }; -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap deleted file mode 100644 index 0c5b0e346f8ff..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap +++ /dev/null @@ -1,100 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CPU chart data fetcher should fetch aggregations 1`] = ` -Array [ - Array [ - "search", - Object { - "body": Object { - "aggs": Object { - "processCPUAverage": Object { - "avg": Object { - "field": "system.process.cpu.total.norm.pct", - }, - }, - "processCPUMax": Object { - "max": Object { - "field": "system.process.cpu.total.norm.pct", - }, - }, - "systemCPUAverage": Object { - "avg": Object { - "field": "system.cpu.total.norm.pct", - }, - }, - "systemCPUMax": Object { - "max": Object { - "field": "system.cpu.total.norm.pct", - }, - }, - "timeseriesData": Object { - "aggs": Object { - "processCPUAverage": Object { - "avg": Object { - "field": "system.process.cpu.total.norm.pct", - }, - }, - "processCPUMax": Object { - "max": Object { - "field": "system.process.cpu.total.norm.pct", - }, - }, - "systemCPUAverage": Object { - "avg": Object { - "field": "system.cpu.total.norm.pct", - }, - }, - "systemCPUMax": Object { - "max": Object { - "field": "system.cpu.total.norm.pct", - }, - }, - }, - "date_histogram": Object { - "extended_bounds": Object { - "max": 200, - "min": 100, - }, - "field": "@timestamp", - "interval": "30s", - "min_doc_count": 0, - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "service.name": "test-service", - }, - }, - Object { - "term": Object { - "processor.event": "metric", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 100, - "lte": 200, - }, - }, - }, - Object { - "term": Object { - "field": "test.esfilter.query", - }, - }, - ], - }, - }, - "size": 0, - }, - "index": undefined, - }, - ], -] -`; diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/transformer.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/transformer.test.ts.snap deleted file mode 100644 index 611cc6ebb881d..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/__snapshots__/transformer.test.ts.snap +++ /dev/null @@ -1,183 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CPU chart data transformer should transform ES data 1`] = ` -Object { - "overallValues": Object { - "processCPUAverage": 200, - "processCPUMax": 100, - "systemCPUAverage": 400, - "systemCPUMax": 300, - }, - "series": Object { - "processCPUAverage": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 20, - }, - Object { - "x": 2, - "y": 40, - }, - Object { - "x": 3, - "y": 60, - }, - Object { - "x": 4, - "y": 80, - }, - Object { - "x": 5, - "y": 100, - }, - Object { - "x": 6, - "y": 120, - }, - Object { - "x": 7, - "y": 140, - }, - Object { - "x": 8, - "y": 160, - }, - Object { - "x": 9, - "y": 180, - }, - ], - "processCPUMax": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 10, - }, - Object { - "x": 2, - "y": 20, - }, - Object { - "x": 3, - "y": 30, - }, - Object { - "x": 4, - "y": 40, - }, - Object { - "x": 5, - "y": 50, - }, - Object { - "x": 6, - "y": 60, - }, - Object { - "x": 7, - "y": 70, - }, - Object { - "x": 8, - "y": 80, - }, - Object { - "x": 9, - "y": 90, - }, - ], - "systemCPUAverage": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 40, - }, - Object { - "x": 2, - "y": 80, - }, - Object { - "x": 3, - "y": 120, - }, - Object { - "x": 4, - "y": 160, - }, - Object { - "x": 5, - "y": 200, - }, - Object { - "x": 6, - "y": 240, - }, - Object { - "x": 7, - "y": 280, - }, - Object { - "x": 8, - "y": 320, - }, - Object { - "x": 9, - "y": 360, - }, - ], - "systemCPUMax": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 30, - }, - Object { - "x": 2, - "y": 60, - }, - Object { - "x": 3, - "y": 90, - }, - Object { - "x": 4, - "y": 120, - }, - Object { - "x": 5, - "y": 150, - }, - Object { - "x": 6, - "y": 180, - }, - Object { - "x": 7, - "y": 210, - }, - Object { - "x": 8, - "y": 240, - }, - Object { - "x": 9, - "y": 270, - }, - ], - }, - "totalHits": 199, -} -`; diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/fetcher.test.ts b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/fetcher.test.ts deleted file mode 100644 index e9a211ad09ee7..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/fetcher.test.ts +++ /dev/null @@ -1,16 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSetupMock } from '../../../../testHelpers/mocks'; -import { fetch } from '../fetcher'; - -describe('CPU chart data fetcher', () => { - it('should fetch aggregations', async () => { - const mockSetup = getSetupMock(); - await fetch({ setup: mockSetup, serviceName: 'test-service' }); - expect(mockSetup.client.mock.calls).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/transformer.test.ts b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/transformer.test.ts deleted file mode 100644 index 388a5e09eb264..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/__tests__/transformer.test.ts +++ /dev/null @@ -1,58 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESResponse } from '../fetcher'; -import { transform } from '../transformer'; - -describe('CPU chart data transformer', () => { - it('should transform ES data', () => { - const response = { - aggregations: { - timeseriesData: { - buckets: Array(10) - .fill(1) - .map((_, i) => ({ - key: i, - systemCPUAverage: { value: i * 40 }, - systemCPUMax: { value: i * 30 }, - processCPUAverage: { value: i * 20 }, - processCPUMax: { value: i * 10 } - })) - }, - systemCPUAverage: { - value: 400 - }, - systemCPUMax: { - value: 300 - }, - processCPUAverage: { - value: 200 - }, - processCPUMax: { - value: 100 - } - }, - hits: { - total: 199 - } - } as ESResponse; - - const result = transform(response); - expect(result).toMatchSnapshot(); - - expect(result.series.systemCPUAverage).toHaveLength(10); - expect(result.series.systemCPUMax).toHaveLength(10); - expect(result.series.processCPUAverage).toHaveLength(10); - expect(result.series.processCPUMax).toHaveLength(10); - - expect(Object.keys(result.overallValues)).toEqual([ - 'systemCPUAverage', - 'systemCPUMax', - 'processCPUAverage', - 'processCPUMax' - ]); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/fetcher.ts deleted file mode 100644 index 9a6d07a0a6e4e..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/fetcher.ts +++ /dev/null @@ -1,81 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { ESFilter } from 'elasticsearch'; -import { - METRIC_PROCESS_CPU_PERCENT, - METRIC_SYSTEM_CPU_PERCENT, - PROCESSOR_EVENT, - SERVICE_NAME -} from '../../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType } from '../../../../typings/common'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { AggValue, MetricsRequestArgs, TimeSeriesBucket } from '../query_types'; - -interface Bucket extends TimeSeriesBucket { - systemCPUAverage: AggValue; - systemCPUMax: AggValue; - processCPUAverage: AggValue; - processCPUMax: AggValue; -} - -interface Aggs { - timeseriesData: { - buckets: Bucket[]; - }; - systemCPUAverage: AggValue; - systemCPUMax: AggValue; - processCPUAverage: AggValue; - processCPUMax: AggValue; -} - -export type ESResponse = PromiseReturnType; -export async function fetch({ serviceName, setup }: MetricsRequestArgs) { - const { start, end, esFilterQuery, client, config } = setup; - const { bucketSize } = getBucketSize(start, end, 'auto'); - const filters: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [PROCESSOR_EVENT]: 'metric' } }, - { - range: { '@timestamp': { gte: start, lte: end, format: 'epoch_millis' } } - } - ]; - - if (esFilterQuery) { - filters.push(esFilterQuery); - } - - const params = { - index: config.get('apm_oss.metricsIndices'), - body: { - size: 0, - query: { bool: { filter: filters } }, - aggs: { - timeseriesData: { - date_histogram: { - field: '@timestamp', - - // ensure minimum bucket size of 30s since this is the default resolution for metric data - interval: `${Math.max(bucketSize, 30)}s`, - min_doc_count: 0, - extended_bounds: { min: start, max: end } - }, - aggs: { - systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, - systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, - processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, - processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } } - } - }, - systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, - systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, - processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, - processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } } - } - } - }; - - return client('search', params); -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/index.ts b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/index.ts deleted file mode 100644 index fb46d9afbfc44..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/index.ts +++ /dev/null @@ -1,15 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { MetricsRequestArgs } from '../query_types'; -import { fetch } from './fetcher'; -import { CPUChartAPIResponse, transform } from './transformer'; - -export { CPUChartAPIResponse }; - -export async function getCPUChartData(args: MetricsRequestArgs) { - const result = await fetch(args); - return transform(result); -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/transformer.ts b/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/transformer.ts deleted file mode 100644 index 024ef76efa3cf..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/transformer.ts +++ /dev/null @@ -1,59 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { Coordinate } from '../../../../typings/timeseries'; -import { ESResponse } from './fetcher'; - -type CPUMetricName = - | 'systemCPUAverage' - | 'systemCPUMax' - | 'processCPUAverage' - | 'processCPUMax'; - -const CPU_METRIC_NAMES: CPUMetricName[] = [ - 'systemCPUAverage', - 'systemCPUMax', - 'processCPUAverage', - 'processCPUMax' -]; - -export type CPUChartAPIResponse = ReturnType; - -export function transform(result: ESResponse) { - const { aggregations, hits } = result; - const { - timeseriesData, - systemCPUAverage, - systemCPUMax, - processCPUAverage, - processCPUMax - } = aggregations; - - const series = { - systemCPUAverage: [] as Coordinate[], - systemCPUMax: [] as Coordinate[], - processCPUAverage: [] as Coordinate[], - processCPUMax: [] as Coordinate[] - }; - - // using forEach here to avoid looping over the entire dataset - // 4 times or doing a complicated, memory-heavy map/reduce - timeseriesData.buckets.forEach(({ key, ...bucket }) => { - CPU_METRIC_NAMES.forEach(name => { - series[name].push({ x: key, y: bucket[name].value }); - }); - }); - - return { - series, - overallValues: { - systemCPUAverage: systemCPUAverage.value, - systemCPUMax: systemCPUMax.value, - processCPUAverage: processCPUAverage.value, - processCPUMax: processCPUMax.value - }, - totalHits: hits.total - }; -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap deleted file mode 100644 index 064a4fa5d24b3..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/fetcher.test.ts.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`get memory chart data fetcher should fetch memory chart aggregations 1`] = ` -Array [ - Array [ - "search", - Object { - "body": Object { - "aggs": Object { - "memoryUsedAvg": Object { - "avg": Object { - "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", - }, - }, - }, - "memoryUsedMax": Object { - "max": Object { - "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", - }, - }, - }, - "timeseriesData": Object { - "aggs": Object { - "memoryUsedAvg": Object { - "avg": Object { - "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", - }, - }, - }, - "memoryUsedMax": Object { - "max": Object { - "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", - }, - }, - }, - }, - "date_histogram": Object { - "extended_bounds": Object { - "max": 200, - "min": 100, - }, - "field": "@timestamp", - "interval": "30s", - "min_doc_count": 0, - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "service.name": "test-service", - }, - }, - Object { - "term": Object { - "processor.event": "metric", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 100, - "lte": 200, - }, - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", - }, - }, - Object { - "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "term": Object { - "field": "test.esfilter.query", - }, - }, - ], - }, - }, - "size": 0, - }, - "index": undefined, - }, - ], -] -`; diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/transformer.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/transformer.test.ts.snap deleted file mode 100644 index a232612ff782b..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/__snapshots__/transformer.test.ts.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`memory chart data transformer should transform ES data 1`] = ` -Object { - "overallValues": Object { - "memoryUsedAvg": 300, - "memoryUsedMax": 400, - }, - "series": Object { - "memoryUsedAvg": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 30, - }, - Object { - "x": 2, - "y": 60, - }, - Object { - "x": 3, - "y": 90, - }, - Object { - "x": 4, - "y": 120, - }, - Object { - "x": 5, - "y": 150, - }, - Object { - "x": 6, - "y": 180, - }, - Object { - "x": 7, - "y": 210, - }, - Object { - "x": 8, - "y": 240, - }, - Object { - "x": 9, - "y": 270, - }, - ], - "memoryUsedMax": Array [ - Object { - "x": 0, - "y": 0, - }, - Object { - "x": 1, - "y": 40, - }, - Object { - "x": 2, - "y": 80, - }, - Object { - "x": 3, - "y": 120, - }, - Object { - "x": 4, - "y": 160, - }, - Object { - "x": 5, - "y": 200, - }, - Object { - "x": 6, - "y": 240, - }, - Object { - "x": 7, - "y": 280, - }, - Object { - "x": 8, - "y": 320, - }, - Object { - "x": 9, - "y": 360, - }, - ], - }, - "totalHits": 199, -} -`; diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/fetcher.test.ts b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/fetcher.test.ts deleted file mode 100644 index c4c4298422bba..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/fetcher.test.ts +++ /dev/null @@ -1,16 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSetupMock } from '../../../../testHelpers/mocks'; -import { fetch } from '../fetcher'; - -describe('get memory chart data fetcher', () => { - it('should fetch memory chart aggregations', async () => { - const mockSetup = getSetupMock(); - await fetch({ setup: mockSetup, serviceName: 'test-service' }); - expect(mockSetup.client.mock.calls).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/transformer.test.ts b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/transformer.test.ts deleted file mode 100644 index 8a7bbb1683057..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/__tests__/transformer.test.ts +++ /dev/null @@ -1,42 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESResponse } from '../fetcher'; -import { transform } from '../transformer'; - -describe('memory chart data transformer', () => { - it('should transform ES data', () => { - const response = { - aggregations: { - timeseriesData: { - buckets: Array(10) - .fill(1) - .map((_, i) => ({ - key: i, - memoryUsedMax: { value: i * 40 }, - memoryUsedAvg: { value: i * 30 } - })) - }, - memoryUsedMax: { value: 400 }, - memoryUsedAvg: { value: 300 } - }, - hits: { total: 199 } - } as ESResponse; - - const result = transform(response); - expect(result).toMatchSnapshot(); - - expect(result.series.memoryUsedMax).toHaveLength(10); - expect(result.series.memoryUsedAvg).toHaveLength(10); - - const overall = Object.keys(result.overallValues); - - expect(overall).toHaveLength(2); - expect(overall).toEqual( - expect.arrayContaining(['memoryUsedMax', 'memoryUsedAvg']) - ); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/fetcher.ts b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/fetcher.ts deleted file mode 100644 index 0bccff17aebe8..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/fetcher.ts +++ /dev/null @@ -1,80 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { ESFilter } from 'elasticsearch'; -import { - METRIC_SYSTEM_FREE_MEMORY, - METRIC_SYSTEM_TOTAL_MEMORY, - PROCESSOR_EVENT, - SERVICE_NAME -} from '../../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType } from '../../../../typings/common'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { AggValue, MetricsRequestArgs, TimeSeriesBucket } from '../query_types'; - -interface Bucket extends TimeSeriesBucket { - memoryUsedAvg: AggValue; - memoryUsedMax: AggValue; -} - -interface Aggs { - timeseriesData: { - buckets: Bucket[]; - }; - memoryUsedAvg: AggValue; - memoryUsedMax: AggValue; -} - -export type ESResponse = PromiseReturnType; -export async function fetch({ serviceName, setup }: MetricsRequestArgs) { - const { start, end, esFilterQuery, client, config } = setup; - const { bucketSize } = getBucketSize(start, end, 'auto'); - const filters: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [PROCESSOR_EVENT]: 'metric' } }, - { - range: { '@timestamp': { gte: start, lte: end, format: 'epoch_millis' } } - }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } } - ]; - - if (esFilterQuery) { - filters.push(esFilterQuery); - } - - const script = { - lang: 'expression', - source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']` - }; - - const params = { - index: config.get('apm_oss.metricsIndices'), - body: { - size: 0, - query: { bool: { filter: filters } }, - aggs: { - timeseriesData: { - date_histogram: { - field: '@timestamp', - - // ensure minimum bucket size of 30s since this is the default resolution for metric data - interval: `${Math.max(bucketSize, 30)}s`, - min_doc_count: 0, - extended_bounds: { min: start, max: end } - }, - aggs: { - memoryUsedAvg: { avg: { script } }, - memoryUsedMax: { max: { script } } - } - }, - memoryUsedAvg: { avg: { script } }, - memoryUsedMax: { max: { script } } - } - } - }; - - return client('search', params); -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/index.ts b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/index.ts deleted file mode 100644 index 8b6dce6bf1c0e..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/index.ts +++ /dev/null @@ -1,15 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { MetricsRequestArgs } from '../query_types'; -import { fetch } from './fetcher'; -import { MemoryChartAPIResponse, transform } from './transformer'; - -export { MemoryChartAPIResponse }; - -export async function getMemoryChartData(args: MetricsRequestArgs) { - const result = await fetch(args); - return transform(result); -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/transformer.ts b/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/transformer.ts deleted file mode 100644 index cc1ed0c9b2ce6..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/transformer.ts +++ /dev/null @@ -1,41 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { Coordinate } from '../../../../typings/timeseries'; -import { ESResponse } from './fetcher'; - -type MemoryMetricName = 'memoryUsedAvg' | 'memoryUsedMax'; -const MEMORY_METRIC_NAMES: MemoryMetricName[] = [ - 'memoryUsedAvg', - 'memoryUsedMax' -]; - -export type MemoryChartAPIResponse = ReturnType; -export function transform(result: ESResponse) { - const { aggregations, hits } = result; - const { timeseriesData, memoryUsedAvg, memoryUsedMax } = aggregations; - - const series = { - memoryUsedAvg: [] as Coordinate[], - memoryUsedMax: [] as Coordinate[] - }; - - // using forEach here to avoid looping over the entire dataset - // multiple times or doing a complicated, memory-heavy map/reduce - timeseriesData.buckets.forEach(({ key, ...bucket }) => { - MEMORY_METRIC_NAMES.forEach(name => { - series[name].push({ x: key, y: bucket[name].value }); - }); - }); - - return { - series, - overallValues: { - memoryUsedAvg: memoryUsedAvg.value, - memoryUsedMax: memoryUsedMax.value - }, - totalHits: hits.total - }; -} diff --git a/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts b/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts new file mode 100644 index 0000000000000..02b94c515d841 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Setup } from '../helpers/setup_request'; +import { getJavaMetricsCharts } from './by_agent/java'; +import { getDefaultMetricsCharts } from './by_agent/default'; +import { GenericMetricsChart } from './transform_metrics_chart'; + +export interface MetricsChartsByAgentAPIResponse { + charts: GenericMetricsChart[]; +} + +export async function getMetricsChartDataByAgent({ + setup, + serviceName, + agentName +}: { + setup: Setup; + serviceName: string; + agentName: string; +}): Promise { + switch (agentName) { + case 'java': { + return getJavaMetricsCharts(setup, serviceName); + } + + default: { + return getDefaultMetricsCharts(setup, serviceName); + } + } +} diff --git a/x-pack/plugins/apm/server/lib/metrics/query_types.ts b/x-pack/plugins/apm/server/lib/metrics/query_types.ts deleted file mode 100644 index 80def2dfece80..0000000000000 --- a/x-pack/plugins/apm/server/lib/metrics/query_types.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Setup } from '../helpers/setup_request'; - -export interface MetricsRequestArgs { - serviceName: string; - setup: Setup; -} - -export interface AggValue { - value: number | null; -} - -export interface TimeSeriesBucket { - key_as_string: string; // timestamp as string - key: number; // timestamp as epoch milliseconds - doc_count: number; -} diff --git a/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts b/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts new file mode 100644 index 0000000000000..e6fff34b37bc4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { AggregationSearchResponse } from 'elasticsearch'; +import { MetricsAggs, MetricSeriesKeys, AggValue } from './types'; +import { transformDataToMetricsChart } from './transform_metrics_chart'; +import { ChartType, YUnit } from '../../../typings/timeseries'; + +test('transformDataToMetricsChart should transform an ES result into a chart object', () => { + interface TestKeys extends MetricSeriesKeys { + a: AggValue; + b: AggValue; + c: AggValue; + } + + type R = AggregationSearchResponse>; + + const response = { + hits: { total: 5000 } as R['hits'], + aggregations: { + a: { value: 1000 }, + b: { value: 1000 }, + c: { value: 1000 }, + timeseriesData: { + buckets: [ + { + a: { value: 10 }, + b: { value: 10 }, + c: { value: 10 }, + key: 1 + } as R['aggregations']['timeseriesData']['buckets'][0], + { + a: { value: 20 }, + b: { value: 20 }, + c: { value: 20 }, + key: 2 + } as R['aggregations']['timeseriesData']['buckets'][0], + { + a: { value: 30 }, + b: { value: 30 }, + c: { value: 30 }, + key: 3 + } as R['aggregations']['timeseriesData']['buckets'][0] + ] + } + } as R['aggregations'] + } as R; + + const chartBase = { + title: 'Test Chart Title', + type: 'linemark' as ChartType, + key: 'test_chart_key', + yUnit: 'number' as YUnit, + series: { + a: { title: 'Series A', color: 'red' }, + b: { title: 'Series B', color: 'blue' }, + c: { title: 'Series C', color: 'green' } + } + }; + + const chart = transformDataToMetricsChart(response, chartBase); + + expect(chart).toMatchInlineSnapshot(` +Object { + "key": "test_chart_key", + "series": Array [ + Object { + "color": "red", + "data": Array [ + Object { + "x": 1, + "y": 10, + }, + Object { + "x": 2, + "y": 20, + }, + Object { + "x": 3, + "y": 30, + }, + ], + "key": "a", + "overallValue": 1000, + "title": "Series A", + "type": "linemark", + }, + Object { + "color": "blue", + "data": Array [ + Object { + "x": 1, + "y": 10, + }, + Object { + "x": 2, + "y": 20, + }, + Object { + "x": 3, + "y": 30, + }, + ], + "key": "b", + "overallValue": 1000, + "title": "Series B", + "type": "linemark", + }, + Object { + "color": "green", + "data": Array [ + Object { + "x": 1, + "y": 10, + }, + Object { + "x": 2, + "y": 20, + }, + Object { + "x": 3, + "y": 30, + }, + ], + "key": "c", + "overallValue": 1000, + "title": "Series C", + "type": "linemark", + }, + ], + "title": "Test Chart Title", + "totalHits": 5000, + "yUnit": "number", +} +`); +}); diff --git a/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.ts b/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.ts new file mode 100644 index 0000000000000..9936b6883a1c7 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/transform_metrics_chart.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { AggregationSearchResponse } from 'elasticsearch'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { ChartBase, MetricsAggs, MetricSeriesKeys } from './types'; + +const colors = [ + theme.euiColorVis0, + theme.euiColorVis1, + theme.euiColorVis2, + theme.euiColorVis3, + theme.euiColorVis4, + theme.euiColorVis5, + theme.euiColorVis6 +]; + +export type GenericMetricsChart = ReturnType< + typeof transformDataToMetricsChart +>; +export function transformDataToMetricsChart( + result: AggregationSearchResponse>, + chartBase: ChartBase +) { + const { aggregations, hits } = result; + const { timeseriesData } = aggregations; + + return { + title: chartBase.title, + key: chartBase.key, + yUnit: chartBase.yUnit, + totalHits: hits.total, + series: Object.keys(chartBase.series).map((seriesKey, i) => ({ + title: chartBase.series[seriesKey].title, + key: seriesKey, + type: chartBase.type, + color: chartBase.series[seriesKey].color || colors[i], + overallValue: aggregations[seriesKey].value, + data: timeseriesData.buckets.map(bucket => { + const { value } = bucket[seriesKey]; + const y = value === null || isNaN(value) ? null : value; + return { + x: bucket.key, + y + }; + }) + })) + }; +} diff --git a/x-pack/plugins/apm/server/lib/metrics/types.ts b/x-pack/plugins/apm/server/lib/metrics/types.ts new file mode 100644 index 0000000000000..f234b44733444 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ChartType, YUnit } from '../../../typings/timeseries'; + +export interface AggValue { + value: number | null; +} + +export interface MetricSeriesKeys { + [key: string]: AggValue; +} + +export interface ChartBase { + title: string; + key: string; + type: ChartType; + yUnit: YUnit; + series: { + [key in keyof T]: { + title: string; + color?: string; + } + }; +} + +export type MetricsAggs = { + timeseriesData: { + buckets: Array< + { + key_as_string: string; // timestamp as string + key: number; // timestamp as epoch milliseconds + doc_count: number; + } & T + >; + }; +} & T; diff --git a/x-pack/plugins/apm/server/lib/services/get_service.ts b/x-pack/plugins/apm/server/lib/services/get_service.ts index f4a403f37264e..609763396f92f 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service.ts @@ -65,7 +65,7 @@ export async function getService(serviceName: string, setup: Setup) { const { aggregations } = await client('search', params); const buckets = idx(aggregations, _ => _.types.buckets) || []; const types = buckets.map(bucket => bucket.key); - const agentName = idx(aggregations, _ => _.agents.buckets[0].key); + const agentName = idx(aggregations, _ => _.agents.buckets[0].key) || ''; return { serviceName, types, diff --git a/x-pack/plugins/apm/server/routes/metrics.ts b/x-pack/plugins/apm/server/routes/metrics.ts index 997ff9b803c09..3bc82010aac77 100644 --- a/x-pack/plugins/apm/server/routes/metrics.ts +++ b/x-pack/plugins/apm/server/routes/metrics.ts @@ -5,10 +5,11 @@ */ import Boom from 'boom'; +import Joi from 'joi'; import { CoreSetup } from 'src/core/server'; import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getAllMetricsChartData } from '../lib/metrics/get_all_metrics_chart_data'; +import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; const defaultErrorHandler = (err: Error) => { // eslint-disable-next-line @@ -18,21 +19,27 @@ const defaultErrorHandler = (err: Error) => { export function initMetricsApi(core: CoreSetup) { const { server } = core.http; + server.route({ method: 'GET', path: `/api/apm/services/{serviceName}/metrics/charts`, options: { validate: { - query: withDefaultValidators() + query: withDefaultValidators({ + agentName: Joi.string().required() + }) }, tags: ['access:apm'] }, handler: async req => { const setup = setupRequest(req); const { serviceName } = req.params; - return await getAllMetricsChartData({ + // casting approach recommended here: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25605 + const { agentName } = req.query as { agentName: string }; + return await getMetricsChartDataByAgent({ setup, - serviceName + serviceName, + agentName }).catch(defaultErrorHandler); } }); diff --git a/x-pack/plugins/apm/typings/timeseries.ts b/x-pack/plugins/apm/typings/timeseries.ts index 93d354b6bc1d4..1b10824234481 100644 --- a/x-pack/plugins/apm/typings/timeseries.ts +++ b/x-pack/plugins/apm/typings/timeseries.ts @@ -13,3 +13,6 @@ export interface RectCoordinate { x: number; x0: number; } + +export type ChartType = 'area' | 'linemark'; +export type YUnit = 'percent' | 'bytes' | 'number'; diff --git a/x-pack/plugins/canvas/common/lib/dataurl.ts b/x-pack/plugins/canvas/common/lib/dataurl.ts index 4542a6a9fccd7..eddc8da1a51aa 100644 --- a/x-pack/plugins/canvas/common/lib/dataurl.ts +++ b/x-pack/plugins/canvas/common/lib/dataurl.ts @@ -42,7 +42,10 @@ export function parseDataUrl(str: string, withData = false) { }; } -export function isValidDataUrl(str: string) { +export function isValidDataUrl(str?: string) { + if (!str) { + return false; + } return dataurlRegex.test(str); } diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.examples.storyshot b/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.examples.storyshot new file mode 100644 index 0000000000000..4fac77ebf47e2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/__examples__/__snapshots__/custom_element_modal.examples.storyshot @@ -0,0 +1,1405 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/CustomElementModal with description 1`] = ` +Array [ +

, +
, +
+
+ +
+
+
+

+ Edit custom element +

+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ 40 characters remaining +
+
+
+
+ +
+