From a149497b73d7046628c1c75490538173f11f4a09 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 24 Oct 2019 08:59:45 -0700 Subject: [PATCH 1/9] NP Security HTTP Interceptors (#39477) * We have a NP plugin! :celebration: * Redirecting to login on all 401s * Adding commented out code for when credentials are omitted * Fixing types * Respond 403 when user changes password with incorrect current password * Adding AnonymousPaths where we ignore all 401s * Adding anonymous path tests * Extracted a dedicated SessionExpires class and added tests * Fixing plugin after refactoring to add SessionExpired * Beginning to work on the session timeout interceptor * Fixing UnauthorizedResponseInterceptor anonymous path test * Removing test anonymous path * Trying to improve readability * Displaying session logout warning * Mocking out the base path * Revert "Mocking out the base path" This reverts commit 824086c168aec5cc55c04e5866ceaafdb2ec12f9. * Changing coreMock to use a concrete instance of BasePath * Adding session timeout interceptor tests * Adding session timeout tests * Adding more tests for short session timeouts * Moving some files to a session folder * More thrashing around: renaming and reorganizing * Renaming Interceptor to HttpInterceptor * Fixing some type errors * Fixing legacy chrome API tests * Fixing other tests to use the concrete instance of BasePath * Adjusting some types * Putting DeeplyMocked back, I don't get how DeeplyMockedKeys works * Moving anonymousPaths to public core http * Reading sessionTimeout from injected vars and supporting null timeout * Doesn't extend session when there is no response * Updating docs and snapshots * Casting sessionTimeout injectedVar to "number | null" * Fixing i18n issues * Update x-pack/plugins/security/public/plugin.ts Co-Authored-By: Larry Gregory * Adding milliseconds postfix to SessionTimeout private fields * Even better anonymous paths, with some validation * Adjusting public method docs for IAnonymousPaths * Adjusting spelling of base-path to basePath * Update x-pack/plugins/security/public/session/session_timeout.tsx Co-Authored-By: Larry Gregory * Update src/core/public/http/anonymous_paths.ts Co-Authored-By: Josh Dover * Update src/core/public/http/anonymous_paths.ts Co-Authored-By: Josh Dover * AnonymousPaths implements IAnonymousPaths and uses IBasePath * Removing DeeplyMocked * Removing TODOs * Fixing types... * Now, ever more normal --- ...n-public.httpservicebase.anonymouspaths.md | 13 ++ .../kibana-plugin-public.httpservicebase.md | 1 + ...ugin-public.ianonymouspaths.isanonymous.md | 24 +++ .../kibana-plugin-public.ianonymouspaths.md | 21 +++ ...-plugin-public.ianonymouspaths.register.md | 24 +++ .../core/public/kibana-plugin-public.md | 1 + src/core/public/http/anonymous_paths.test.ts | 107 +++++++++++ src/core/public/http/anonymous_paths.ts | 53 ++++++ src/core/public/http/http_service.mock.ts | 11 +- src/core/public/http/http_setup.ts | 3 + src/core/public/http/types.ts | 20 ++ src/core/public/index.ts | 1 + src/core/public/mocks.ts | 14 +- .../notifications_service.mock.ts | 6 +- src/core/public/public.api.md | 9 + .../ui_settings_service.test.ts.snap | 18 +- .../query_bar_input.test.tsx.snap | 108 ++++++++--- .../public/chrome/api/base_path.test.mocks.ts | 2 +- .../ui/public/chrome/api/base_path.test.ts | 22 +-- x-pack/.i18nrc.json | 2 +- x-pack/dev-tools/jest/setup/polyfills.js | 1 + .../lens/public/app_plugin/app.test.tsx | 5 +- .../change_password_form.tsx | 2 +- .../public/hacks/on_session_timeout.js | 56 +----- .../server/routes/api/v1/__tests__/users.js | 6 +- .../security/server/routes/api/v1/users.js | 2 +- x-pack/package.json | 1 + x-pack/plugins/security/kibana.json | 2 +- x-pack/plugins/security/public/index.ts | 11 ++ x-pack/plugins/security/public/plugin.ts | 43 +++++ .../plugins/security/public/session/index.ts | 10 + .../public/session/session_expired.mock.ts} | 8 +- .../public/session/session_expired.test.ts | 46 +++++ .../public/session/session_expired.ts | 24 +++ .../public/session/session_timeout.mock.ts | 13 ++ .../public/session/session_timeout.test.tsx | 171 ++++++++++++++++++ .../public/session/session_timeout.tsx | 79 ++++++++ .../session_timeout_http_interceptor.test.ts | 120 ++++++++++++ .../session_timeout_http_interceptor.ts | 50 +++++ .../session/session_timeout_warning.test.tsx} | 6 +- .../session/session_timeout_warning.tsx} | 6 +- ...thorized_response_http_interceptor.test.ts | 89 +++++++++ .../unauthorized_response_http_interceptor.ts | 42 +++++ .../translations/translations/ja-JP.json | 8 +- .../translations/translations/zh-CN.json | 8 +- 45 files changed, 1126 insertions(+), 143 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.md create mode 100644 docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md create mode 100644 src/core/public/http/anonymous_paths.test.ts create mode 100644 src/core/public/http/anonymous_paths.ts create mode 100644 x-pack/plugins/security/public/index.ts create mode 100644 x-pack/plugins/security/public/plugin.ts create mode 100644 x-pack/plugins/security/public/session/index.ts rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/index.ts => plugins/security/public/session/session_expired.mock.ts} (58%) create mode 100644 x-pack/plugins/security/public/session/session_expired.test.ts create mode 100644 x-pack/plugins/security/public/session/session_expired.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout.mock.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout.test.tsx create mode 100644 x-pack/plugins/security/public/session/session_timeout.tsx create mode 100644 x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts create mode 100644 x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx => plugins/security/public/session/session_timeout_warning.test.tsx} (74%) rename x-pack/{legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx => plugins/security/public/session/session_timeout_warning.tsx} (81%) create mode 100644 x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts create mode 100644 x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md new file mode 100644 index 0000000000000..e94757c5eb031 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.anonymouspaths.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) > [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md) + +## HttpServiceBase.anonymousPaths property + +APIs for denoting certain paths for not requiring authentication + +Signature: + +```typescript +anonymousPaths: IAnonymousPaths; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpservicebase.md b/docs/development/core/public/kibana-plugin-public.httpservicebase.md index a1eef3db42b7d..9ea77c95b343e 100644 --- a/docs/development/core/public/kibana-plugin-public.httpservicebase.md +++ b/docs/development/core/public/kibana-plugin-public.httpservicebase.md @@ -15,6 +15,7 @@ export interface HttpServiceBase | Property | Type | Description | | --- | --- | --- | +| [anonymousPaths](./kibana-plugin-public.httpservicebase.anonymouspaths.md) | IAnonymousPaths | APIs for denoting certain paths for not requiring authentication | | [basePath](./kibana-plugin-public.httpservicebase.basepath.md) | IBasePath | APIs for manipulating the basePath on URL segments. | | [delete](./kibana-plugin-public.httpservicebase.delete.md) | HttpHandler | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. | | [fetch](./kibana-plugin-public.httpservicebase.fetch.md) | HttpHandler | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-public.httphandler.md) for options. | diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md new file mode 100644 index 0000000000000..92a87668b6ef0 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.isanonymous.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) > [isAnonymous](./kibana-plugin-public.ianonymouspaths.isanonymous.md) + +## IAnonymousPaths.isAnonymous() method + +Determines whether the provided path doesn't require authentication + +Signature: + +```typescript +isAnonymous(path: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| path | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md new file mode 100644 index 0000000000000..3e5caf49695c2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) + +## IAnonymousPaths interface + +APIs for denoting paths as not requiring authentication + +Signature: + +```typescript +export interface IAnonymousPaths +``` + +## Methods + +| Method | Description | +| --- | --- | +| [isAnonymous(path)](./kibana-plugin-public.ianonymouspaths.isanonymous.md) | Determines whether the provided path doesn't require authentication | +| [register(path)](./kibana-plugin-public.ianonymouspaths.register.md) | Register path as not requiring authentication | + diff --git a/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md new file mode 100644 index 0000000000000..88c615da05155 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.ianonymouspaths.register.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) > [register](./kibana-plugin-public.ianonymouspaths.register.md) + +## IAnonymousPaths.register() method + +Register `path` as not requiring authentication + +Signature: + +```typescript +register(path: string): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| path | string | | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index e787621c3aaf9..57ab8bedde95e 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -57,6 +57,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpResponse](./kibana-plugin-public.httpresponse.md) | | | [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | | | [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | | [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | diff --git a/src/core/public/http/anonymous_paths.test.ts b/src/core/public/http/anonymous_paths.test.ts new file mode 100644 index 0000000000000..bf9212f625f1e --- /dev/null +++ b/src/core/public/http/anonymous_paths.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { AnonymousPaths } from './anonymous_paths'; +import { BasePath } from './base_path_service'; + +describe('#register', () => { + it(`allows paths that don't start with /`, () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('bar'); + }); + + it(`allows paths that end with '/'`, () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar/'); + }); +}); + +describe('#isAnonymous', () => { + it('returns true for registered paths', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true); + }); + + it('returns true for paths registered with a trailing slash, but call "isAnonymous" with no trailing slash', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar/'); + expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true); + }); + + it('returns true for paths registered without a trailing slash, but call "isAnonymous" with a trailing slash', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/foo/bar/')).toBe(true); + }); + + it('returns true for paths registered without a starting slash', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('bar'); + expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true); + }); + + it('returns true for paths registered with a starting slash', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true); + }); + + it('when there is no basePath and calling "isAnonymous" without a starting slash, returns true for paths registered with a starting slash', () => { + const basePath = new BasePath('/'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('bar')).toBe(true); + }); + + it('when there is no basePath and calling "isAnonymous" with a starting slash, returns true for paths registered with a starting slash', () => { + const basePath = new BasePath('/'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/bar')).toBe(true); + }); + + it('returns true for paths whose capitalization is different', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/BAR'); + expect(anonymousPaths.isAnonymous('/foo/bar')).toBe(true); + }); + + it('returns false for other paths', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/foo/foo')).toBe(false); + }); + + it('returns false for sub-paths of registered paths', () => { + const basePath = new BasePath('/foo'); + const anonymousPaths = new AnonymousPaths(basePath); + anonymousPaths.register('/bar'); + expect(anonymousPaths.isAnonymous('/foo/bar/baz')).toBe(false); + }); +}); diff --git a/src/core/public/http/anonymous_paths.ts b/src/core/public/http/anonymous_paths.ts new file mode 100644 index 0000000000000..300c4d64df353 --- /dev/null +++ b/src/core/public/http/anonymous_paths.ts @@ -0,0 +1,53 @@ +/* + * 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 { IAnonymousPaths, IBasePath } from 'src/core/public'; + +export class AnonymousPaths implements IAnonymousPaths { + private readonly paths = new Set(); + + constructor(private basePath: IBasePath) {} + + public isAnonymous(path: string): boolean { + const pathWithoutBasePath = this.basePath.remove(path); + return this.paths.has(this.normalizePath(pathWithoutBasePath)); + } + + public register(path: string) { + this.paths.add(this.normalizePath(path)); + } + + private normalizePath(path: string) { + // always lower-case it + let normalized = path.toLowerCase(); + + // remove the slash from the end + if (normalized.endsWith('/')) { + normalized = normalized.slice(0, normalized.length - 1); + } + + // put a slash at the start + if (!normalized.startsWith('/')) { + normalized = `/${normalized}`; + } + + // it's normalized!!! + return normalized; + } +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index a94543414acfa..52f188c7b20a0 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -20,9 +20,11 @@ import { HttpService } from './http_service'; import { HttpSetup } from './types'; import { BehaviorSubject } from 'rxjs'; +import { BasePath } from './base_path_service'; +import { AnonymousPaths } from './anonymous_paths'; type ServiceSetupMockType = jest.Mocked & { - basePath: jest.Mocked; + basePath: BasePath; }; const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({ @@ -34,11 +36,8 @@ const createServiceMock = ({ basePath = '' } = {}): ServiceSetupMockType => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: { - get: jest.fn(() => basePath), - prepend: jest.fn(path => `${basePath}${path}`), - remove: jest.fn(), - }, + basePath: new BasePath(basePath), + anonymousPaths: new AnonymousPaths(new BasePath(basePath)), addLoadingCount: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), stop: jest.fn(), diff --git a/src/core/public/http/http_setup.ts b/src/core/public/http/http_setup.ts index a10358926de1f..602382e3a5a60 100644 --- a/src/core/public/http/http_setup.ts +++ b/src/core/public/http/http_setup.ts @@ -36,6 +36,7 @@ import { HttpInterceptController } from './http_intercept_controller'; import { HttpFetchError } from './http_fetch_error'; import { HttpInterceptHaltError } from './http_intercept_halt_error'; import { BasePath } from './base_path_service'; +import { AnonymousPaths } from './anonymous_paths'; const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; @@ -57,6 +58,7 @@ export const setup = ( const interceptors = new Set(); const kibanaVersion = injectedMetadata.getKibanaVersion(); const basePath = new BasePath(injectedMetadata.getBasePath()); + const anonymousPaths = new AnonymousPaths(basePath); function intercept(interceptor: HttpInterceptor) { interceptors.add(interceptor); @@ -318,6 +320,7 @@ export const setup = ( return { stop, basePath, + anonymousPaths, intercept, removeAllInterceptors, fetch, diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 96500d566b3e5..870d4af8f9e86 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -29,6 +29,11 @@ export interface HttpServiceBase { */ basePath: IBasePath; + /** + * APIs for denoting certain paths for not requiring authentication + */ + anonymousPaths: IAnonymousPaths; + /** * Adds a new {@link HttpInterceptor} to the global HTTP client. * @param interceptor a {@link HttpInterceptor} @@ -92,6 +97,21 @@ export interface IBasePath { remove: (url: string) => string; } +/** + * APIs for denoting paths as not requiring authentication + */ +export interface IAnonymousPaths { + /** + * Determines whether the provided path doesn't require authentication. `path` should include the current basePath. + */ + isAnonymous(path: string): boolean; + + /** + * Register `path` as not requiring authentication. `path` should not include the current basePath. + */ + register(path: string): void; +} + /** * See {@link HttpServiceBase} * @public diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 3d8714a001158..24201ff0253cb 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -110,6 +110,7 @@ export { HttpHandler, HttpBody, IBasePath, + IAnonymousPaths, IHttpInterceptController, IHttpFetchError, InterceptedHttpResponse, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8345980b6869d..b9cd2577c2217 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -18,7 +18,7 @@ */ import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; -import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext } from '.'; +import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext, NotificationsSetup } from '.'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -41,12 +41,12 @@ export { notificationServiceMock } from './notifications/notifications_service.m export { overlayServiceMock } from './overlays/overlay_service.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; -function createCoreSetupMock() { - const mock: MockedKeys = { +function createCoreSetupMock({ basePath = '' } = {}) { + const mock: MockedKeys & { notifications: MockedKeys } = { application: applicationServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), - http: httpServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract({ basePath }), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), injectedMetadata: { @@ -57,12 +57,12 @@ function createCoreSetupMock() { return mock; } -function createCoreStartMock() { - const mock: MockedKeys = { +function createCoreStartMock({ basePath = '' } = {}) { + const mock: MockedKeys & { notifications: MockedKeys } = { application: applicationServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), - http: httpServiceMock.createStartContract(), + http: httpServiceMock.createStartContract({ basePath }), i18n: i18nServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), overlays: overlayServiceMock.createStartContract(), diff --git a/src/core/public/notifications/notifications_service.mock.ts b/src/core/public/notifications/notifications_service.mock.ts index 8f4a1d5bcd400..464f47e20aa3b 100644 --- a/src/core/public/notifications/notifications_service.mock.ts +++ b/src/core/public/notifications/notifications_service.mock.ts @@ -23,10 +23,8 @@ import { } from './notifications_service'; import { toastsServiceMock } from './toasts/toasts_service.mock'; -type DeeplyMocked = { [P in keyof T]: jest.Mocked }; - const createSetupContractMock = () => { - const setupContract: DeeplyMocked = { + const setupContract: MockedKeys = { // we have to suppress type errors until decide how to mock es6 class toasts: toastsServiceMock.createSetupContract(), }; @@ -34,7 +32,7 @@ const createSetupContractMock = () => { }; const createStartContractMock = () => { - const startContract: DeeplyMocked = { + const startContract: MockedKeys = { // we have to suppress type errors until decide how to mock es6 class toasts: toastsServiceMock.createStartContract(), }; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index ec8a22fe5953c..11a1b5c0d1d9b 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -488,6 +488,7 @@ export interface HttpResponse extends InterceptedHttpResponse { // @public (undocumented) export interface HttpServiceBase { addLoadingCount(countSource$: Observable): void; + anonymousPaths: IAnonymousPaths; basePath: IBasePath; delete: HttpHandler; fetch: HttpHandler; @@ -517,6 +518,14 @@ export interface I18nStart { }) => JSX.Element; } +// Warning: (ae-missing-release-tag) "IAnonymousPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface IAnonymousPaths { + isAnonymous(path: string): boolean; + register(path: string): void; +} + // @public export interface IBasePath { get: () => string; diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap index f607c924a9e68..84f9a5ab7c5cd 100644 --- a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap @@ -20,10 +20,20 @@ exports[`#setup constructs UiSettingsClient and UiSettingsApi: UiSettingsApi arg }, ], }, - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap index 06f9e6081e522..f59afc7165bab 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap @@ -229,10 +229,20 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], @@ -775,10 +785,20 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1309,10 +1329,20 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1 }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1852,10 +1882,20 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1 }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2386,10 +2426,20 @@ exports[`QueryBarInput Should render the given query 1`] = ` }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2929,10 +2979,20 @@ exports[`QueryBarInput Should render the given query 1`] = ` }, "http": Object { "addLoadingCount": [MockFunction], - "basePath": Object { - "get": [MockFunction], - "prepend": [MockFunction], - "remove": [MockFunction], + "anonymousPaths": AnonymousPaths { + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], + }, + "paths": Set {}, + }, + "basePath": BasePath { + "basePath": "", + "get": [Function], + "prepend": [Function], + "remove": [Function], }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts b/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts index c362b1709fba6..f2c5fd5734b10 100644 --- a/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts +++ b/src/legacy/ui/public/chrome/api/base_path.test.mocks.ts @@ -19,7 +19,7 @@ import { httpServiceMock } from '../../../../../core/public/mocks'; -export const newPlatformHttp = httpServiceMock.createSetupContract(); +const newPlatformHttp = httpServiceMock.createSetupContract({ basePath: 'npBasePath' }); jest.doMock('ui/new_platform', () => ({ npSetup: { core: { http: newPlatformHttp }, diff --git a/src/legacy/ui/public/chrome/api/base_path.test.ts b/src/legacy/ui/public/chrome/api/base_path.test.ts index 812635ba36483..d3cfc6a3168a8 100644 --- a/src/legacy/ui/public/chrome/api/base_path.test.ts +++ b/src/legacy/ui/public/chrome/api/base_path.test.ts @@ -16,8 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - -import { newPlatformHttp } from './base_path.test.mocks'; +import './base_path.test.mocks'; import { initChromeBasePathApi } from './base_path'; function initChrome() { @@ -26,10 +25,6 @@ function initChrome() { return chrome; } -newPlatformHttp.basePath.get.mockImplementation(() => 'gotBasePath'); -newPlatformHttp.basePath.prepend.mockImplementation(() => 'addedToPath'); -newPlatformHttp.basePath.remove.mockImplementation(() => 'removedFromPath'); - beforeEach(() => { jest.clearAllMocks(); }); @@ -37,29 +32,20 @@ beforeEach(() => { describe('#getBasePath()', () => { it('proxies to newPlatformHttp.basePath.get()', () => { const chrome = initChrome(); - expect(newPlatformHttp.basePath.prepend).not.toHaveBeenCalled(); - expect(chrome.getBasePath()).toBe('gotBasePath'); - expect(newPlatformHttp.basePath.get).toHaveBeenCalledTimes(1); - expect(newPlatformHttp.basePath.get).toHaveBeenCalledWith(); + expect(chrome.getBasePath()).toBe('npBasePath'); }); }); describe('#addBasePath()', () => { it('proxies to newPlatformHttp.basePath.prepend(path)', () => { const chrome = initChrome(); - expect(newPlatformHttp.basePath.prepend).not.toHaveBeenCalled(); - expect(chrome.addBasePath('foo/bar')).toBe('addedToPath'); - expect(newPlatformHttp.basePath.prepend).toHaveBeenCalledTimes(1); - expect(newPlatformHttp.basePath.prepend).toHaveBeenCalledWith('foo/bar'); + expect(chrome.addBasePath('/foo/bar')).toBe('npBasePath/foo/bar'); }); }); describe('#removeBasePath', () => { it('proxies to newPlatformBasePath.basePath.remove(path)', () => { const chrome = initChrome(); - expect(newPlatformHttp.basePath.remove).not.toHaveBeenCalled(); - expect(chrome.removeBasePath('foo/bar')).toBe('removedFromPath'); - expect(newPlatformHttp.basePath.remove).toHaveBeenCalledTimes(1); - expect(newPlatformHttp.basePath.remove).toHaveBeenCalledWith('foo/bar'); + expect(chrome.removeBasePath('npBasePath/foo/bar')).toBe('/foo/bar'); }); }); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 735ee0b6b67b5..79a482c470367 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -31,7 +31,7 @@ "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "legacy/plugins/searchprofiler", "xpack.siem": "legacy/plugins/siem", - "xpack.security": "legacy/plugins/security", + "xpack.security": ["legacy/plugins/security", "plugins/security"], "xpack.server": "legacy/server", "xpack.snapshotRestore": "legacy/plugins/snapshot_restore", "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index 8e5c5a8025b82..566e4701eeaac 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -14,5 +14,6 @@ bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, f const MutationObserver = require('mutation-observer'); Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); +require('whatwg-fetch'); const URL = { createObjectURL: () => '' }; Object.defineProperty(window, 'URL', { value: URL }); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index 77d0d5a5305aa..0e3e6b0381309 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -127,7 +127,7 @@ describe('Lens App', () => { beforeEach(() => { frame = createMockFrame(); - core = coreMock.createStart(); + core = coreMock.createStart({ basePath: '/testbasepath' }); core.uiSettings.get.mockImplementation( jest.fn(type => { @@ -140,9 +140,6 @@ describe('Lens App', () => { } }) ); - - (core.http.basePath.get as jest.Mock).mockReturnValue(`/testbasepath`); - (core.http.basePath.prepend as jest.Mock).mockImplementation(s => `/testbasepath${s}`); }); it('renders the editor frame', () => { diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx index 9521cbdc58a78..61c0b77decd56 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx @@ -314,7 +314,7 @@ export class ChangePasswordForm extends Component { }; private handleChangePasswordFailure = (error: Record) => { - if (error.body && error.body.statusCode === 401) { + if (error.body && error.body.statusCode === 403) { this.setState({ currentPasswordError: true }); } else { toastNotifications.addDanger( diff --git a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js b/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js index 0f1787820a2f2..81b14ee7d8bf4 100644 --- a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js +++ b/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js @@ -4,15 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; import { uiModules } from 'ui/modules'; import { isSystemApiRequest } from 'ui/system_api'; import { Path } from 'plugins/xpack_main/services/path'; -import { toastNotifications } from 'ui/notify'; -import 'plugins/security/services/auto_logout'; -import { SessionExpirationWarning } from '../components/session_expiration_warning'; +import { npSetup } from 'ui/new_platform'; /** * Client session timeout is decreased by this number so that Kibana server @@ -20,65 +16,19 @@ import { SessionExpirationWarning } from '../components/session_expiration_warni * user session up (invalidate access tokens, redirect to logout portal etc.). * @type {number} */ -const SESSION_TIMEOUT_GRACE_PERIOD_MS = 5000; const module = uiModules.get('security', []); module.config(($httpProvider) => { $httpProvider.interceptors.push(( - $timeout, $q, - $injector, - sessionTimeout, - Private, - autoLogout ) => { - function refreshSession() { - // Make a simple request to keep the session alive - $injector.get('es').ping(); - clearNotifications(); - } - const isUnauthenticated = Path.isUnauthenticated(); - const notificationLifetime = 60 * 1000; - const notificationOptions = { - color: 'warning', - text: ( - - ), - title: i18n.translate('xpack.security.hacks.warningTitle', { - defaultMessage: 'Warning' - }), - toastLifeTimeMs: Math.min( - (sessionTimeout - SESSION_TIMEOUT_GRACE_PERIOD_MS), - notificationLifetime - ), - }; - - let pendingNotification; - let activeNotification; - let pendingSessionExpiration; - - function clearNotifications() { - if (pendingNotification) $timeout.cancel(pendingNotification); - if (pendingSessionExpiration) clearTimeout(pendingSessionExpiration); - if (activeNotification) toastNotifications.remove(activeNotification); - } - - function scheduleNotification() { - pendingNotification = $timeout(showNotification, Math.max(sessionTimeout - notificationLifetime, 0)); - } - - function showNotification() { - activeNotification = toastNotifications.add(notificationOptions); - pendingSessionExpiration = setTimeout(() => autoLogout(), notificationOptions.toastLifeTimeMs); - } function interceptorFactory(responseHandler) { return function interceptor(response) { - if (!isUnauthenticated && !isSystemApiRequest(response.config) && sessionTimeout !== null) { - clearNotifications(); - scheduleNotification(); + if (!isUnauthenticated && !isSystemApiRequest(response.config)) { + npSetup.plugins.security.sessionTimeout.extend(); } return responseHandler(response); }; diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js index 8444efe8790e7..83dfa778f1b50 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js @@ -81,7 +81,7 @@ describe('User routes', () => { .resolves(AuthenticationResult.succeeded({})); }); - it('returns 401 if old password is wrong.', async () => { + it('returns 403 if old password is wrong.', async () => { loginStub.resolves(AuthenticationResult.failed(new Error('Something went wrong.'))); const response = await changePasswordRoute.handler(request); @@ -89,13 +89,13 @@ describe('User routes', () => { sinon.assert.notCalled(clusterStub.callWithRequest); expect(response.isBoom).to.be(true); expect(response.output.payload).to.eql({ - statusCode: 401, + statusCode: 403, error: 'Unauthorized', message: 'Something went wrong.' }); }); - it('returns 401 if user can authenticate with new password.', async () => { + it(`returns 401 if user can't authenticate with new password.`, async () => { loginStub .withArgs( sinon.match.instanceOf(KibanaRequest), diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 9cb2ad799a211..1d47dc8875348 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -108,7 +108,7 @@ export function initUsersApi({ authc: { login }, config }, server) { return Boom.unauthorized(authenticationResult.error); } } catch(err) { - return Boom.unauthorized(err); + throw Boom.forbidden(err); } } diff --git a/x-pack/package.json b/x-pack/package.json index c8f7345450aa6..fa439a2087547 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -182,6 +182,7 @@ "ts-loader": "^6.0.4", "typescript": "3.5.3", "vinyl-fs": "^3.0.3", + "whatwg-fetch": "^3.0.0", "xml-crypto": "^1.4.0", "yargs": "4.8.1" }, diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 7ac9d654eb07e..9f243a7dfb2fc 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "security"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts new file mode 100644 index 0000000000000..12a3092039d0d --- /dev/null +++ b/x-pack/plugins/security/public/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { PluginInitializer } from 'src/core/public'; +import { SecurityPlugin, SecurityPluginSetup, SecurityPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new SecurityPlugin(); diff --git a/x-pack/plugins/security/public/plugin.ts b/x-pack/plugins/security/public/plugin.ts new file mode 100644 index 0000000000000..55d125bf993ec --- /dev/null +++ b/x-pack/plugins/security/public/plugin.ts @@ -0,0 +1,43 @@ +/* + * 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 { Plugin, CoreSetup } from 'src/core/public'; +import { + SessionExpired, + SessionTimeout, + SessionTimeoutHttpInterceptor, + UnauthorizedResponseHttpInterceptor, +} from './session'; + +export class SecurityPlugin implements Plugin { + public setup(core: CoreSetup) { + const { http, notifications, injectedMetadata } = core; + const { basePath, anonymousPaths } = http; + anonymousPaths.register('/login'); + anonymousPaths.register('/logout'); + anonymousPaths.register('/logged_out'); + + const sessionExpired = new SessionExpired(basePath); + http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths)); + const sessionTimeout = new SessionTimeout( + injectedMetadata.getInjectedVar('sessionTimeout', null) as number | null, + notifications, + sessionExpired, + http + ); + http.intercept(new SessionTimeoutHttpInterceptor(sessionTimeout, anonymousPaths)); + + return { + anonymousPaths, + sessionTimeout, + }; + } + + public start() {} +} + +export type SecurityPluginSetup = ReturnType; +export type SecurityPluginStart = ReturnType; diff --git a/x-pack/plugins/security/public/session/index.ts b/x-pack/plugins/security/public/session/index.ts new file mode 100644 index 0000000000000..253207dc1b717 --- /dev/null +++ b/x-pack/plugins/security/public/session/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SessionExpired } from './session_expired'; +export { SessionTimeout } from './session_timeout'; +export { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor'; +export { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor'; diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts b/x-pack/plugins/security/public/session/session_expired.mock.ts similarity index 58% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts rename to x-pack/plugins/security/public/session/session_expired.mock.ts index 0aa0c1f9fb079..e894caafd9594 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/index.ts +++ b/x-pack/plugins/security/public/session/session_expired.mock.ts @@ -4,4 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SessionExpirationWarning } from './session_expiration_warning'; +import { ISessionExpired } from './session_expired'; + +export function createSessionExpiredMock() { + return { + logout: jest.fn(), + } as jest.Mocked; +} diff --git a/x-pack/plugins/security/public/session/session_expired.test.ts b/x-pack/plugins/security/public/session/session_expired.test.ts new file mode 100644 index 0000000000000..9c0e4cd8036cc --- /dev/null +++ b/x-pack/plugins/security/public/session/session_expired.test.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 { coreMock } from 'src/core/public/mocks'; +import { SessionExpired } from './session_expired'; + +const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url); + +it('redirects user to "/logout" when there is no basePath', async () => { + const { basePath } = coreMock.createSetup().http; + mockCurrentUrl('/foo/bar?baz=quz#quuz'); + const sessionExpired = new SessionExpired(basePath); + const newUrlPromise = new Promise(resolve => { + jest.spyOn(window.location, 'assign').mockImplementation(url => { + resolve(url); + }); + }); + + sessionExpired.logout(); + + const url = await newUrlPromise; + expect(url).toBe( + `/logout?next=${encodeURIComponent('/foo/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED` + ); +}); + +it('redirects user to "/${basePath}/logout" and removes basePath from next parameter when there is a basePath', async () => { + const { basePath } = coreMock.createSetup({ basePath: '/foo' }).http; + mockCurrentUrl('/foo/bar?baz=quz#quuz'); + const sessionExpired = new SessionExpired(basePath); + const newUrlPromise = new Promise(resolve => { + jest.spyOn(window.location, 'assign').mockImplementation(url => { + resolve(url); + }); + }); + + sessionExpired.logout(); + + const url = await newUrlPromise; + expect(url).toBe( + `/foo/logout?next=${encodeURIComponent('/bar?baz=quz#quuz')}&msg=SESSION_EXPIRED` + ); +}); diff --git a/x-pack/plugins/security/public/session/session_expired.ts b/x-pack/plugins/security/public/session/session_expired.ts new file mode 100644 index 0000000000000..3ef15088bb288 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_expired.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; + +export interface ISessionExpired { + logout(): void; +} + +export class SessionExpired { + constructor(private basePath: HttpSetup['basePath']) {} + + logout() { + const next = this.basePath.remove( + `${window.location.pathname}${window.location.search}${window.location.hash}` + ); + window.location.assign( + this.basePath.prepend(`/logout?next=${encodeURIComponent(next)}&msg=SESSION_EXPIRED`) + ); + } +} diff --git a/x-pack/plugins/security/public/session/session_timeout.mock.ts b/x-pack/plugins/security/public/session/session_timeout.mock.ts new file mode 100644 index 0000000000000..9917a50279083 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.mock.ts @@ -0,0 +1,13 @@ +/* + * 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 { ISessionTimeout } from './session_timeout'; + +export function createSessionTimeoutMock() { + return { + extend: jest.fn(), + } as jest.Mocked; +} diff --git a/x-pack/plugins/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx new file mode 100644 index 0000000000000..776247dda94e6 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -0,0 +1,171 @@ +/* + * 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 { coreMock } from 'src/core/public/mocks'; +import { SessionTimeout } from './session_timeout'; +import { createSessionExpiredMock } from './session_expired.mock'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +jest.useFakeTimers(); + +const expectNoWarningToast = ( + notifications: ReturnType['notifications'] +) => { + expect(notifications.toasts.add).not.toHaveBeenCalled(); +}; + +const expectWarningToast = ( + notifications: ReturnType['notifications'], + toastLifeTimeMS: number = 60000 +) => { + expect(notifications.toasts.add).toHaveBeenCalledTimes(1); + expect(notifications.toasts.add.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "color": "warning", + "text": , + "title": "Warning", + "toastLifeTimeMs": ${toastLifeTimeMS}, + }, + ] + `); +}; + +const expectWarningToastHidden = ( + notifications: ReturnType['notifications'], + toast: symbol +) => { + expect(notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(notifications.toasts.remove).toHaveBeenCalledWith(toast); +}; + +describe('warning toast', () => { + test(`shows session expiration warning toast`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires + jest.advanceTimersByTime(55 * 1000); + expectWarningToast(notifications); + }); + + test(`extend delays the warning toast`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + jest.advanceTimersByTime(54 * 1000); + expectNoWarningToast(notifications); + + sessionTimeout.extend(); + jest.advanceTimersByTime(54 * 1000); + expectNoWarningToast(notifications); + + jest.advanceTimersByTime(1 * 1000); + + expectWarningToast(notifications); + }); + + test(`extend hides displayed warning toast`, () => { + const { notifications, http } = coreMock.createSetup(); + const toast = Symbol(); + notifications.toasts.add.mockReturnValue(toast as any); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires + jest.advanceTimersByTime(55 * 1000); + expectWarningToast(notifications); + + sessionTimeout.extend(); + expectWarningToastHidden(notifications, toast); + }); + + test('clicking "extend" causes a new HTTP request (which implicitly extends the session)', () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + // we display the warning a minute before we expire the the session, which is 5 seconds before it actually expires + jest.advanceTimersByTime(55 * 1000); + expectWarningToast(notifications); + + expect(http.get).not.toHaveBeenCalled(); + const toastInput = notifications.toasts.add.mock.calls[0][0]; + expect(toastInput).toHaveProperty('text'); + const reactComponent = (toastInput as any).text; + const wrapper = mountWithIntl(reactComponent); + wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click'); + expect(http.get).toHaveBeenCalled(); + }); + + test('when the session timeout is shorter than 65 seconds, display the warning immediately and for a shorter duration', () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(64 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + jest.advanceTimersByTime(0); + expectWarningToast(notifications, 59 * 1000); + }); +}); + +describe('session expiration', () => { + test(`expires the session 5 seconds before it really expires`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + jest.advanceTimersByTime(114 * 1000); + expect(sessionExpired.logout).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1 * 1000); + expect(sessionExpired.logout).toHaveBeenCalled(); + }); + + test(`extend delays the expiration`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(2 * 60 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + jest.advanceTimersByTime(114 * 1000); + + sessionTimeout.extend(); + jest.advanceTimersByTime(114 * 1000); + expect(sessionExpired.logout).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1 * 1000); + expect(sessionExpired.logout).toHaveBeenCalled(); + }); + + test(`if the session timeout is shorter than 5 seconds, expire session immediately`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(4 * 1000, notifications, sessionExpired, http); + + sessionTimeout.extend(); + jest.advanceTimersByTime(0); + expect(sessionExpired.logout).toHaveBeenCalled(); + }); + + test(`'null' sessionTimeout never logs you out`, () => { + const { notifications, http } = coreMock.createSetup(); + const sessionExpired = createSessionExpiredMock(); + const sessionTimeout = new SessionTimeout(null, notifications, sessionExpired, http); + sessionTimeout.extend(); + jest.advanceTimersByTime(Number.MAX_VALUE); + expect(sessionExpired.logout).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx new file mode 100644 index 0000000000000..db4926e7f04ea --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -0,0 +1,79 @@ +/* + * 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 { NotificationsSetup, Toast, HttpSetup } from 'src/core/public'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SessionTimeoutWarning } from './session_timeout_warning'; +import { ISessionExpired } from './session_expired'; + +/** + * Client session timeout is decreased by this number so that Kibana server + * can still access session content during logout request to properly clean + * user session up (invalidate access tokens, redirect to logout portal etc.). + */ +const GRACE_PERIOD_MS = 5 * 1000; + +/** + * Duration we'll normally display the warning toast + */ +const WARNING_MS = 60 * 1000; + +export interface ISessionTimeout { + extend(): void; +} + +export class SessionTimeout { + private warningTimeoutMilliseconds?: number; + private expirationTimeoutMilliseconds?: number; + private warningToast?: Toast; + + constructor( + private sessionTimeoutMilliseconds: number | null, + private notifications: NotificationsSetup, + private sessionExpired: ISessionExpired, + private http: HttpSetup + ) {} + + extend() { + if (this.sessionTimeoutMilliseconds == null) { + return; + } + + if (this.warningTimeoutMilliseconds) { + window.clearTimeout(this.warningTimeoutMilliseconds); + } + if (this.expirationTimeoutMilliseconds) { + window.clearTimeout(this.expirationTimeoutMilliseconds); + } + if (this.warningToast) { + this.notifications.toasts.remove(this.warningToast); + } + this.warningTimeoutMilliseconds = window.setTimeout( + () => this.showWarning(), + Math.max(this.sessionTimeoutMilliseconds - WARNING_MS - GRACE_PERIOD_MS, 0) + ); + this.expirationTimeoutMilliseconds = window.setTimeout( + () => this.sessionExpired.logout(), + Math.max(this.sessionTimeoutMilliseconds - GRACE_PERIOD_MS, 0) + ); + } + + private showWarning = () => { + this.warningToast = this.notifications.toasts.add({ + color: 'warning', + text: , + title: i18n.translate('xpack.security.components.sessionTimeoutWarning.title', { + defaultMessage: 'Warning', + }), + toastLifeTimeMs: Math.min(this.sessionTimeoutMilliseconds! - GRACE_PERIOD_MS, WARNING_MS), + }); + }; + + private refreshSession = () => { + this.http.get('/api/security/v1/me'); + }; +} diff --git a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts new file mode 100644 index 0000000000000..ffbd625590b15 --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.test.ts @@ -0,0 +1,120 @@ +/* + * 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. + */ + +// @ts-ignore +import fetchMock from 'fetch-mock/es5/client'; +import { SessionTimeoutHttpInterceptor } from './session_timeout_http_interceptor'; +import { setup } from '../../../../../src/test_utils/public/http_test_setup'; +import { createSessionTimeoutMock } from './session_timeout.mock'; + +const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url); + +const setupHttp = (basePath: string) => { + const { http } = setup(injectedMetadata => { + injectedMetadata.getBasePath.mockReturnValue(basePath); + }); + return http; +}; + +afterEach(() => { + fetchMock.restore(); +}); + +describe('response', () => { + test('extends session timeouts', async () => { + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 200); + + await http.fetch('/foo-api'); + + expect(sessionTimeoutMock.extend).toHaveBeenCalled(); + }); + + test(`doesn't extend session timeouts for anonymous paths`, async () => { + mockCurrentUrl('/foo/bar'); + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const { anonymousPaths } = http; + anonymousPaths.register('/bar'); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 200); + + await http.fetch('/foo-api'); + + expect(sessionTimeoutMock.extend).not.toHaveBeenCalled(); + }); + + test(`doesn't extend session timeouts for system api requests`, async () => { + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 200); + + await http.fetch('/foo-api', { headers: { 'kbn-system-api': 'true' } }); + + expect(sessionTimeoutMock.extend).not.toHaveBeenCalled(); + }); +}); + +describe('responseError', () => { + test('extends session timeouts', async () => { + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + + expect(sessionTimeoutMock.extend).toHaveBeenCalled(); + }); + + test(`doesn't extend session timeouts for anonymous paths`, async () => { + mockCurrentUrl('/foo/bar'); + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const { anonymousPaths } = http; + anonymousPaths.register('/bar'); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + + expect(sessionTimeoutMock.extend).not.toHaveBeenCalled(); + }); + + test(`doesn't extend session timeouts for system api requests`, async () => { + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + await expect( + http.fetch('/foo-api', { headers: { 'kbn-system-api': 'true' } }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + + expect(sessionTimeoutMock.extend).not.toHaveBeenCalled(); + }); + + test(`doesn't extend session timeouts when there is no response`, async () => { + const http = setupHttp('/foo'); + const sessionTimeoutMock = createSessionTimeoutMock(); + const interceptor = new SessionTimeoutHttpInterceptor(sessionTimeoutMock, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', new Promise((resolve, reject) => reject(new Error('Network is down')))); + + await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Network is down]`); + + expect(sessionTimeoutMock.extend).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts new file mode 100644 index 0000000000000..98516cb4a613b --- /dev/null +++ b/x-pack/plugins/security/public/session/session_timeout_http_interceptor.ts @@ -0,0 +1,50 @@ +/* + * 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 { HttpInterceptor, HttpErrorResponse, HttpResponse, IAnonymousPaths } from 'src/core/public'; + +import { ISessionTimeout } from './session_timeout'; + +const isSystemAPIRequest = (request: Request) => { + return request.headers.has('kbn-system-api'); +}; + +export class SessionTimeoutHttpInterceptor implements HttpInterceptor { + constructor(private sessionTimeout: ISessionTimeout, private anonymousPaths: IAnonymousPaths) {} + + response(httpResponse: HttpResponse) { + if (this.anonymousPaths.isAnonymous(window.location.pathname)) { + return; + } + + if (isSystemAPIRequest(httpResponse.request)) { + return; + } + + this.sessionTimeout.extend(); + } + + responseError(httpErrorResponse: HttpErrorResponse) { + if (this.anonymousPaths.isAnonymous(window.location.pathname)) { + return; + } + + if (isSystemAPIRequest(httpErrorResponse.request)) { + return; + } + + // if we happen to not have a response, for example if there is no + // network connectivity, we won't extend the session because there + // won't be a response with a set-cookie header, which is required + // to extend the session + const { response } = httpErrorResponse; + if (!response) { + return; + } + + this.sessionTimeout.extend(); + } +} diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx b/x-pack/plugins/security/public/session/session_timeout_warning.test.tsx similarity index 74% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx rename to x-pack/plugins/security/public/session/session_timeout_warning.test.tsx index abc5a970eec9a..a52e7ce4e94b5 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout_warning.test.tsx @@ -5,12 +5,12 @@ */ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { SessionExpirationWarning } from './session_expiration_warning'; +import { SessionTimeoutWarning } from './session_timeout_warning'; -describe('SessionExpirationWarning', () => { +describe('SessionTimeoutWarning', () => { it('fires its callback when the OK button is clicked', () => { const handler = jest.fn(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect(handler).toBeCalledTimes(0); wrapper.find('EuiButton[data-test-subj="refreshSessionButton"]').simulate('click'); diff --git a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx b/x-pack/plugins/security/public/session/session_timeout_warning.tsx similarity index 81% rename from x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx rename to x-pack/plugins/security/public/session/session_timeout_warning.tsx index 2b957e9b251a7..e1b4542031ed1 100644 --- a/x-pack/legacy/plugins/security/public/components/session_expiration_warning/session_expiration_warning.tsx +++ b/x-pack/plugins/security/public/session/session_timeout_warning.tsx @@ -12,12 +12,12 @@ interface Props { onRefreshSession: () => void; } -export const SessionExpirationWarning = (props: Props) => { +export const SessionTimeoutWarning = (props: Props) => { return ( <>

@@ -29,7 +29,7 @@ export const SessionExpirationWarning = (props: Props) => { data-test-subj="refreshSessionButton" > diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts new file mode 100644 index 0000000000000..60f032652221b --- /dev/null +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts @@ -0,0 +1,89 @@ +/* + * 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. + */ + +// @ts-ignore +import fetchMock from 'fetch-mock/es5/client'; +import { SessionExpired } from './session_expired'; +import { setup } from '../../../../../src/test_utils/public/http_test_setup'; +import { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor'; +jest.mock('./session_expired'); + +const drainPromiseQueue = () => { + return new Promise(resolve => { + setImmediate(resolve); + }); +}; + +const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url); + +const setupHttp = (basePath: string) => { + const { http } = setup(injectedMetadata => { + injectedMetadata.getBasePath.mockReturnValue(basePath); + }); + return http; +}; + +afterEach(() => { + fetchMock.restore(); +}); + +it(`logs out 401 responses`, async () => { + const http = setupHttp('/foo'); + const sessionExpired = new SessionExpired(http.basePath); + const logoutPromise = new Promise(resolve => { + jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve()); + }); + const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + let fetchResolved = false; + let fetchRejected = false; + http.fetch('/foo-api').then(() => (fetchResolved = true), () => (fetchRejected = true)); + + await logoutPromise; + await drainPromiseQueue(); + expect(fetchResolved).toBe(false); + expect(fetchRejected).toBe(false); +}); + +it(`ignores anonymous paths`, async () => { + mockCurrentUrl('/foo/bar'); + const http = setupHttp('/foo'); + const { anonymousPaths } = http; + anonymousPaths.register('/bar'); + const sessionExpired = new SessionExpired(http.basePath); + const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + expect(sessionExpired.logout).not.toHaveBeenCalled(); +}); + +it(`ignores errors which don't have a response, for example network connectivity issues`, async () => { + const http = setupHttp('/foo'); + const sessionExpired = new SessionExpired(http.basePath); + const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', new Promise((resolve, reject) => reject(new Error('Network is down')))); + + await expect(http.fetch('/foo-api')).rejects.toMatchInlineSnapshot(`[Error: Network is down]`); + expect(sessionExpired.logout).not.toHaveBeenCalled(); +}); + +it(`ignores requests which omit credentials`, async () => { + const http = setupHttp('/foo'); + const sessionExpired = new SessionExpired(http.basePath); + const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths); + http.intercept(interceptor); + fetchMock.mock('*', 401); + + await expect(http.fetch('/foo-api', { credentials: 'omit' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized]` + ); + expect(sessionExpired.logout).not.toHaveBeenCalled(); +}); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts new file mode 100644 index 0000000000000..a0ef2fdb86b47 --- /dev/null +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts @@ -0,0 +1,42 @@ +/* + * 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 { + HttpInterceptor, + HttpErrorResponse, + IHttpInterceptController, + IAnonymousPaths, +} from 'src/core/public'; + +import { SessionExpired } from './session_expired'; + +export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor { + constructor(private sessionExpired: SessionExpired, private anonymousPaths: IAnonymousPaths) {} + + responseError(httpErrorResponse: HttpErrorResponse, controller: IHttpInterceptController) { + if (this.anonymousPaths.isAnonymous(window.location.pathname)) { + return; + } + + // if the request was omitting credentials it's to an anonymous endpoint + // (for example to login) and we don't wish to ever redirect + if (httpErrorResponse.request.credentials === 'omit') { + return; + } + + // if we happen to not have a response, for example if there is no + // network connectivity, we don't do anything + const { response } = httpErrorResponse; + if (!response) { + return; + } + + if (response.status === 401) { + this.sessionExpired.logout(); + controller.halt(); + } + } +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4ff1459637889..0d8d4d908231f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8549,9 +8549,9 @@ "xpack.security.account.passwordsDoNotMatch": "パスワードが一致していません。", "xpack.security.account.usernameGroupDescription": "この情報は変更できません。", "xpack.security.account.usernameGroupTitle": "ユーザー名とメールアドレス", - "xpack.security.components.sessionExpiration.logoutNotification": "操作がないため間もなくログアウトします。再開するには [OK] をクリックしてくださ。", - "xpack.security.components.sessionExpiration.okButtonText": "OK", - "xpack.security.hacks.warningTitle": "警告", + "xpack.security.components.sessionTimeoutWarning.message": "操作がないため間もなくログアウトします。再開するには [OK] をクリックしてくださ。", + "xpack.security.components.sessionTimeoutWarning.okButtonText": "OK", + "xpack.security.components.sessionTimeoutWarning.title": "警告", "xpack.security.loggedOut.login": "ログイン", "xpack.security.loggedOut.title": "ログアウト完了", "xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "無効なユーザー名またはパスワード再試行してください。", @@ -10470,4 +10470,4 @@ "xpack.fileUpload.fileParser.errorReadingFile": "ファイルの読み込み中にエラーが発生しました", "xpack.fileUpload.fileParser.noFileProvided": "エラー、ファイルが提供されていません" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 116a1f15a139b..2e99d6d49a8e5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8706,9 +8706,9 @@ "xpack.security.account.passwordsDoNotMatch": "密码不匹配。", "xpack.security.account.usernameGroupDescription": "不能更改此信息。", "xpack.security.account.usernameGroupTitle": "用户名和电子邮件", - "xpack.security.components.sessionExpiration.logoutNotification": "由于处于不活动状态,您即将退出。单击“确定”可以恢复。", - "xpack.security.components.sessionExpiration.okButtonText": "确定", - "xpack.security.hacks.warningTitle": "警告", + "xpack.security.components.sessionTimeoutWarning.message": "由于处于不活动状态,您即将退出。单击“确定”可以恢复。", + "xpack.security.components.sessionTimeoutWarning.okButtonText": "确定", + "xpack.security.components.sessionTimeoutWarning.title": "警告", "xpack.security.loggedOut.login": "登录", "xpack.security.loggedOut.title": "已成功退出", "xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "用户名或密码无效。请重试。", @@ -10627,4 +10627,4 @@ "xpack.fileUpload.fileParser.errorReadingFile": "读取文件时出错", "xpack.fileUpload.fileParser.noFileProvided": "错误,未提供任何文件" } -} \ No newline at end of file +} From 88f0eba57e67656d11d3ce72ec1e8a7865fdbe2f Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 24 Oct 2019 09:14:47 -0700 Subject: [PATCH 2/9] [ML] Fix tooltip positioning. (#49160) - Moves all DOM manipulation to a ref callback for the ChartTooltip react component. The service mlChartTooltipService no longer touches the DOM, it just passed around data and triggers the chartTooltip$ observable which the ChartTooltip is subscribed to. The code infers positioning now from the tooltips parent element and we no longer rely on hard-coded Kibana classnames. Besides that the code to determine the position remains the same (with some offset tweaking). - The mocha tests for mlChartTooltipService have been migrated to jest. --- .../chart_tooltip/chart_tooltip.tsx | 60 ++++++++++--- .../chart_tooltip/chart_tooltip_service.d.ts | 11 ++- .../chart_tooltip/chart_tooltip_service.js | 86 +++++-------------- ...oltip.js => chart_tooltip_service.test.ts} | 15 ++-- .../plugins/ml/public/explorer/explorer.js | 3 +- .../explorer_chart_distribution.js | 4 +- .../explorer_chart_single_metric.js | 4 +- .../ml/public/explorer/explorer_swimlane.js | 8 +- .../timeseriesexplorer/timeseriesexplorer.js | 1 + 9 files changed, 96 insertions(+), 96 deletions(-) rename x-pack/legacy/plugins/ml/public/components/chart_tooltip/{__tests__/chart_tooltip.js => chart_tooltip_service.test.ts} (50%) diff --git a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip.tsx b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip.tsx index 784deb260902b..ea9bc4f0f92ee 100644 --- a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip.tsx @@ -12,7 +12,53 @@ import { TooltipValueFormatter } from '@elastic/charts'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { useObservable } from '../../../../../../../src/plugins/kibana_react/public/util/use_observable'; -import { chartTooltip$, mlChartTooltipService, ChartTooltipValue } from './chart_tooltip_service'; +import { chartTooltip$, ChartTooltipValue } from './chart_tooltip_service'; + +type RefValue = HTMLElement | null; + +function useRefWithCallback() { + const chartTooltipState = useObservable(chartTooltip$); + const ref = useRef(null); + + return (node: RefValue) => { + ref.current = node; + + if ( + node !== null && + node.parentElement !== null && + chartTooltipState !== undefined && + chartTooltipState.isTooltipVisible + ) { + const parentBounding = node.parentElement.getBoundingClientRect(); + + const { targetPosition, offset } = chartTooltipState; + + const contentWidth = document.body.clientWidth - parentBounding.left; + const tooltipWidth = node.clientWidth; + + let left = targetPosition.left + offset.x - parentBounding.left; + if (left + tooltipWidth > contentWidth) { + // the tooltip is hanging off the side of the page, + // so move it to the other side of the target + const markerWidthAdjustment = 25; + left = left - (tooltipWidth + offset.x + markerWidthAdjustment); + } + + const top = targetPosition.top + offset.y - parentBounding.top; + + if ( + chartTooltipState.tooltipPosition.left !== left || + chartTooltipState.tooltipPosition.top !== top + ) { + // render the tooltip with adjusted position. + chartTooltip$.next({ + ...chartTooltipState, + tooltipPosition: { left, top }, + }); + } + } + }; +} const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -23,24 +69,18 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo }; export const ChartTooltip: FC = () => { - const chartTooltipElement = useRef(null); - - mlChartTooltipService.element = chartTooltipElement.current; - const chartTooltipState = useObservable(chartTooltip$); + const chartTooltipElement = useRefWithCallback(); if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { return
; } const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; + const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; return ( -
+
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
{renderHeader(tooltipData[0], tooltipHeaderFormatter)} diff --git a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.d.ts index 2e80d577adeb6..e6b0b6b4270bd 100644 --- a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.d.ts @@ -8,15 +8,19 @@ import { BehaviorSubject } from 'rxjs'; import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; +export declare const getChartTooltipDefaultState: () => ChartTooltipState; + export interface ChartTooltipValue extends TooltipValue { skipHeader?: boolean; } interface ChartTooltipState { isTooltipVisible: boolean; + offset: ToolTipOffset; + targetPosition: ClientRect; tooltipData: ChartTooltipValue[]; tooltipHeaderFormatter?: TooltipValueFormatter; - tooltipPosition: { transform: string }; + tooltipPosition: { left: number; top: number }; } export declare const chartTooltip$: BehaviorSubject; @@ -27,11 +31,10 @@ interface ToolTipOffset { } interface MlChartTooltipService { - element: HTMLElement | null; show: ( tooltipData: ChartTooltipValue[], - target: HTMLElement | null, - offset: ToolTipOffset + target?: HTMLElement | null, + offset?: ToolTipOffset ) => void; hide: () => void; } diff --git a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js index 2177ed9fa2288..8fe1e795df53a 100644 --- a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js +++ b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js @@ -6,74 +6,32 @@ import { BehaviorSubject } from 'rxjs'; -const doc = document.documentElement; - -const chartTooltipDefaultState = { +export const getChartTooltipDefaultState = () => ({ isTooltipVisible: false, tooltipData: [], - tooltipPosition: { transform: 'translate(0px, 0px)' } -}; + offset: { x: 0, y: 0 }, + targetPosition: { left: 0, top: 0 }, + tooltipPosition: { left: 0, top: 0 } +}); -export const chartTooltip$ = new BehaviorSubject(chartTooltipDefaultState); +export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); export const mlChartTooltipService = { - element: null, -}; - -mlChartTooltipService.show = function (tooltipData, target, offset = { x: 0, y: 0 }) { - if (this.element === null || typeof target === 'undefined') { - return; - } - - // side bar width - const euiNavDrawer = document.getElementsByClassName('euiNavDrawer'); - - if (euiNavDrawer.length === 0) { - return; - } - - // enable the tooltip to render it in the DOM - // so the correct `tooltipWidth` gets returned. - const tooltipState = { - ...chartTooltipDefaultState, - isTooltipVisible: true, - tooltipData, - }; - chartTooltip$.next(tooltipState); - - const navOffset = euiNavDrawer[0].clientWidth; // Offset by width of side navbar - const contentWidth = document.body.clientWidth - navOffset; - const tooltipWidth = this.element.clientWidth; - - const pos = target.getBoundingClientRect(); - let left = pos.left + offset.x + 4 - navOffset; - if (left + tooltipWidth > contentWidth) { - // the tooltip is hanging off the side of the page, - // so move it to the other side of the target - const markerWidthAdjustment = 10; - left = left - (tooltipWidth + offset.x + markerWidthAdjustment); + show: (tooltipData, target, offset = { x: 0, y: 0 }) => { + if (typeof target !== 'undefined' && target !== null) { + chartTooltip$.next({ + ...chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + targetPosition: target.getBoundingClientRect(), + tooltipData, + }); + } + }, + hide: () => { + chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false + }); } - - // Calculate top offset - const scrollTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); - const topNavHeightAdjustment = 190; - const top = pos.top + offset.y + scrollTop - topNavHeightAdjustment; - - // render the tooltip with adjusted position. - chartTooltip$.next({ - ...tooltipState, - tooltipPosition: { transform: `translate(${left}px, ${top}px)` } - }); - -}; - -// When selecting multiple cells using dragSelect, we need to quickly -// hide the tooltip with `noTransition`, otherwise, if the mouse pointer -// enters the tooltip while dragging, it will cancel selecting multiple -// swimlane cells which we'd like to avoid of course. -mlChartTooltipService.hide = function () { - chartTooltip$.next({ - ...chartTooltipDefaultState, - isTooltipVisible: false - }); }; diff --git a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.test.ts similarity index 50% rename from x-pack/legacy/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js rename to x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.test.ts index a542b5284b70b..aa1dbf92b0677 100644 --- a/x-pack/legacy/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js +++ b/x-pack/legacy/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,21 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; - -import { mlChartTooltipService } from '../chart_tooltip_service'; +import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; describe('ML - mlChartTooltipService', () => { it('service API duck typing', () => { - expect(mlChartTooltipService).to.be.an('object'); - expect(mlChartTooltipService.show).to.be.a('function'); - expect(mlChartTooltipService.hide).to.be.a('function'); + expect(typeof mlChartTooltipService).toBe('object'); + expect(typeof mlChartTooltipService.show).toBe('function'); + expect(typeof mlChartTooltipService.hide).toBe('function'); }); it('should fail silently when target is not defined', () => { - mlChartTooltipService.element = {}; expect(() => { - mlChartTooltipService.show('', undefined); - }).to.not.throwError('Call to show() should fail silently.'); + mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); + }).not.toThrow('Call to show() should fail silently.'); }); }); diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer.js b/x-pack/legacy/plugins/ml/public/explorer/explorer.js index 694e61db0583e..1acdd041c4052 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer.js @@ -1169,8 +1169,9 @@ export const Explorer = injectI18n(injectObservablesAsProps( return ( -
+ {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */} + {noInfluencersConfigured === false && influencers !== undefined && diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js index 82a4744f6aec8..588c3e3d6f1e9 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js @@ -498,8 +498,8 @@ export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribut } mlChartTooltipService.show(tooltipData, circle, { - x: LINE_CHART_ANOMALY_RADIUS * 2, - y: 0 + x: LINE_CHART_ANOMALY_RADIUS * 3, + y: LINE_CHART_ANOMALY_RADIUS * 2, }); } } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js index 2d991172ed345..be85af5a70c40 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js @@ -444,8 +444,8 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet } mlChartTooltipService.show(tooltipData, circle, { - x: LINE_CHART_ANOMALY_RADIUS * 2, - y: 0 + x: LINE_CHART_ANOMALY_RADIUS * 3, + y: LINE_CHART_ANOMALY_RADIUS * 2, }); } } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_swimlane.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_swimlane.js index d49a1dc0523c4..2ee725b6fda86 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_swimlane.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_swimlane.js @@ -325,10 +325,10 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React. yAccessor: 'anomaly_score' }); - const offsets = (target.className === 'sl-cell-inner' ? { x: 0, y: 0 } : { x: 2, y: 1 }); + const offsets = (target.className === 'sl-cell-inner' ? { x: 6, y: 0 } : { x: 8, y: 1 }); mlChartTooltipService.show(tooltipData, target, { - x: target.offsetWidth - offsets.x, - y: 10 + offsets.y + x: target.offsetWidth + offsets.x, + y: 6 + offsets.y }); } @@ -364,7 +364,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React. .on('mouseover', label => { mlChartTooltipService.show([{ skipHeader: true }, { name: swimlaneData.fieldName, value: label }], this, { x: laneLabelWidth, - y: 8 + y: 0 }); }) .on('mouseout', () => { diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js index 164e5e7b5a778..73d1dd2818042 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js @@ -1179,6 +1179,7 @@ export class TimeSeriesExplorer extends React.Component { {(arePartitioningFieldsProvided && jobs.length > 0 && (fullRefresh === false || loading === false) && hasResults === true) && ( + {/* Make sure ChartTooltip is inside this plain wrapping element so positioning can be infered correctly. */} From cd8c708d88c5a6b09df4b8bd19620881f3edf15e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Oct 2019 18:23:51 +0200 Subject: [PATCH 3/9] Kibana app migration: Centralize home dependencies (#48618) --- .../kibana/public/home/components/add_data.js | 5 +- .../public/home/components/add_data.test.js | 27 ++++--- .../kibana/public/home/components/home.js | 7 +- .../public/home/components/home.test.js | 7 ++ .../public/home/components/home.test.mocks.ts | 24 ++---- .../kibana/public/home/components/home_app.js | 35 +++++--- .../home/components/sample_data_set_cards.js | 21 ++--- .../sample_data_view_data_button.js | 7 +- .../sample_data_view_data_button.test.js | 13 ++- .../tutorial/replace_template_strings.js | 31 ++++--- .../home/components/tutorial/tutorial.js | 4 +- .../home/components/tutorial/tutorial.test.js | 8 ++ .../home/components/tutorial_directory.js | 4 +- .../kibana/public/home/components/welcome.tsx | 28 ++++--- .../core_plugins/kibana/public/home/index.js | 29 +++---- .../kibana/public/home/kibana_services.js | 40 --------- .../kibana/public/home/kibana_services.ts | 81 +++++++++++++++++++ .../kibana/public/home/load_tutorials.js | 7 +- .../kibana/public/home/sample_data_client.js | 24 +++--- 19 files changed, 233 insertions(+), 169 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana/public/home/kibana_services.js create mode 100644 src/legacy/core_plugins/kibana/public/home/kibana_services.ts diff --git a/src/legacy/core_plugins/kibana/public/home/components/add_data.js b/src/legacy/core_plugins/kibana/public/home/components/add_data.js index f8c8e0ec8411f..0a3adfa430a45 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/add_data.js +++ b/src/legacy/core_plugins/kibana/public/home/components/add_data.js @@ -21,7 +21,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; +import { getServices } from '../kibana_services'; import { EuiButton, @@ -38,10 +38,9 @@ import { EuiFlexGrid, } from '@elastic/eui'; -/* istanbul ignore next */ -const basePath = chrome.getBasePath(); const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl, mlEnabled }) => { + const basePath = getServices().getBasePath(); const renderCards = () => { const apmData = { title: intl.formatMessage({ diff --git a/src/legacy/core_plugins/kibana/public/home/components/add_data.test.js b/src/legacy/core_plugins/kibana/public/home/components/add_data.test.js index d7c8e9daa99da..07f415cfcb1c9 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/add_data.test.js +++ b/src/legacy/core_plugins/kibana/public/home/components/add_data.test.js @@ -20,15 +20,20 @@ import React from 'react'; import { AddData } from './add_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import chrome from 'ui/chrome'; +import { getServices } from '../kibana_services'; -jest.mock( - 'ui/chrome', - () => ({ +jest.mock('../kibana_services', () =>{ + const mock = { getBasePath: jest.fn(() => 'path'), - }), - { virtual: true } -); + }; + return { + getServices: () => mock, + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); test('render', () => { const component = shallowWithIntl( { isNewKibanaInstance={false} />); expect(component).toMatchSnapshot(); // eslint-disable-line - expect(chrome.getBasePath).toHaveBeenCalledTimes(1); + expect(getServices().getBasePath).toHaveBeenCalledTimes(1); }); test('mlEnabled', () => { @@ -47,7 +52,7 @@ test('mlEnabled', () => { isNewKibanaInstance={false} />); expect(component).toMatchSnapshot(); // eslint-disable-line - expect(chrome.getBasePath).toHaveBeenCalledTimes(1); + expect(getServices().getBasePath).toHaveBeenCalledTimes(1); }); test('apmUiEnabled', () => { @@ -57,7 +62,7 @@ test('apmUiEnabled', () => { isNewKibanaInstance={false} />); expect(component).toMatchSnapshot(); // eslint-disable-line - expect(chrome.getBasePath).toHaveBeenCalledTimes(1); + expect(getServices().getBasePath).toHaveBeenCalledTimes(1); }); test('isNewKibanaInstance', () => { @@ -67,5 +72,5 @@ test('isNewKibanaInstance', () => { isNewKibanaInstance={true} />); expect(component).toMatchSnapshot(); // eslint-disable-line - expect(chrome.getBasePath).toHaveBeenCalledTimes(1); + expect(getServices().getBasePath).toHaveBeenCalledTimes(1); }); diff --git a/src/legacy/core_plugins/kibana/public/home/components/home.js b/src/legacy/core_plugins/kibana/public/home/components/home.js index 6a8dff2ad4fa7..e4c7de9b495a0 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home.js +++ b/src/legacy/core_plugins/kibana/public/home/components/home.js @@ -22,7 +22,6 @@ import PropTypes from 'prop-types'; import { Synopsis } from './synopsis'; import { AddData } from './add_data'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; import { EuiButton, @@ -40,6 +39,7 @@ import { import { Welcome } from './welcome'; import { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; +import { getServices } from '../kibana_services'; const KEY_ENABLE_WELCOME = 'home:welcome:show'; @@ -47,7 +47,10 @@ export class Home extends Component { constructor(props) { super(props); - const isWelcomeEnabled = !(chrome.getInjected('disableWelcomeScreen') || props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false'); + const isWelcomeEnabled = !( + getServices().getInjected('disableWelcomeScreen') || + props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false' + ); this.state = { // If welcome is enabled, we wait for loading to complete diff --git a/src/legacy/core_plugins/kibana/public/home/components/home.test.js b/src/legacy/core_plugins/kibana/public/home/components/home.test.js index aa520ba2ed5f9..c21c6fa3d98a5 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home.test.js +++ b/src/legacy/core_plugins/kibana/public/home/components/home.test.js @@ -25,6 +25,13 @@ import { shallow } from 'enzyme'; import { Home } from './home'; import { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; +jest.mock('../kibana_services', () =>({ + getServices: () => ({ + getBasePath: () => 'path', + getInjected: () => '' + }) +})); + describe('home', () => { let defaultProps; diff --git a/src/legacy/core_plugins/kibana/public/home/components/home.test.mocks.ts b/src/legacy/core_plugins/kibana/public/home/components/home.test.mocks.ts index 621c058c803db..cd7bc82fe3345 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home.test.mocks.ts +++ b/src/legacy/core_plugins/kibana/public/home/components/home.test.mocks.ts @@ -17,7 +17,12 @@ * under the License. */ -import { notificationServiceMock, overlayServiceMock } from '../../../../../../core/public/mocks'; +import { + notificationServiceMock, + overlayServiceMock, + httpServiceMock, + injectedMetadataServiceMock, +} from '../../../../../../core/public/mocks'; jest.doMock('ui/new_platform', () => { return { @@ -29,22 +34,9 @@ jest.doMock('ui/new_platform', () => { npStart: { core: { overlays: overlayServiceMock.createStartContract(), + http: httpServiceMock.createStartContract({ basePath: 'path' }), + injectedMetadata: injectedMetadataServiceMock.createStartContract(), }, }, }; }); - -jest.doMock( - 'ui/chrome', - () => ({ - getBasePath: jest.fn(() => 'path'), - getInjected: jest.fn(() => ''), - }), - { virtual: true } -); - -jest.doMock('ui/capabilities', () => ({ - catalogue: {}, - management: {}, - navLinks: {}, -})); diff --git a/src/legacy/core_plugins/kibana/public/home/components/home_app.js b/src/legacy/core_plugins/kibana/public/home/components/home_app.js index 9aa44863f6d70..005d4bdb0a99e 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home_app.js +++ b/src/legacy/core_plugins/kibana/public/home/components/home_app.js @@ -26,23 +26,32 @@ import { Tutorial } from './tutorial/tutorial'; import { HashRouter as Router, Switch, - Route + Route, } from 'react-router-dom'; import { getTutorial } from '../load_tutorials'; import { replaceTemplateStrings } from './tutorial/replace_template_strings'; -import { telemetryOptInProvider, shouldShowTelemetryOptIn } from '../kibana_services'; -import chrome from 'ui/chrome'; +import { + getServices +} from '../kibana_services'; export function HomeApp({ directories }) { - const isCloudEnabled = chrome.getInjected('isCloudEnabled', false); - const apmUiEnabled = chrome.getInjected('apmUiEnabled', true); - const mlEnabled = chrome.getInjected('mlEnabled', false); - const savedObjectsClient = chrome.getSavedObjectsClient(); + const { + telemetryOptInProvider, + shouldShowTelemetryOptIn, + getInjected, + savedObjectsClient, + getBasePath, + addBasePath, + } = getServices(); + + const isCloudEnabled = getInjected('isCloudEnabled', false); + const apmUiEnabled = getInjected('apmUiEnabled', true); + const mlEnabled = getInjected('mlEnabled', false); const renderTutorialDirectory = (props) => { return ( @@ -52,7 +61,7 @@ export function HomeApp({ directories }) { const renderTutorial = (props) => { return ( @@ -85,13 +94,13 @@ export function HomeApp({ directories }) { path="/home" > { - return IS_DARK_THEME && sampleDataSet.darkPreviewImagePath ? sampleDataSet.darkPreviewImagePath : sampleDataSet.previewImagePath; + return getServices().uiSettings.get('theme:darkMode') && sampleDataSet.darkPreviewImagePath + ? sampleDataSet.darkPreviewImagePath + : sampleDataSet.previewImagePath; } render() { diff --git a/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.js b/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.js index 1df99e9b03d11..89d4909b0c66f 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.js +++ b/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.js @@ -27,9 +27,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import chrome from 'ui/chrome'; +import { getServices } from '../kibana_services'; export class SampleDataViewDataButton extends React.Component { + addBasePath = getServices().addBasePath; state = { isPopoverOpen: false @@ -56,7 +57,7 @@ export class SampleDataViewDataButton extends React.Component { datasetName: this.props.name, }, }); - const dashboardPath = chrome.addBasePath(`/app/kibana#/dashboard/${this.props.overviewDashboard}`); + const dashboardPath = this.addBasePath(`/app/kibana#/dashboard/${this.props.overviewDashboard}`); if (this.props.appLinks.length === 0) { return ( @@ -79,7 +80,7 @@ export class SampleDataViewDataButton extends React.Component { size="m" /> ), - href: chrome.addBasePath(path) + href: this.addBasePath(path) }; }); const panels = [ diff --git a/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.test.js b/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.test.js index b0551341965fa..cc515993ac061 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.test.js +++ b/src/legacy/core_plugins/kibana/public/home/components/sample_data_view_data_button.test.js @@ -17,19 +17,18 @@ * under the License. */ -jest.mock('ui/chrome', () => { - return { - addBasePath: (path) => { - return `root${path}`; - }, - }; -}); import React from 'react'; import { shallow } from 'enzyme'; import { SampleDataViewDataButton } from './sample_data_view_data_button'; +jest.mock('../kibana_services', () =>({ + getServices: () =>({ + addBasePath: path => `root${path}` + }) +})); + test('should render simple button when appLinks is empty', () => { const component = shallow(({ + getServices: () =>({ + getBasePath: jest.fn(() => 'path'), + chrome: { + setBreadcrumbs: () => {} + } + }) +})); jest.mock('../../../../../kibana_react/public', () => { return { Markdown: () =>
, diff --git a/src/legacy/core_plugins/kibana/public/home/components/tutorial_directory.js b/src/legacy/core_plugins/kibana/public/home/components/tutorial_directory.js index eae549f8a6ac0..0c537c8e9ae8a 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/tutorial_directory.js +++ b/src/legacy/core_plugins/kibana/public/home/components/tutorial_directory.js @@ -22,7 +22,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Synopsis } from './synopsis'; import { SampleDataSetCards } from './sample_data_set_cards'; -import chrome from 'ui/chrome'; +import { getServices } from '../kibana_services'; import { EuiPage, @@ -112,7 +112,7 @@ class TutorialDirectoryUi extends React.Component { async componentDidMount() { this._isMounted = true; - chrome.breadcrumbs.set([ + getServices().chrome.setBreadcrumbs([ { text: homeTitle, href: '#/home', diff --git a/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx b/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx index 8869819290263..afe43a23e18cb 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx +++ b/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx @@ -33,15 +33,11 @@ import { EuiIcon, EuiPortal, } from '@elastic/eui'; -// @ts-ignore -import { banners } from 'ui/notify'; - import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; +import { getServices } from '../kibana_services'; + import { SampleDataCard } from './sample_data'; import { TelemetryOptInCard } from './telemetry_opt_in'; -// @ts-ignore -import { trackUiMetric, METRIC_TYPE } from '../kibana_services'; interface Props { urlBasePath: string; @@ -51,6 +47,7 @@ interface Props { getTelemetryBannerId: () => string; shouldShowTelemetryOptIn: boolean; } + interface State { step: number; } @@ -59,6 +56,7 @@ interface State { * Shows a full-screen welcome page that gives helpful quick links to beginners. */ export class Welcome extends React.PureComponent { + private services = getServices(); public readonly state: State = { step: 0, }; @@ -70,31 +68,35 @@ export class Welcome extends React.PureComponent { }; private redirecToSampleData() { - const path = chrome.addBasePath('#/home/tutorial_directory/sampleData'); + const path = this.services.addBasePath('#/home/tutorial_directory/sampleData'); window.location.href = path; } + private async handleTelemetrySelection(confirm: boolean) { const metricName = `telemetryOptIn${confirm ? 'Confirm' : 'Decline'}`; - trackUiMetric(METRIC_TYPE.CLICK, metricName); + this.services.trackUiMetric(this.services.METRIC_TYPE.CLICK, metricName); await this.props.setOptIn(confirm); const bannerId = this.props.getTelemetryBannerId(); - banners.remove(bannerId); + this.services.banners.remove(bannerId); this.setState(() => ({ step: 1 })); } private onSampleDataDecline = () => { - trackUiMetric(METRIC_TYPE.CLICK, 'sampleDataDecline'); + this.services.trackUiMetric(this.services.METRIC_TYPE.CLICK, 'sampleDataDecline'); this.props.onSkip(); }; private onSampleDataConfirm = () => { - trackUiMetric(METRIC_TYPE.CLICK, 'sampleDataConfirm'); + this.services.trackUiMetric(this.services.METRIC_TYPE.CLICK, 'sampleDataConfirm'); this.redirecToSampleData(); }; componentDidMount() { - trackUiMetric(METRIC_TYPE.LOADED, 'welcomeScreenMount'); + this.services.trackUiMetric(this.services.METRIC_TYPE.LOADED, 'welcomeScreenMount'); if (this.props.shouldShowTelemetryOptIn) { - trackUiMetric(METRIC_TYPE.COUNT, 'welcomeScreenWithTelemetryOptIn'); + this.services.trackUiMetric( + this.services.METRIC_TYPE.COUNT, + 'welcomeScreenWithTelemetryOptIn' + ); } document.addEventListener('keydown', this.hideOnEsc); } diff --git a/src/legacy/core_plugins/kibana/public/home/index.js b/src/legacy/core_plugins/kibana/public/home/index.js index 8233df680edfd..829d1ef8f0ba4 100644 --- a/src/legacy/core_plugins/kibana/public/home/index.js +++ b/src/legacy/core_plugins/kibana/public/home/index.js @@ -17,17 +17,14 @@ * under the License. */ -import chrome from 'ui/chrome'; -import routes from 'ui/routes'; +import { getServices } from './kibana_services'; import template from './home_ng_wrapper.html'; -import { FeatureCatalogueRegistryProvider } from 'ui/registry/feature_catalogue'; -import { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from 'ui/modules'; import { HomeApp } from './components/home_app'; import { i18n } from '@kbn/i18n'; -import { npStart } from 'ui/new_platform'; + +const { wrapInI18nContext, uiRoutes, uiModules } = getServices(); const app = uiModules.get('apps/home', []); app.directive('homeApp', function (reactDirective) { @@ -39,10 +36,14 @@ const homeTitle = i18n.translate('kbn.home.breadcrumbs.homeTitle', { defaultMess function getRoute() { return { template, - controller($scope, Private) { - $scope.directories = Private(FeatureCatalogueRegistryProvider).inTitleOrder; - $scope.recentlyAccessed = npStart.core.chrome.recentlyAccessed.get().map(item => { - item.link = chrome.addBasePath(item.link); + controller($scope) { + const { chrome, addBasePath, getFeatureCatalogueRegistryProvider } = getServices(); + getFeatureCatalogueRegistryProvider().then(catalogue => { + $scope.directories = catalogue.inTitleOrder; + $scope.$digest(); + }); + $scope.recentlyAccessed = chrome.recentlyAccessed.get().map(item => { + item.link = addBasePath(item.link); return item; }); }, @@ -54,7 +55,7 @@ function getRoute() { // All routing will be handled inside HomeApp via react, we just need to make sure angular doesn't // redirect us to the default page by encountering a url it isn't marked as being able to handle. -routes.when('/home', getRoute()); -routes.when('/home/feature_directory', getRoute()); -routes.when('/home/tutorial_directory/:tab?', getRoute()); -routes.when('/home/tutorial/:id', getRoute()); +uiRoutes.when('/home', getRoute()); +uiRoutes.when('/home/feature_directory', getRoute()); +uiRoutes.when('/home/tutorial_directory/:tab?', getRoute()); +uiRoutes.when('/home/tutorial/:id', getRoute()); diff --git a/src/legacy/core_plugins/kibana/public/home/kibana_services.js b/src/legacy/core_plugins/kibana/public/home/kibana_services.js deleted file mode 100644 index 792c5e09435a4..0000000000000 --- a/src/legacy/core_plugins/kibana/public/home/kibana_services.js +++ /dev/null @@ -1,40 +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. - */ - -import { uiModules } from 'ui/modules'; -import { npStart } from 'ui/new_platform'; -import { createUiStatsReporter, METRIC_TYPE } from '../../../ui_metric/public'; -import { TelemetryOptInProvider } from '../../../telemetry/public/services'; - -export let indexPatternService; -export let shouldShowTelemetryOptIn; -export let telemetryOptInProvider; - -export const trackUiMetric = createUiStatsReporter('Kibana_home'); -export { METRIC_TYPE }; - -uiModules.get('kibana').run(($injector) => { - const telemetryEnabled = npStart.core.injectedMetadata.getInjectedVar('telemetryEnabled'); - const telemetryBanner = npStart.core.injectedMetadata.getInjectedVar('telemetryBanner'); - const Private = $injector.get('Private'); - - telemetryOptInProvider = Private(TelemetryOptInProvider); - shouldShowTelemetryOptIn = telemetryEnabled && telemetryBanner && !telemetryOptInProvider.getOptIn(); - indexPatternService = $injector.get('indexPatterns'); -}); diff --git a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts new file mode 100644 index 0000000000000..39067e2271f28 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ + +// @ts-ignore +import { toastNotifications, banners } from 'ui/notify'; +import { kfetch } from 'ui/kfetch'; +import chrome from 'ui/chrome'; + +import { wrapInI18nContext } from 'ui/i18n'; + +// @ts-ignore +import { uiModules as modules } from 'ui/modules'; +import routes from 'ui/routes'; +import { npStart } from 'ui/new_platform'; +import { IPrivate } from 'ui/private'; +import { FeatureCatalogueRegistryProvider } from 'ui/registry/feature_catalogue'; +import { createUiStatsReporter, METRIC_TYPE } from '../../../ui_metric/public'; +import { TelemetryOptInProvider } from '../../../telemetry/public/services'; +import { start as data } from '../../../data/public/legacy'; + +let shouldShowTelemetryOptIn: boolean; +let telemetryOptInProvider: any; + +export function getServices() { + return { + getInjected: npStart.core.injectedMetadata.getInjectedVar, + metadata: npStart.core.injectedMetadata.getLegacyMetadata(), + docLinks: npStart.core.docLinks, + + uiRoutes: routes, + uiModules: modules, + + savedObjectsClient: npStart.core.savedObjects.client, + chrome: npStart.core.chrome, + uiSettings: npStart.core.uiSettings, + addBasePath: npStart.core.http.basePath.prepend, + getBasePath: npStart.core.http.basePath.get, + + indexPatternService: data.indexPatterns.indexPatterns, + shouldShowTelemetryOptIn, + telemetryOptInProvider, + getFeatureCatalogueRegistryProvider: async () => { + const injector = await chrome.dangerouslyGetActiveInjector(); + const Private = injector.get('Private'); + return Private(FeatureCatalogueRegistryProvider as any); + }, + + trackUiMetric: createUiStatsReporter('Kibana_home'), + METRIC_TYPE, + + toastNotifications, + banners, + kfetch, + wrapInI18nContext, + }; +} + +modules.get('kibana').run((Private: IPrivate) => { + const telemetryEnabled = npStart.core.injectedMetadata.getInjectedVar('telemetryEnabled'); + const telemetryBanner = npStart.core.injectedMetadata.getInjectedVar('telemetryBanner'); + + telemetryOptInProvider = Private(TelemetryOptInProvider); + shouldShowTelemetryOptIn = + telemetryEnabled && telemetryBanner && !telemetryOptInProvider.getOptIn(); +}); diff --git a/src/legacy/core_plugins/kibana/public/home/load_tutorials.js b/src/legacy/core_plugins/kibana/public/home/load_tutorials.js index d6b264154d424..a6f19bc166dc7 100644 --- a/src/legacy/core_plugins/kibana/public/home/load_tutorials.js +++ b/src/legacy/core_plugins/kibana/public/home/load_tutorials.js @@ -18,11 +18,10 @@ */ import _ from 'lodash'; -import chrome from 'ui/chrome'; +import { getServices } from './kibana_services'; import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; -const baseUrl = chrome.addBasePath('/api/kibana/home/tutorials'); +const baseUrl = getServices().addBasePath('/api/kibana/home/tutorials'); const headers = new Headers(); headers.append('Accept', 'application/json'); headers.append('Content-Type', 'application/json'); @@ -47,7 +46,7 @@ async function loadTutorials() { tutorials = await response.json(); tutorialsLoaded = true; } catch(err) { - toastNotifications.addDanger({ + getServices().toastNotifications.addDanger({ title: i18n.translate('kbn.home.loadTutorials.unableToLoadErrorMessage', { defaultMessage: 'Unable to load tutorials' } ), diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_client.js b/src/legacy/core_plugins/kibana/public/home/sample_data_client.js index da46b3e16c093..9411373004c25 100644 --- a/src/legacy/core_plugins/kibana/public/home/sample_data_client.js +++ b/src/legacy/core_plugins/kibana/public/home/sample_data_client.js @@ -17,36 +17,36 @@ * under the License. */ -import { kfetch } from 'ui/kfetch'; -import chrome from 'ui/chrome'; -import { indexPatternService } from './kibana_services'; +import { getServices } from './kibana_services'; const sampleDataUrl = '/api/sample_data'; function clearIndexPatternsCache() { - indexPatternService.clearCache(); + getServices().indexPatternService.clearCache(); } export async function listSampleDataSets() { - return await kfetch({ method: 'GET', pathname: sampleDataUrl }); + return await getServices().kfetch({ method: 'GET', pathname: sampleDataUrl }); } export async function installSampleDataSet(id, sampleDataDefaultIndex) { - await kfetch({ method: 'POST', pathname: `${sampleDataUrl}/${id}` }); + await getServices().kfetch({ method: 'POST', pathname: `${sampleDataUrl}/${id}` }); - if (chrome.getUiSettingsClient().isDefault('defaultIndex')) { - chrome.getUiSettingsClient().set('defaultIndex', sampleDataDefaultIndex); + if (getServices().uiSettings.isDefault('defaultIndex')) { + getServices().uiSettings.set('defaultIndex', sampleDataDefaultIndex); } clearIndexPatternsCache(); } export async function uninstallSampleDataSet(id, sampleDataDefaultIndex) { - await kfetch({ method: 'DELETE', pathname: `${sampleDataUrl}/${id}` }); + await getServices().kfetch({ method: 'DELETE', pathname: `${sampleDataUrl}/${id}` }); - if (!chrome.getUiSettingsClient().isDefault('defaultIndex') - && chrome.getUiSettingsClient().get('defaultIndex') === sampleDataDefaultIndex) { - chrome.getUiSettingsClient().set('defaultIndex', null); + const uiSettings = getServices().uiSettings; + + if (!uiSettings.isDefault('defaultIndex') + && uiSettings.get('defaultIndex') === sampleDataDefaultIndex) { + uiSettings.set('defaultIndex', null); } clearIndexPatternsCache(); From f4cf28f2e81e919ef950c8c97df1c5bb1e1accb7 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 24 Oct 2019 12:31:02 -0400 Subject: [PATCH 4/9] [ML] Overview: ensure proper permissions check for empty prompt 'Create job' buttons (#49067) * disabled overview empty prompt create job button if no permissions * permission check from overviewPage main component. add mlNodes check to resolver * remove period from overview empty prompt title --- .../analytics_panel/analytics_panel.tsx | 15 +++++++-- .../anomaly_detection_panel.tsx | 16 ++++++++-- .../ml/public/overview/components/content.tsx | 14 +++++++-- .../ml/public/overview/components/sidebar.tsx | 31 +++++++++++++------ .../ml/public/overview/overview_page.tsx | 13 ++++++-- .../plugins/ml/public/overview/route.ts | 2 ++ 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx index ff7c519829b7c..fb08403b391ef 100644 --- a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx @@ -19,7 +19,10 @@ import { AnalyticsTable } from './table'; import { getAnalyticsFactory } from '../../../data_frame_analytics/pages/analytics_management/services/analytics_service'; import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; -export const AnalyticsPanel: FC = () => { +interface Props { + jobCreationDisabled: boolean; +} +export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { const [analytics, setAnalytics] = useState([]); const [errorMessage, setErrorMessage] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); @@ -67,7 +70,7 @@ export const AnalyticsPanel: FC = () => { title={

{i18n.translate('xpack.ml.overview.analyticsList.createFirstJobMessage', { - defaultMessage: 'Create your first analytics job.', + defaultMessage: 'Create your first analytics job', })}

} @@ -81,7 +84,13 @@ export const AnalyticsPanel: FC = () => { } actions={ - + {i18n.translate('xpack.ml.overview.analyticsList.createJobButtonText', { defaultMessage: 'Create job', })} diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx index 72e17c4d090f4..3c89e72ee4943 100644 --- a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -50,7 +50,11 @@ function getDefaultAnomalyScores(groups: Group[]): MaxScoresByGroup { return anomalyScores; } -export const AnomalyDetectionPanel: FC = () => { +interface Props { + jobCreationDisabled: boolean; +} + +export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { const [isLoading, setIsLoading] = useState(false); const [groups, setGroups] = useState({}); const [groupsCount, setGroupsCount] = useState(0); @@ -156,7 +160,7 @@ export const AnomalyDetectionPanel: FC = () => { title={

{i18n.translate('xpack.ml.overview.anomalyDetection.createFirstJobMessage', { - defaultMessage: 'Create your first anomaly detection job.', + defaultMessage: 'Create your first anomaly detection job', })}

} @@ -170,7 +174,13 @@ export const AnomalyDetectionPanel: FC = () => { } actions={ - + {i18n.translate('xpack.ml.overview.anomalyDetection.createJobButtonText', { defaultMessage: 'Create job', })} diff --git a/x-pack/legacy/plugins/ml/public/overview/components/content.tsx b/x-pack/legacy/plugins/ml/public/overview/components/content.tsx index 98295fe0a1a49..a285d5c91a266 100644 --- a/x-pack/legacy/plugins/ml/public/overview/components/content.tsx +++ b/x-pack/legacy/plugins/ml/public/overview/components/content.tsx @@ -9,15 +9,23 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { AnomalyDetectionPanel } from './anomaly_detection_panel'; import { AnalyticsPanel } from './analytics_panel/'; +interface Props { + createAnomalyDetectionJobDisabled: boolean; + createAnalyticsJobDisabled: boolean; +} + // Fetch jobs and determine what to show -export const OverviewContent: FC = () => ( +export const OverviewContent: FC = ({ + createAnomalyDetectionJobDisabled, + createAnalyticsJobDisabled, +}) => ( - + - + diff --git a/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx b/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx index 5a25f9ad54aa1..496beb158f698 100644 --- a/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx +++ b/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx @@ -17,7 +17,27 @@ const feedbackLink = 'https://www.elastic.co/community/'; const transformsLink = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/transform`; const whatIsMachineLearningLink = 'https://www.elastic.co/what-is/elasticsearch-machine-learning'; -export const OverviewSideBar: FC = () => ( +interface Props { + createAnomalyDetectionJobDisabled: boolean; +} + +function getCreateJobLink(createAnomalyDetectionJobDisabled: boolean) { + return createAnomalyDetectionJobDisabled === true ? ( + + ) : ( + + + + ); +} + +export const OverviewSideBar: FC = ({ createAnomalyDetectionJobDisabled }) => (

@@ -41,14 +61,7 @@ export const OverviewSideBar: FC = () => ( /> ), - createJob: ( - - - - ), + createJob: getCreateJobLink(createAnomalyDetectionJobDisabled), transforms: ( { + const disableCreateAnomalyDetectionJob = !checkPermission('canCreateJob') || !mlNodesAvailable(); + const disableCreateAnalyticsButton = + !checkPermission('canCreateDataFrameAnalytics') || + !checkPermission('canStartStopDataFrameAnalytics'); return ( - - + + diff --git a/x-pack/legacy/plugins/ml/public/overview/route.ts b/x-pack/legacy/plugins/ml/public/overview/route.ts index 4f5ce7e453f9a..c24b537796a00 100644 --- a/x-pack/legacy/plugins/ml/public/overview/route.ts +++ b/x-pack/legacy/plugins/ml/public/overview/route.ts @@ -5,6 +5,7 @@ */ import uiRoutes from 'ui/routes'; +import { getMlNodeCount } from '../ml_nodes_check/check_ml_nodes'; // @ts-ignore no declaration module import { checkFullLicense } from '../license/check_license'; import { checkGetJobsPrivilege } from '../privilege/check_privilege'; @@ -19,5 +20,6 @@ uiRoutes.when('/overview/?', { resolve: { CheckLicense: checkFullLicense, privileges: checkGetJobsPrivilege, + mlNodeCount: getMlNodeCount, }, }); From 213014bb884679884db2003cda4c9589e6003be4 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Thu, 24 Oct 2019 14:14:27 -0400 Subject: [PATCH 5/9] [SIEM] Remove Timezone since we are using epoch (#49199) * We are using epoch time for our query so we should not using timezone with them * update api integration --- .../siem/common/graphql/shared/schema.gql.ts | 2 - .../components/timeline/helpers.test.tsx | 22 +- .../events/events_over_time/index.tsx | 2 - .../siem/public/containers/query_template.tsx | 1 - .../siem/public/graphql/introspection.json | 6 - .../plugins/siem/public/graphql/types.ts | 2 - .../plugins/siem/public/lib/keury/index.ts | 5 +- .../plugins/siem/public/pages/hosts/hosts.tsx | 2 +- .../siem/public/pages/hosts/hosts_body.tsx | 2 - .../plugins/siem/public/pages/hosts/index.tsx | 417 +++++++++--------- .../navigation/events_query_tab_body.tsx | 2 - .../public/pages/hosts/navigation/types.ts | 1 - .../plugins/siem/server/graphql/types.ts | 2 - .../lib/events/query.events_over_time.dsl.ts | 3 +- .../apis/siem/events_over_time.ts | 2 - 15 files changed, 220 insertions(+), 251 deletions(-) diff --git a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts index 937b8771ac89b..d043c1587d3c3 100644 --- a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts @@ -14,8 +14,6 @@ export const sharedSchema = gql` to: Float! "The beginning of the timerange" from: Float! - "The default browser set time zone" - timezone: String } type CursorType { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/helpers.test.tsx index 855c951fbcecc..b30771760bad3 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/helpers.test.tsx @@ -156,7 +156,7 @@ describe('Combined Queries', () => { }) ).toEqual({ filterQuery: - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}', + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}', }); }); @@ -174,7 +174,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -194,7 +194,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521848183232,"lte":1521848183232,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521848183232,"lte":1521848183232}}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -214,7 +214,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521848183232,"lte":1521848183232,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521848183232,"lte":1521848183232}}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -234,7 +234,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match":{"event.end":1521848183232}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match":{"event.end":1521848183232}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -254,7 +254,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match":{"event.end":1521848183232}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match":{"event.end":1521848183232}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -271,7 +271,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -289,7 +289,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -307,7 +307,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -327,7 +327,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 3"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 4"}}],"minimum_should_match":1}}]}}]}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 2"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 5"}}],"minimum_should_match":1}}]}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 3"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 4"}}],"minimum_should_match":1}}]}}]}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 2"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 5"}}],"minimum_should_match":1}}]}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); @@ -347,7 +347,7 @@ describe('Combined Queries', () => { end: endDate, })!; expect(filterQuery).toEqual( - '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 3"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 4"}}],"minimum_should_match":1}}]}}]}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 2"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 5"}}],"minimum_should_match":1}}]}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132,"time_zone":"America/New_York"}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253,"time_zone":"America/New_York"}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 3"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 4"}}],"minimum_should_match":1}}]}}]}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 2"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 5"}}],"minimum_should_match":1}}]}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}},{"bool":{"filter":[{"bool":{"should":[{"range":{"@timestamp":{"gte":1521830963132}}}],"minimum_should_match":1}},{"bool":{"should":[{"range":{"@timestamp":{"lte":1521862432253}}}],"minimum_should_match":1}}]}}]}}],"should":[],"must_not":[]}}' ); }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx index 6fde630c691b4..77ce98e180ab0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/events/events_over_time/index.tsx @@ -56,7 +56,6 @@ class EventsOverTimeComponentQuery extends QueryTemplate< isInspected, sourceId, startDate, - timezone, } = this.props; return ( @@ -70,7 +69,6 @@ class EventsOverTimeComponentQuery extends QueryTemplate< interval: '12h', from: startDate!, to: endDate!, - timezone, }, defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), inspect: isInspected, diff --git a/x-pack/legacy/plugins/siem/public/containers/query_template.tsx b/x-pack/legacy/plugins/siem/public/containers/query_template.tsx index 035ebba5ae6ba..b51eac492c48a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/query_template.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/query_template.tsx @@ -17,7 +17,6 @@ export interface QueryTemplateProps { skip?: boolean; sourceId: string; startDate?: number; - timezone?: string; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type FetchMoreOptionsArgs = FetchMoreQueryOptions & diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index cc9438d67bd26..0348b283ed318 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -2351,12 +2351,6 @@ "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } }, "defaultValue": null - }, - { - "name": "timezone", - "description": "The default browser set time zone", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null } ], "interfaces": null, diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 332592a64dfa5..50fb6bd9e8a8a 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -27,8 +27,6 @@ export interface TimerangeInput { to: number; /** The beginning of the timerange */ from: number; - /** The default browser set time zone */ - timezone?: Maybe; } export interface PaginationInputPaginated { diff --git a/x-pack/legacy/plugins/siem/public/lib/keury/index.ts b/x-pack/legacy/plugins/siem/public/lib/keury/index.ts index 0c78fb9d6f45f..7bd8560a1770a 100644 --- a/x-pack/legacy/plugins/siem/public/lib/keury/index.ts +++ b/x-pack/legacy/plugins/siem/public/lib/keury/index.ts @@ -87,7 +87,10 @@ export const convertToBuildEsQuery = ({ }) => { try { return JSON.stringify( - buildEsQuery(indexPattern, queries, filters.filter(f => f.meta.disabled === false), config) + buildEsQuery(indexPattern, queries, filters.filter(f => f.meta.disabled === false), { + ...config, + dateFormatTZ: null, + }) ); } catch (exp) { return ''; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx index fc969974609ea..7c54745f872a9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx @@ -52,7 +52,7 @@ interface HostsComponentDispatchProps { }>; } -export type HostsQueryProps = { timezone?: string } & GlobalTimeArgs; +export type HostsQueryProps = GlobalTimeArgs; export type HostsComponentProps = HostsComponentReduxProps & HostsComponentDispatchProps & diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_body.tsx index 242c66bb3a9ee..3d7e54b4a19ac 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts_body.tsx @@ -33,7 +33,6 @@ const HostsBodyComponent = memo( query, setAbsoluteRangeDatePicker, setQuery, - timezone, to, }) => { const core = useKibanaCore(); @@ -55,7 +54,6 @@ const HostsBodyComponent = memo( skip: isInitializing, setQuery, startDate: from, - timezone, type: hostsModel.HostsType.page, indexPattern, narrowDateRange: (score: Anomaly, interval: string) => { diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/index.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/index.tsx index 840c65af8229e..6596d4c65c00e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/index.tsx @@ -20,8 +20,6 @@ import { HostsTableType } from '../../store/hosts/model'; import { GlobalTime } from '../../containers/global_time'; import { SiemPageName } from '../home/types'; import { Hosts } from './hosts'; -import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; -import { DEFAULT_TIMEZONE_BROWSER } from '../../../common/constants'; const hostsPagePath = `/:pageName(${SiemPageName.hosts})`; @@ -42,223 +40,214 @@ const getHostDetailsTabPath = (pagePath: string) => type Props = Partial> & { url: string }; -export const HostsContainer = React.memo(({ url }) => { - const [timezone] = useKibanaUiSetting(DEFAULT_TIMEZONE_BROWSER); - return ( - - {({ to, from, setQuery, deleteQuery, isInitializing }) => ( - - ( +export const HostsContainer = React.memo(({ url }) => ( + + {({ to, from, setQuery, deleteQuery, isInitializing }) => ( + + ( + ( + <> + + + + )} + /> + )} + /> + ( + <> + ( - <> - - - + )} /> - )} - /> - ( - <> - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - )} - /> - ( - <> - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - )} - /> - ( - ( + + )} + /> + ( + + )} + /> + ( + + )} /> - )} - /> - ( - ( + + )} + /> + + )} + /> + ( + <> + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} /> - )} - /> - - )} - - ); -}); + + )} + /> + ( + + )} + /> + ( + + )} + /> + + )} + +)); HostsContainer.displayName = 'HostsContainer'; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx index 53bf73a1b9b7b..808565cadfa46 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/events_query_tab_body.tsx @@ -21,7 +21,6 @@ export const EventsQueryTabBody = ({ filterQuery, setQuery, startDate, - timezone, updateDateRange = () => {}, }: HostsComponentsQueryProps) => { return ( @@ -31,7 +30,6 @@ export const EventsQueryTabBody = ({ filterQuery={filterQuery} sourceId="default" startDate={startDate} - timezone={timezone} type={hostsModel.HostsType.page} > {({ eventsOverTime, loading, id, inspect, refetch, totalCount }) => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts index 552426602cdc5..d567038a05bd8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/navigation/types.ts @@ -29,7 +29,6 @@ interface QueryTabBodyProps { type: hostsModel.HostsType; startDate: number; endDate: number; - timezone?: string; filterQuery?: string | ESTermQuery; } diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index a87d321fc68d2..776efeebd6ddb 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -29,8 +29,6 @@ export interface TimerangeInput { to: number; /** The beginning of the timerange */ from: number; - /** The default browser set time zone */ - timezone?: Maybe; } export interface PaginationInputPaginated { diff --git a/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts index 357c1c24119bb..e655485638e16 100644 --- a/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/events/query.events_over_time.dsl.ts @@ -8,7 +8,7 @@ import { RequestBasicOptions } from '../framework'; export const buildEventsOverTimeQuery = ({ filterQuery, - timerange: { from, timezone, to }, + timerange: { from, to }, defaultIndex, sourceConfiguration: { fields: { timestamp }, @@ -21,7 +21,6 @@ export const buildEventsOverTimeQuery = ({ [timestamp]: { gte: from, lte: to, - ...(timezone && { time_zone: timezone }), }, }, }, diff --git a/x-pack/test/api_integration/apis/siem/events_over_time.ts b/x-pack/test/api_integration/apis/siem/events_over_time.ts index 253dbbab77c3c..10b81734b7b79 100644 --- a/x-pack/test/api_integration/apis/siem/events_over_time.ts +++ b/x-pack/test/api_integration/apis/siem/events_over_time.ts @@ -29,7 +29,6 @@ export default function({ getService }: FtrProviderContext) { interval: '12h', to: TO, from: FROM, - timezone: 'America/Denver', }, defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], inspect: false, @@ -70,7 +69,6 @@ export default function({ getService }: FtrProviderContext) { interval: '12h', to: TO, from: FROM, - timezone: 'America/Denver', }, defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], inspect: false, From adc204e7e63ab7f264530a0a455eb8c69f26f546 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 24 Oct 2019 13:06:35 -0700 Subject: [PATCH 6/9] disable flaky suite (#48617) --- x-pack/test/api_integration/apis/code/feature_controls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/code/feature_controls.ts b/x-pack/test/api_integration/apis/code/feature_controls.ts index a96b3074e4f81..c72ff362a9a9c 100644 --- a/x-pack/test/api_integration/apis/code/feature_controls.ts +++ b/x-pack/test/api_integration/apis/code/feature_controls.ts @@ -121,7 +121,8 @@ export default function featureControlsTests({ getService }: FtrProviderContext) } } - describe('feature controls', () => { + // FLAKY: https://github.com/elastic/kibana/issues/48617 + describe.skip('feature controls', () => { const codeAdminUsername = 'code_admin_user'; const codeAdminRoleName = 'code_admin_role'; const codeAdminUserPassword = `${codeAdminUsername}-password`; From 48313f73f63eae135f68766d8579603b9622f4dd Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 24 Oct 2019 14:57:46 -0600 Subject: [PATCH 7/9] [Maps] add unique count metric aggregation (#48961) * [Maps] add unique count metric aggregation * do not format unique_count aggregation results * do not format value in legend for unique count * update heatmap docs * one more doc change --- docs/maps/heatmap-layer.asciidoc | 4 +-- .../legacy/plugins/maps/common/constants.js | 9 +++++++ .../maps/public/components/metric_editor.js | 13 ++++++---- .../maps/public/components/metric_select.js | 17 +++++++++---- .../maps/public/components/metrics_editor.js | 3 ++- .../resources/metrics_expression.js | 8 +++--- .../es_geo_grid_source/es_geo_grid_source.js | 19 +++++++++----- .../update_source_editor.js | 3 ++- .../es_pew_pew_source/es_pew_pew_source.js | 19 +++++++++----- .../es_search_source/es_search_source.js | 5 ---- .../maps/public/layers/sources/es_source.js | 22 ++++++++-------- .../public/layers/sources/es_term_source.js | 25 ++++++++++++------- .../tooltips/es_aggmetric_tooltip_property.js | 3 ++- 13 files changed, 94 insertions(+), 56 deletions(-) diff --git a/docs/maps/heatmap-layer.asciidoc b/docs/maps/heatmap-layer.asciidoc index 9d456c59b69ef..77b6d929a931c 100644 --- a/docs/maps/heatmap-layer.asciidoc +++ b/docs/maps/heatmap-layer.asciidoc @@ -13,6 +13,6 @@ You can create a heat map layer from the following data source: Set *Show as* to *heat map*. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point]. -NOTE: Only count and sum metric aggregations are available with the grid aggregation source and heat map layers. -Mean, median, min, and max are turned off because the heat map will blend nearby values. +NOTE: Only count, sum, unique count metric aggregations are available with the grid aggregation source and heat map layers. +Average, min, and max are turned off because the heat map will blend nearby values. Blending two average values would make the cluster more prominent, even though it just might literally mean that these nearby areas are average. diff --git a/x-pack/legacy/plugins/maps/common/constants.js b/x-pack/legacy/plugins/maps/common/constants.js index 1fd1f4b43bbda..942d0a21123c2 100644 --- a/x-pack/legacy/plugins/maps/common/constants.js +++ b/x-pack/legacy/plugins/maps/common/constants.js @@ -102,3 +102,12 @@ export const DRAW_TYPE = { BOUNDS: 'BOUNDS', POLYGON: 'POLYGON' }; + +export const METRIC_TYPE = { + AVG: 'avg', + COUNT: 'count', + MAX: 'max', + MIN: 'min', + SUM: 'sum', + UNIQUE_COUNT: 'cardinality', +}; diff --git a/x-pack/legacy/plugins/maps/public/components/metric_editor.js b/x-pack/legacy/plugins/maps/public/components/metric_editor.js index f3123d5645ccd..03d364f3d84a9 100644 --- a/x-pack/legacy/plugins/maps/public/components/metric_editor.js +++ b/x-pack/legacy/plugins/maps/public/components/metric_editor.js @@ -12,6 +12,7 @@ import { EuiFieldText, EuiFormRow } from '@elastic/eui'; import { MetricSelect, METRIC_AGGREGATION_VALUES } from './metric_select'; import { SingleFieldSelect } from './single_field_select'; +import { METRIC_TYPE } from '../../common/constants'; export function MetricEditor({ fields, metricsFilter, metric, onChange, removeButton }) { const onAggChange = metricAggregationType => { @@ -34,10 +35,12 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu }; let fieldSelect; - if (metric.type && metric.type !== 'count') { - const filterNumberFields = field => { - return field.type === 'number'; - }; + if (metric.type && metric.type !== METRIC_TYPE.COUNT) { + const filterField = metric.type !== METRIC_TYPE.UNIQUE_COUNT + ? field => { + return field.type === 'number'; + } + : undefined; fieldSelect = ( { - if (type === 'count') { + if (type === METRIC_TYPE.COUNT) { return true; } @@ -69,7 +71,7 @@ export class MetricsExpression extends Component { }) .map(({ type, field }) => { // do not use metric label so field and aggregation are not obscured. - if (type === 'count') { + if (type === METRIC_TYPE.COUNT) { return 'count'; } @@ -127,6 +129,6 @@ MetricsExpression.propTypes = { MetricsExpression.defaultProps = { metrics: [ - { type: 'count' } + { type: METRIC_TYPE.COUNT } ] }; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index 8416ef5709e30..776980e17bb13 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -21,7 +21,7 @@ import { RENDER_AS } from './render_as'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; import { GRID_RESOLUTION } from '../../grid_resolution'; -import { SOURCE_DATA_ID_ORIGIN, ES_GEO_GRID } from '../../../../common/constants'; +import { SOURCE_DATA_ID_ORIGIN, ES_GEO_GRID, METRIC_TYPE } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -36,9 +36,16 @@ const aggSchemas = new Schemas([ title: 'Value', min: 1, max: Infinity, - aggFilter: ['avg', 'count', 'max', 'min', 'sum'], + aggFilter: [ + METRIC_TYPE.AVG, + METRIC_TYPE.COUNT, + METRIC_TYPE.MAX, + METRIC_TYPE.MIN, + METRIC_TYPE.SUM, + METRIC_TYPE.UNIQUE_COUNT + ], defaults: [ - { schema: 'metric', type: 'count' } + { schema: 'metric', type: METRIC_TYPE.COUNT } ] }, { @@ -215,11 +222,11 @@ export class ESGeoGridSource extends AbstractESSource { } _formatMetricKey(metric) { - return metric.type !== 'count' ? `${metric.type}_of_${metric.field}` : COUNT_PROP_NAME; + return metric.type !== METRIC_TYPE.COUNT ? `${metric.type}_of_${metric.field}` : COUNT_PROP_NAME; } _formatMetricLabel(metric) { - return metric.type !== 'count' ? `${metric.type} of ${metric.field}` : COUNT_PROP_LABEL; + return metric.type !== METRIC_TYPE.COUNT ? `${metric.type} of ${metric.field}` : COUNT_PROP_LABEL; } _makeAggConfigs(precision) { @@ -231,7 +238,7 @@ export class ESGeoGridSource extends AbstractESSource { schema: 'metric', params: {} }; - if (metric.type !== 'count') { + if (metric.type !== METRIC_TYPE.COUNT) { metricAggConfig.params = { field: metric.field }; } return metricAggConfig; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js index bed8236da1be8..9ea5093724ed9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js @@ -8,6 +8,7 @@ import React, { Fragment, Component } from 'react'; import { RENDER_AS } from './render_as'; import { MetricsEditor } from '../../../components/metrics_editor'; +import { METRIC_TYPE } from '../../../../common/constants'; import { indexPatternService } from '../../../kibana_services'; import { ResolutionEditor } from './resolution_editor'; import { i18n } from '@kbn/i18n'; @@ -66,7 +67,7 @@ export class UpdateSourceEditor extends Component { this.props.renderAs === RENDER_AS.HEATMAP ? metric => { //these are countable metrics, where blending heatmap color blobs make sense - return ['count', 'sum'].includes(metric.value); + return [METRIC_TYPE.COUNT, METRIC_TYPE.SUM, METRIC_TYPE.UNIQUE_COUNT].includes(metric.value); } : null; const allowMultipleMetrics = this.props.renderAs !== RENDER_AS.HEATMAP; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js index 4c081d386b3d7..3debfdf1541f7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js @@ -15,7 +15,7 @@ import { UpdateSourceEditor } from './update_source_editor'; import { VectorStyle } from '../../styles/vector_style'; import { vectorStyles } from '../../styles/vector_style_defaults'; import { i18n } from '@kbn/i18n'; -import { SOURCE_DATA_ID_ORIGIN, ES_PEW_PEW } from '../../../../common/constants'; +import { SOURCE_DATA_ID_ORIGIN, ES_PEW_PEW, METRIC_TYPE } from '../../../../common/constants'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { convertToLines } from './convert_to_lines'; import { Schemas } from 'ui/vis/editors/default/schemas'; @@ -32,9 +32,16 @@ const aggSchemas = new Schemas([ title: 'Value', min: 1, max: Infinity, - aggFilter: ['avg', 'count', 'max', 'min', 'sum'], + aggFilter: [ + METRIC_TYPE.AVG, + METRIC_TYPE.COUNT, + METRIC_TYPE.MAX, + METRIC_TYPE.MIN, + METRIC_TYPE.SUM, + METRIC_TYPE.UNIQUE_COUNT + ], defaults: [ - { schema: 'metric', type: 'count' } + { schema: 'metric', type: METRIC_TYPE.COUNT } ] } ]); @@ -193,7 +200,7 @@ export class ESPewPewSource extends AbstractESSource { schema: 'metric', params: {} }; - if (metric.type !== 'count') { + if (metric.type !== METRIC_TYPE.COUNT) { metricAggConfig.params = { field: metric.field }; } return metricAggConfig; @@ -252,11 +259,11 @@ export class ESPewPewSource extends AbstractESSource { } _formatMetricKey(metric) { - return metric.type !== 'count' ? `${metric.type}_of_${metric.field}` : COUNT_PROP_NAME; + return metric.type !== METRIC_TYPE.COUNT ? `${metric.type}_of_${metric.field}` : COUNT_PROP_NAME; } _formatMetricLabel(metric) { - return metric.type !== 'count' ? `${metric.type} of ${metric.field}` : COUNT_PROP_LABEL; + return metric.type !== METRIC_TYPE.COUNT ? `${metric.type} of ${metric.field}` : COUNT_PROP_LABEL; } async _getGeoField() { diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 7c248e332d403..d9c48424bb77d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -512,9 +512,4 @@ export class ESSearchSource extends AbstractESSource { path: geoField.name, }; } - - _getRawFieldName(fieldName) { - // fieldName is rawFieldName for documents source since the source uses raw documents instead of aggregated metrics - return fieldName; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js index 0670474df89bb..85c866479a6ba 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js @@ -19,7 +19,7 @@ import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_pro import uuid from 'uuid/v4'; import { copyPersistentState } from '../../reducers/util'; -import { ES_GEO_FIELD_TYPE } from '../../../common/constants'; +import { ES_GEO_FIELD_TYPE, METRIC_TYPE } from '../../../common/constants'; import { DataRequestAbortError } from '../util/data_request'; export class AbstractESSource extends AbstractVectorSource { @@ -59,7 +59,7 @@ export class AbstractESSource extends AbstractVectorSource { _getValidMetrics() { const metrics = _.get(this._descriptor, 'metrics', []).filter(({ type, field }) => { - if (type === 'count') { + if (type === METRIC_TYPE.COUNT) { return true; } @@ -69,7 +69,7 @@ export class AbstractESSource extends AbstractVectorSource { return false; }); if (metrics.length === 0) { - metrics.push({ type: 'count' }); + metrics.push({ type: METRIC_TYPE.COUNT }); } return metrics; } @@ -300,18 +300,13 @@ export class AbstractESSource extends AbstractVectorSource { return this._descriptor.id; } - _getRawFieldName(fieldName) { + async getFieldFormatter(fieldName) { const metricField = this.getMetricFields().find(({ propertyKey }) => { return propertyKey === fieldName; }); - return metricField ? metricField.field : null; - } - - async getFieldFormatter(fieldName) { - // fieldName could be an aggregation so it needs to be unpacked to expose raw field. - const rawFieldName = this._getRawFieldName(fieldName); - if (!rawFieldName) { + // Do not use field formatters for counting metrics + if (metricField && metricField.type === METRIC_TYPE.COUNT || metricField.type === METRIC_TYPE.UNIQUE_COUNT) { return null; } @@ -322,7 +317,10 @@ export class AbstractESSource extends AbstractVectorSource { return null; } - const fieldFromIndexPattern = indexPattern.fields.getByName(rawFieldName); + const realFieldName = metricField + ? metricField.field + : fieldName; + const fieldFromIndexPattern = indexPattern.fields.getByName(realFieldName); if (!fieldFromIndexPattern) { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js index 5d876dbbd011f..1f5adc00cca6f 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js @@ -11,7 +11,7 @@ import { Schemas } from 'ui/vis/editors/default/schemas'; import { AggConfigs } from 'ui/agg_types'; import { i18n } from '@kbn/i18n'; import { ESTooltipProperty } from '../tooltips/es_tooltip_property'; -import { ES_SIZE_LIMIT } from '../../../common/constants'; +import { ES_SIZE_LIMIT, METRIC_TYPE } from '../../../common/constants'; const TERMS_AGG_NAME = 'join'; @@ -22,9 +22,16 @@ const aggSchemas = new Schemas([ title: 'Value', min: 1, max: Infinity, - aggFilter: ['avg', 'count', 'max', 'min', 'sum'], + aggFilter: [ + METRIC_TYPE.AVG, + METRIC_TYPE.COUNT, + METRIC_TYPE.MAX, + METRIC_TYPE.MIN, + METRIC_TYPE.SUM, + METRIC_TYPE.UNIQUE_COUNT + ], defaults: [ - { schema: 'metric', type: 'count' } + { schema: 'metric', type: METRIC_TYPE.COUNT } ] }, { @@ -81,12 +88,12 @@ export class ESTermSource extends AbstractESSource { } _formatMetricKey(metric) { - const metricKey = metric.type !== 'count' ? `${metric.type}_of_${metric.field}` : metric.type; + const metricKey = metric.type !== METRIC_TYPE.COUNT ? `${metric.type}_of_${metric.field}` : metric.type; return `__kbnjoin__${metricKey}_groupby_${this._descriptor.indexPatternTitle}.${this._descriptor.term}`; } _formatMetricLabel(metric) { - const metricLabel = metric.type !== 'count' ? `${metric.type} ${metric.field}` : 'count'; + const metricLabel = metric.type !== METRIC_TYPE.COUNT ? `${metric.type} ${metric.field}` : 'count'; return `${metricLabel} of ${this._descriptor.indexPatternTitle}:${this._descriptor.term}`; } @@ -108,13 +115,13 @@ export class ESTermSource extends AbstractESSource { const metricPropertyNames = configStates .filter(configState => { - return configState.schema === 'metric' && configState.type !== 'count'; + return configState.schema === 'metric' && configState.type !== METRIC_TYPE.COUNT; }) .map(configState => { return configState.id; }); const countConfigState = configStates.find(configState => { - return configState.type === 'count'; + return configState.type === METRIC_TYPE.COUNT; }); const countPropertyName = _.get(countConfigState, 'id'); return { @@ -128,7 +135,7 @@ export class ESTermSource extends AbstractESSource { _getRequestDescription(leftSourceName, leftFieldName) { const metrics = this._getValidMetrics().map(metric => { - return metric.type !== 'count' ? `${metric.type} ${metric.field}` : 'count'; + return metric.type !== METRIC_TYPE.COUNT ? `${metric.type} ${metric.field}` : 'count'; }); const joinStatement = []; joinStatement.push(i18n.translate('xpack.maps.source.esJoin.joinLeftDescription', { @@ -157,7 +164,7 @@ export class ESTermSource extends AbstractESSource { schema: 'metric', params: {} }; - if (metric.type !== 'count') { + if (metric.type !== METRIC_TYPE.COUNT) { metricAggConfig.params = { field: metric.field }; } return metricAggConfig; diff --git a/x-pack/legacy/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js b/x-pack/legacy/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js index 11cbb36f49593..42629e192c27d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js @@ -6,6 +6,7 @@ import { ESTooltipProperty } from './es_tooltip_property'; +import { METRIC_TYPE } from '../../../common/constants'; export class ESAggMetricTooltipProperty extends ESTooltipProperty { @@ -21,7 +22,7 @@ export class ESAggMetricTooltipProperty extends ESTooltipProperty { if (typeof this._rawValue === 'undefined') { return '-'; } - if (this._metricField.type === 'count') { + if (this._metricField.type === METRIC_TYPE.COUNT || this._metricField.type === METRIC_TYPE.UNIQUE_COUNT) { return this._rawValue; } const indexPatternField = this._indexPattern.fields.getByName(this._metricField.field); From f601ab4c5410a60b3fdb23dde11688fbb91b55d2 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 24 Oct 2019 17:26:24 -0500 Subject: [PATCH 8/9] [SIEM] Fields browser, auto selects category bugfix (#48999) --- .../fields_browser/category_columns.test.tsx | 4 +- .../fields_browser/category_columns.tsx | 4 +- .../components/fields_browser/index.test.tsx | 55 +++++++++++++++++-- .../components/fields_browser/index.tsx | 11 ++-- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx index 6406c4609a266..0a8fd9b54f4aa 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx @@ -112,7 +112,7 @@ describe('getCategoryColumns', () => { expect( wrapper.find(`.field-browser-category-pane-${selectedCategoryId}-${timelineId}`).first() - ).toHaveStyleRule('font-weight', 'bold'); + ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); }); test('it does NOT render an un-selected category with bold text', () => { @@ -135,7 +135,7 @@ describe('getCategoryColumns', () => { expect( wrapper.find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`).first() - ).toHaveStyleRule('font-weight', 'normal'); + ).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' }); }); test('it invokes onCategorySelected when a user clicks a category', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx index 2581fba75da1e..1845a0bae88d5 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx @@ -26,7 +26,9 @@ import { LoadingSpinner, getCategoryPaneCategoryClassName, getFieldCount } from import * as i18n from './translations'; const CategoryName = styled.span<{ bold: boolean }>` - font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')}; + .euiText { + font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')}; + } `; CategoryName.displayName = 'CategoryName'; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx index 4c9c1fc4147ab..2a13d1549d90a 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx @@ -13,9 +13,18 @@ import { TestProviders } from '../../mock'; import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from './helpers'; -import { StatefulFieldsBrowser } from '.'; - +import { INPUT_TIMEOUT, StatefulFieldsBrowser } from '.'; +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('StatefulFieldsBrowser', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); const timelineId = 'test'; test('it renders the Fields button, which displays the fields browser on click', () => { @@ -85,6 +94,9 @@ describe('StatefulFieldsBrowser', () => { }); describe('updateSelectedCategoryId', () => { + beforeEach(() => { + jest.setTimeout(10000); + }); test('it updates the selectedCategoryId state, which makes the category bold, when the user clicks a category name in the left hand side of the field browser', () => { const wrapper = mount( @@ -111,10 +123,45 @@ describe('StatefulFieldsBrowser', () => { .simulate('click'); wrapper.update(); - expect( wrapper.find(`.field-browser-category-pane-auditd-${timelineId}`).first() - ).toHaveStyleRule('font-weight', 'bold'); + ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); + }); + test('it updates the selectedCategoryId state according to most fields returned', done => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="show-field-browser"]') + .first() + .simulate('click'); + expect( + wrapper.find(`.field-browser-category-pane-cloud-${timelineId}`).first() + ).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' }); + wrapper + .find('[data-test-subj="field-search"]') + .last() + .simulate('change', { target: { value: 'cloud' } }); + + setTimeout(() => { + wrapper.update(); + expect( + wrapper.find(`.field-browser-category-pane-cloud-${timelineId}`).first() + ).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' }); + wrapper.unmount(); + done(); + }, INPUT_TIMEOUT); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 7d21e1f44d04b..a58721eb5a87f 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -23,7 +23,7 @@ import { FieldBrowserProps } from './types'; const fieldsButtonClassName = 'fields-button'; /** wait this many ms after the user completes typing before applying the filter input */ -const INPUT_TIMEOUT = 250; +export const INPUT_TIMEOUT = 250; const FieldsBrowserButtonContainer = styled.div` position: relative; @@ -94,19 +94,18 @@ export const StatefulFieldsBrowserComponent = React.memo { const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), - substring: filterInput, + substring: newFilterInput, }); setFilteredBrowserFields(newFilteredBrowserFields); setIsSearching(false); const newSelectedCategoryId = - filterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 + newFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 ? DEFAULT_CATEGORY_NAME : Object.keys(newFilteredBrowserFields) .sort() @@ -114,8 +113,8 @@ export const StatefulFieldsBrowserComponent = React.memo newFilteredBrowserFields[category].fields != null && newFilteredBrowserFields[selected].fields != null && - newFilteredBrowserFields[category].fields!.length > - newFilteredBrowserFields[selected].fields!.length + Object.keys(newFilteredBrowserFields[category].fields!).length > + Object.keys(newFilteredBrowserFields[selected].fields!).length ? category : selected, Object.keys(newFilteredBrowserFields)[0] From 39aa43992973a713241110d6c431d208ffe0beed Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 24 Oct 2019 17:27:00 -0500 Subject: [PATCH 9/9] [SIEM] Table columns, number related design tweaks (#48969) --- .../field_renderers.test.tsx.snap | 3 +- .../field_renderers/field_renderers.test.tsx | 6 +-- .../field_renderers/field_renderers.tsx | 14 ++---- .../components/formatted_date/index.test.tsx | 45 ++++++++++++++++++- .../components/formatted_date/index.tsx | 34 ++++++++++++++ .../components/last_event_time/index.test.tsx | 3 +- .../components/last_event_time/index.tsx | 21 ++++----- .../get_anomalies_host_table_columns.tsx | 10 +---- .../get_anomalies_network_table_columns.tsx | 11 ++--- .../timelines_table/common_columns.tsx | 4 +- .../hosts/authentications_table/index.tsx | 15 +++---- .../page/hosts/first_last_seen_host/index.tsx | 9 ++-- .../page/hosts/hosts_table/columns.tsx | 10 +---- .../hosts/uncommon_process_table/index.tsx | 4 ++ .../page/network/ip_overview/index.tsx | 10 ++++- .../network/network_dns_table/columns.tsx | 4 ++ .../page/network/users_table/columns.tsx | 1 + .../containers/events/last_event_time/mock.ts | 8 ++-- 18 files changed, 135 insertions(+), 77 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/field_renderers/__snapshots__/field_renderers.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/field_renderers/__snapshots__/field_renderers.test.tsx.snap index 928f62cffd720..6ae9268966480 100644 --- a/x-pack/legacy/plugins/siem/public/components/field_renderers/__snapshots__/field_renderers.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/field_renderers/__snapshots__/field_renderers.test.tsx.snap @@ -35,8 +35,7 @@ exports[`Field Renderers #autonomousSystemRenderer it renders correctly against exports[`Field Renderers #dateRenderer it renders correctly against snapshot 1`] = ` - diff --git a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.test.tsx b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.test.tsx index 6306dc0d288cf..0fd63bc3f2bf2 100644 --- a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.test.tsx @@ -60,16 +60,14 @@ describe('Field Renderers', () => { describe('#dateRenderer', () => { test('it renders correctly against snapshot', () => { const wrapper = shallow( - {dateRenderer('firstSeen', mockData.complete.source!)} + {dateRenderer(mockData.complete.source!.firstSeen)} ); expect(toJson(wrapper)).toMatchSnapshot(); }); test('it renders emptyTagValue when invalid field provided', () => { - const wrapper = mount( - {dateRenderer('geo.spark_plug', mockData.complete.source!)} - ); + const wrapper = mount({dateRenderer(null)}); expect(wrapper.text()).toEqual(getEmptyValue()); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx index 1045f9c52e5e1..ffad36c7f9396 100644 --- a/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/field_renderers/field_renderers.tsx @@ -11,17 +11,11 @@ import React, { Fragment, useState } from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; -import { - AutonomousSystem, - FlowTarget, - HostEcsFields, - IpOverviewData, - Overview, -} from '../../graphql/types'; +import { AutonomousSystem, FlowTarget, HostEcsFields, IpOverviewData } from '../../graphql/types'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { DefaultDraggable } from '../draggables'; import { getEmptyTagValue } from '../empty_value'; -import { FormattedDate } from '../formatted_date'; +import { FormattedRelativePreferenceDate } from '../formatted_date'; import { HostDetailsLink, ReputationLink, VirusTotalLink, WhoIsLink } from '../links'; import { Spacer } from '../page'; import * as i18n from '../page/network/ip_overview/translations'; @@ -58,8 +52,8 @@ export const locationRenderer = (fieldNames: string[], data: IpOverviewData): Re getEmptyTagValue() ); -export const dateRenderer = (fieldName: string, data: Overview): React.ReactElement => ( - +export const dateRenderer = (timestamp?: string | null): React.ReactElement => ( + ); export const autonomousSystemRenderer = ( diff --git a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.test.tsx index a66ec399a838d..bb0b947f149f4 100644 --- a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.test.tsx @@ -13,8 +13,8 @@ import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; import { mockFrameworks, TestProviders, MockFrameworks, getMockKibanaUiSetting } from '../../mock'; -import { PreferenceFormattedDate, FormattedDate } from '.'; -import { getEmptyValue } from '../empty_value'; +import { PreferenceFormattedDate, FormattedDate, FormattedRelativePreferenceDate } from '.'; +import { getEmptyString, getEmptyValue } from '../empty_value'; const mockUseKibanaUiSetting: jest.Mock = useKibanaUiSetting as jest.Mock; jest.mock('../../lib/settings/use_kibana_ui_setting', () => ({ @@ -162,4 +162,45 @@ describe('formatted_date', () => { }); }); }); + + describe('FormattedRelativePreferenceDate', () => { + describe('rendering', () => { + test('renders time over an hour correctly against snapshot', () => { + const isoDateString = '2019-02-25T22:27:05.000Z'; + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="preference-time"]').exists()).toBe(true); + }); + test('renders time under an hour correctly against snapshot', () => { + const timeTwelveMinutesAgo = new Date(new Date().getTime() - 12 * 60 * 1000).toISOString(); + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="relative-time"]').exists()).toBe(true); + }); + test('renders empty string value correctly', () => { + const wrapper = mount( + + + + ); + expect(wrapper.text()).toBe(getEmptyString()); + }); + + test('renders undefined value correctly', () => { + const wrapper = mount( + + + + ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('renders null value correctly', () => { + const wrapper = mount( + + + + ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx index 22a3893cf0f98..32c064096fcf9 100644 --- a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx @@ -6,6 +6,7 @@ import moment from 'moment-timezone'; import * as React from 'react'; +import { FormattedRelative } from '@kbn/i18n/react'; import { pure } from 'recompose'; import { @@ -62,3 +63,36 @@ export const FormattedDate = pure<{ ); FormattedDate.displayName = 'FormattedDate'; + +/** + * Renders the specified date value according to under/over one hour + * Under an hour = relative format + * Over an hour = in a format determined by the user's preferences, + * with a tooltip that renders: + * - the name of the field + * - a humanized relative date (e.g. 16 minutes ago) + * - a long representation of the date that includes the day of the week (e.g. Thursday, March 21, 2019 6:47pm) + * - the raw date value (e.g. 2019-03-22T00:47:46Z) + */ + +export const FormattedRelativePreferenceDate = ({ value }: { value?: string | number | null }) => { + if (value == null) { + return getOrEmptyTagFromValue(value); + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return getOrEmptyTagFromValue(value); + } + const date = maybeDate.toDate(); + return ( + + {moment(date) + .add(1, 'hours') + .isBefore(new Date()) ? ( + + ) : ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/last_event_time/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/last_event_time/index.test.tsx index bcf5e3c1de408..c23a757647a42 100644 --- a/x-pack/legacy/plugins/siem/public/components/last_event_time/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/last_event_time/index.test.tsx @@ -53,8 +53,7 @@ describe('Last Event Time Stat', () => { ); - - expect(wrapper.html()).toBe('Last event: 12 days ago'); + expect(wrapper.html()).toBe('Last event: 12 minutes ago'); }); test('Bad date time string', async () => { mockUseLastEventTimeQuery.mockImplementation(() => ({ diff --git a/x-pack/legacy/plugins/siem/public/components/last_event_time/index.tsx b/x-pack/legacy/plugins/siem/public/components/last_event_time/index.tsx index cb9895bebcf09..2493a1378e944 100644 --- a/x-pack/legacy/plugins/siem/public/components/last_event_time/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/last_event_time/index.tsx @@ -5,18 +5,20 @@ */ import { EuiIcon, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; -import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { memo } from 'react'; import { LastEventIndexKey } from '../../graphql/types'; import { useLastEventTimeQuery } from '../../containers/events/last_event_time'; import { getEmptyTagValue } from '../empty_value'; +import { FormattedRelativePreferenceDate } from '../formatted_date'; export interface LastEventTimeProps { hostName?: string; indexKey: LastEventIndexKey; ip?: string; } + export const LastEventTime = memo(({ hostName, indexKey, ip }) => { const { loading, lastSeen, errorMessage } = useLastEventTimeQuery( indexKey, @@ -37,6 +39,7 @@ export const LastEventTime = memo(({ hostName, indexKey, ip ); } + return ( <> {loading && } @@ -44,15 +47,13 @@ export const LastEventTime = memo(({ hostName, indexKey, ip ? lastSeen : !loading && lastSeen != null && ( - - , - }} - /> - + , + }} + /> )} {!loading && lastSeen == null && getEmptyTagValue()} diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx index 6650449dd8200..daac4835adb28 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; -import moment from 'moment'; import { Columns } from '../../paginated_table'; import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; @@ -18,10 +17,9 @@ import * as i18n from './translations'; import { getEntries } from '../get_entries'; import { DraggableScore } from '../score/draggable_score'; import { createExplorerLink } from '../links/create_explorer_link'; -import { LocalizedDateTooltip } from '../../localized_date_tooltip'; -import { PreferenceFormattedDate } from '../../formatted_date'; import { HostsType } from '../../../store/hosts/model'; import { escapeDataProviderId } from '../../drag_and_drop/helpers'; +import { FormattedRelativePreferenceDate } from '../../formatted_date'; export const getAnomaliesHostTableColumns = ( startDate: number, @@ -126,11 +124,7 @@ export const getAnomaliesHostTableColumns = ( name: i18n.TIME_STAMP, field: 'anomaly.time', sortable: true, - render: time => ( - - - - ), + render: time => , }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx index 1e1628fb077dd..2113d3b82f52e 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; -import moment from 'moment'; + import { Columns } from '../../paginated_table'; import { Anomaly, NarrowDateRange, AnomaliesByNetwork } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; @@ -18,8 +18,7 @@ import * as i18n from './translations'; import { getEntries } from '../get_entries'; import { DraggableScore } from '../score/draggable_score'; import { createExplorerLink } from '../links/create_explorer_link'; -import { LocalizedDateTooltip } from '../../localized_date_tooltip'; -import { PreferenceFormattedDate } from '../../formatted_date'; +import { FormattedRelativePreferenceDate } from '../../formatted_date'; import { NetworkType } from '../../../store/network/model'; import { escapeDataProviderId } from '../../drag_and_drop/helpers'; @@ -120,11 +119,7 @@ export const getAnomaliesNetworkTableColumns = ( name: i18n.TIME_STAMP, field: 'anomaly.time', sortable: true, - render: time => ( - - - - ), + render: time => , }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx index 9818a33385286..8eeb29794f5a6 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx @@ -14,7 +14,7 @@ import { NotePreviews } from '../note_previews'; import * as i18n from '../translations'; import { OnOpenTimeline, OnToggleShowNotes, OpenTimelineResult } from '../types'; import { getEmptyTagValue } from '../../empty_value'; -import { FormattedDate } from '../../formatted_date'; +import { FormattedRelativePreferenceDate } from '../../formatted_date'; /** * Returns the column definitions (passed as the `columns` prop to @@ -96,7 +96,7 @@ export const getCommonColumns = ({ render: (date: number, timelineResult: OpenTimelineResult) => (
{timelineResult.updated != null ? ( - + ) : ( getEmptyTagValue() )} diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx index fe280c2899327..b9b132b4f50a4 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/authentications_table/index.tsx @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; import { has } from 'lodash/fp'; import React from 'react'; import { connect } from 'react-redux'; @@ -18,6 +16,7 @@ import { hostsModel, hostsSelectors, State } from '../../../../store'; import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; +import { FormattedRelativePreferenceDate } from '../../../formatted_date'; import { HostDetailsLink, IPDetailsLink } from '../../../links'; import { Columns, ItemsPerRow, PaginatedTable } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; @@ -200,6 +199,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ /> ); }, + width: '8%', }, { name: i18n.FAILURES, @@ -237,16 +237,15 @@ const getAuthenticationColumns = (): AuthTableColumns => [ /> ); }, + width: '8%', }, { name: i18n.LAST_SUCCESSFUL_TIME, truncateText: false, hideForMobile: false, render: ({ node }) => - has('lastSuccess.timestamp', node) ? ( - - - + has('lastSuccess.timestamp', node) && node.lastSuccess!.timestamp != null ? ( + ) : ( getEmptyTagValue() ), @@ -291,9 +290,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ hideForMobile: false, render: ({ node }) => has('lastFailure.timestamp', node) && node.lastFailure!.timestamp != null ? ( - - - + ) : ( getEmptyTagValue() ), diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/first_last_seen_host/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/first_last_seen_host/index.tsx index 665f1f46bc7c1..553615e950b8d 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/first_last_seen_host/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/first_last_seen_host/index.tsx @@ -5,15 +5,14 @@ */ import { EuiIcon, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui'; -import moment from 'moment'; + import React from 'react'; import { ApolloConsumer } from 'react-apollo'; import { pure } from 'recompose'; import { useFirstLastSeenHostQuery } from '../../../../containers/hosts/first_last_seen'; import { getEmptyTagValue } from '../../../empty_value'; -import { PreferenceFormattedDate } from '../../../formatted_date'; -import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; +import { FormattedRelativePreferenceDate } from '../../../formatted_date'; export enum FirstLastSeenHostType { FIRST_SEEN = 'first-seen', @@ -52,9 +51,7 @@ export const FirstLastSeenHost = pure<{ hostname: string; type: FirstLastSeenHos : !loading && valueSeen != null && ( - - - + )} {!loading && valueSeen == null && getEmptyTagValue()} diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx index b626df58b007f..8d490d2c152d9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/columns.tsx @@ -5,14 +5,12 @@ */ import { EuiIcon, EuiToolTip } from '@elastic/eui'; -import moment from 'moment'; import React from 'react'; import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; -import { PreferenceFormattedDate } from '../../../formatted_date'; import { HostDetailsLink } from '../../../links'; -import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; +import { FormattedRelativePreferenceDate } from '../../../formatted_date'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { Provider } from '../../../timeline/data_providers/provider'; import { AddFilterToGlobalSearchBar, createFilter } from '../../add_filter_to_global_search_bar'; @@ -75,11 +73,7 @@ export const getHostsColumns = (): HostsTableColumns => [ sortable: true, render: lastSeen => { if (lastSeen != null) { - return ( - - - - ); + return ; } return getEmptyTagValue(); }, diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/uncommon_process_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/uncommon_process_table/index.tsx index 6fd2cab90efea..2f2d84306e25e 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/uncommon_process_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/uncommon_process_table/index.tsx @@ -161,16 +161,20 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [ width: '20%', }, { + align: 'right', name: i18n.NUMBER_OF_HOSTS, truncateText: false, hideForMobile: false, render: ({ node }) => <>{node.hosts != null ? node.hosts.length : getEmptyValue()}, + width: '8%', }, { + align: 'right', name: i18n.NUMBER_OF_INSTANCES, truncateText: false, hideForMobile: false, render: ({ node }) => defaultToEmptyTag(node.instances), + width: '8%', }, { name: i18n.HOSTS, diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx index 7c695e37386ef..b71d786e643eb 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -114,8 +114,14 @@ export const IpOverview = pure( const descriptionLists: Readonly = [ firstColumn, [ - { title: i18n.FIRST_SEEN, description: dateRenderer('firstSeen', typeData) }, - { title: i18n.LAST_SEEN, description: dateRenderer('lastSeen', typeData) }, + { + title: i18n.FIRST_SEEN, + description: typeData ? dateRenderer(typeData.firstSeen) : getEmptyTagValue(), + }, + { + title: i18n.LAST_SEEN, + description: typeData ? dateRenderer(typeData.lastSeen) : getEmptyTagValue(), + }, ], [ { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx index 353699c5158bc..b1c1b26cd498d 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx @@ -69,6 +69,7 @@ export const getNetworkDnsColumns = (type: networkModel.NetworkType): NetworkDns }, }, { + align: 'right', field: `node.${NetworkDnsFields.queryCount}`, name: i18n.TOTAL_QUERIES, sortable: true, @@ -83,6 +84,7 @@ export const getNetworkDnsColumns = (type: networkModel.NetworkType): NetworkDns }, }, { + align: 'right', field: `node.${NetworkDnsFields.uniqueDomains}`, name: i18n.UNIQUE_DOMAINS, sortable: true, @@ -97,6 +99,7 @@ export const getNetworkDnsColumns = (type: networkModel.NetworkType): NetworkDns }, }, { + align: 'right', field: `node.${NetworkDnsFields.dnsBytesIn}`, name: i18n.DNS_BYTES_IN, sortable: true, @@ -111,6 +114,7 @@ export const getNetworkDnsColumns = (type: networkModel.NetworkType): NetworkDns }, }, { + align: 'right', field: `node.${NetworkDnsFields.dnsBytesOut}`, name: i18n.DNS_BYTES_OUT, sortable: true, diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx index 2c51fb8f94561..b732ac5bfd5fa 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx @@ -73,6 +73,7 @@ export const getUsersColumns = (flowTarget: FlowTarget, tableId: string): UsersC }), }, { + align: 'right', field: 'node.user.count', name: i18n.DOCUMENT_COUNT, truncateText: false, diff --git a/x-pack/legacy/plugins/siem/public/containers/events/last_event_time/mock.ts b/x-pack/legacy/plugins/siem/public/containers/events/last_event_time/mock.ts index 09cdfbd07bffd..ca8786077851f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/events/last_event_time/mock.ts +++ b/x-pack/legacy/plugins/siem/public/containers/events/last_event_time/mock.ts @@ -28,11 +28,11 @@ interface MockLastEventTimeQuery { }; } -const getTimeTwelveDaysAgo = () => { +const getTimeTwelveMinutesAgo = () => { const d = new Date(); const ts = d.getTime(); - const twelveDays = ts - 12 * 24 * 60 * 60 * 1000; - return new Date(twelveDays).toISOString(); + const twelveMinutes = ts - 12 * 60 * 1000; + return new Date(twelveMinutes).toISOString(); }; export const mockLastEventTimeQuery: MockLastEventTimeQuery[] = [ @@ -51,7 +51,7 @@ export const mockLastEventTimeQuery: MockLastEventTimeQuery[] = [ source: { id: 'default', LastEventTime: { - lastSeen: getTimeTwelveDaysAgo(), + lastSeen: getTimeTwelveMinutesAgo(), errorMessage: null, }, },