diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index be8b7c27495a..a484c856ec01 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -121,6 +121,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | +| [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) | Additional data to extend a response when rendering a new body | | [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreResponse interceptor for incoming request. | | [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md new file mode 100644 index 000000000000..ab5b5e7a4f27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [body](./kibana-plugin-core-server.onpreresponserender.body.md) + +## OnPreResponseRender.body property + +the body to use in the response + +Signature: + +```typescript +body: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md new file mode 100644 index 000000000000..100d12f63d16 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) + +## OnPreResponseRender.headers property + +additional headers to attach to the response + +Signature: + +```typescript +headers?: ResponseHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md new file mode 100644 index 000000000000..0a7ce2d54670 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) + +## OnPreResponseRender interface + +Additional data to extend a response when rendering a new body + +Signature: + +```typescript +export interface OnPreResponseRender +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [body](./kibana-plugin-core-server.onpreresponserender.body.md) | string | the body to use in the response | +| [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) | ResponseHeaders | additional headers to attach to the response | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 44da09d0cc68..14070038132d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -17,4 +17,5 @@ export interface OnPreResponseToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreresponsetoolkit.next.md) | (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult | To pass request to the next handler | +| [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) | (responseRender: OnPreResponseRender) => OnPreResponseResult | To override the response with a different body | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md new file mode 100644 index 000000000000..7dced7fe8dee --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) > [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) + +## OnPreResponseToolkit.render property + +To override the response with a different body + +Signature: + +```typescript +render: (responseRender: OnPreResponseRender) => OnPreResponseResult; +``` diff --git a/package.json b/package.json index 5137af553fff..9f9ad9ead709 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "@babel/register": "^7.10.5", "@babel/types": "^7.11.0", "@elastic/apm-rum": "^5.6.1", - "@elastic/charts": "23.1.1", + "@elastic/charts": "23.2.1", "@elastic/ems-client": "7.10.0", "@elastic/eslint-config-kibana": "0.15.0", "@elastic/eslint-plugin-eui": "0.0.2", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 6f13e461cccb..e5ebb874e58a 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,7 +9,7 @@ "kbn:watch": "node scripts/build --dev --watch" }, "dependencies": { - "@elastic/charts": "23.1.1", + "@elastic/charts": "23.2.1", "@elastic/eui": "29.3.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 9deaa73d8aac..6aad232cf42b 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -175,6 +175,7 @@ type ToolkitMock = jest.Mocked { return { + render: jest.fn(), next: jest.fn(), rewriteUrl: jest.fn(), }; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index f81708145edc..df837dc35505 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -198,6 +198,7 @@ const createAuthToolkitMock = (): jest.Mocked => ({ }); const createOnPreResponseToolkitMock = (): jest.Mocked => ({ + render: jest.fn(), next: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 7513e6096608..cb842b2f6026 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -83,6 +83,7 @@ export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, } from './lifecycle/on_pre_response'; diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index b9548bf7a8d7..59090d101acb 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -1286,6 +1286,67 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); + + it('supports rendering a different response body', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ body: 'overridden' }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('A'); + }); + + it('supports rendering a different response body + headers', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + 'Original-Header-B': 'B', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ + headers: { + 'Original-Header-A': 'AA', + 'New-Header-C': 'C', + }, + body: 'overridden', + }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('AA'); + expect(result.header['original-header-b']).toBe('B'); + expect(result.header['new-header-c']).toBe('C'); + }); }); describe('run interceptors in the right order', () => { diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 4d1b53313a51..37dddf4dd476 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -17,16 +17,23 @@ * under the License. */ -import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Lifecycle, Request, ResponseObject, ResponseToolkit as HapiResponseToolkit } from 'hapi'; import Boom from 'boom'; import { Logger } from '../../logging'; import { HapiResponseAdapter, KibanaRequest, ResponseHeaders } from '../router'; enum ResultType { + render = 'render', next = 'next', } +interface Render { + type: ResultType.render; + body: string; + headers?: ResponseHeaders; +} + interface Next { type: ResultType.next; headers?: ResponseHeaders; @@ -35,7 +42,18 @@ interface Next { /** * @internal */ -type OnPreResponseResult = Next; +type OnPreResponseResult = Render | Next; + +/** + * Additional data to extend a response when rendering a new body + * @public + */ +export interface OnPreResponseRender { + /** additional headers to attach to the response */ + headers?: ResponseHeaders; + /** the body to use in the response */ + body: string; +} /** * Additional data to extend a response. @@ -55,6 +73,12 @@ export interface OnPreResponseInfo { } const preResponseResult = { + render(responseRender: OnPreResponseRender): OnPreResponseResult { + return { type: ResultType.render, body: responseRender.body, headers: responseRender?.headers }; + }, + isRender(result: OnPreResponseResult): result is Render { + return result && result.type === ResultType.render; + }, next(responseExtensions?: OnPreResponseExtensions): OnPreResponseResult { return { type: ResultType.next, headers: responseExtensions?.headers }; }, @@ -68,11 +92,14 @@ const preResponseResult = { * @public */ export interface OnPreResponseToolkit { + /** To override the response with a different body */ + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; /** To pass request to the next handler */ next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } const toolkit: OnPreResponseToolkit = { + render: preResponseResult.render, next: preResponseResult.next, }; @@ -106,26 +133,36 @@ export function adoptToHapiOnPreResponseFormat(fn: OnPreResponseHandler, log: Lo : response.statusCode; const result = await fn(KibanaRequest.from(request), { statusCode }, toolkit); - if (!preResponseResult.isNext(result)) { + + if (preResponseResult.isNext(result)) { + if (result.headers) { + if (isBoom(response)) { + findHeadersIntersection(response.output.headers, result.headers, log); + // hapi wraps all error response in Boom object internally + response.output.headers = { + ...response.output.headers, + ...(result.headers as any), // hapi types don't specify string[] as valid value + }; + } else { + findHeadersIntersection(response.headers, result.headers, log); + setHeaders(response, result.headers); + } + } + } else if (preResponseResult.isRender(result)) { + const overriddenResponse = responseToolkit.response(result.body).code(statusCode); + + const originalHeaders = isBoom(response) ? response.output.headers : response.headers; + setHeaders(overriddenResponse, originalHeaders); + if (result.headers) { + setHeaders(overriddenResponse, result.headers); + } + + return overriddenResponse; + } else { throw new Error( `Unexpected result from OnPreResponse. Expected OnPreResponseResult, but given: ${result}.` ); } - if (result.headers) { - if (isBoom(response)) { - findHeadersIntersection(response.output.headers, result.headers, log); - // hapi wraps all error response in Boom object internally - response.output.headers = { - ...response.output.headers, - ...(result.headers as any), // hapi types don't specify string[] as valid value - }; - } else { - findHeadersIntersection(response.headers, result.headers, log); - for (const [headerName, headerValue] of Object.entries(result.headers)) { - response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value - } - } - } } } catch (error) { log.error(error); @@ -140,6 +177,12 @@ function isBoom(response: any): response is Boom { return response instanceof Boom; } +function setHeaders(response: ResponseObject, headers: ResponseHeaders) { + for (const [headerName, headerValue] of Object.entries(headers)) { + response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value + } +} + // NOTE: responseHeaders contains not a full list of response headers, but only explicitly set on a response object. // any headers added by hapi internally, like `content-type`, `content-length`, etc. are not present here. function findHeadersIntersection( diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 887dc50d5f78..fc091bd17bdf 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -173,6 +173,7 @@ export { OnPostAuthToolkit, OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, RedirectResponseOptions, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index a718ae8a6ff1..a877700a48bc 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1530,9 +1530,16 @@ export interface OnPreResponseInfo { statusCode: number; } +// @public +export interface OnPreResponseRender { + body: string; + headers?: ResponseHeaders; +} + // @public export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; } // Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 77c4a2235d47..5713996ca9f7 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { AppMountParameters } from 'kibana/public'; import { ViewMode } from '../../embeddable_plugin'; import { TopNavIds } from './top_nav_ids'; import { NavAction } from '../../types'; @@ -31,7 +32,8 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + hideWriteControls: boolean, + onAppLeave?: AppMountParameters['onAppLeave'] ) { switch (dashboardMode) { case ViewMode.VIEW: diff --git a/src/plugins/visualize/public/application/app.tsx b/src/plugins/visualize/public/application/app.tsx index 8dd6b2ace841..bf11cde3115a 100644 --- a/src/plugins/visualize/public/application/app.tsx +++ b/src/plugins/visualize/public/application/app.tsx @@ -21,6 +21,7 @@ import './app.scss'; import React, { useEffect } from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; +import { AppMountParameters } from 'kibana/public'; import { syncQueryStateWithUrl } from '../../../data/public'; import { useKibana } from '../../../kibana_react/public'; import { VisualizeServices } from './types'; @@ -32,7 +33,11 @@ import { } from './components'; import { VisualizeConstants } from './visualize_constants'; -export const VisualizeApp = () => { +export interface VisualizeAppProps { + onAppLeave: AppMountParameters['onAppLeave']; +} + +export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { const { services: { data: { query }, @@ -54,10 +59,10 @@ export const VisualizeApp = () => { return ( - + - + { +export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { const [originatingApp, setOriginatingApp] = useState(); const { services } = useKibana(); const [eventEmitter] = useState(new EventEmitter()); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [embeddableId, setEmbeddableId] = useState(); const [valueInput, setValueInput] = useState(); @@ -100,6 +101,7 @@ export const VisualizeByValueEditor = () => { setHasUnsavedChanges={setHasUnsavedChanges} visEditorRef={visEditorRef} embeddableId={embeddableId} + onAppLeave={onAppLeave} /> ); }; diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx index 6a0bd26a16fa..7c0fa065c3a7 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -32,8 +32,9 @@ import { } from '../utils'; import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; +import { VisualizeAppProps } from '../app'; -export const VisualizeEditor = () => { +export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { const { id: visualizationIdFromUrl } = useParams<{ id: string }>(); const [originatingApp, setOriginatingApp] = useState(); const { services } = useKibana(); @@ -91,6 +92,7 @@ export const VisualizeEditor = () => { visualizationIdFromUrl={visualizationIdFromUrl} setHasUnsavedChanges={setHasUnsavedChanges} visEditorRef={visEditorRef} + onAppLeave={onAppLeave} /> ); }; diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index 545552b90555..947385b05d45 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -20,6 +20,7 @@ import './visualize_editor.scss'; import React, { RefObject } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiScreenReaderOnly } from '@elastic/eui'; +import { AppMountParameters } from 'kibana/public'; import { VisualizeTopNav } from './visualize_top_nav'; import { ExperimentalVisInfo } from './experimental_vis_info'; import { @@ -38,6 +39,7 @@ interface VisualizeEditorCommonProps { setHasUnsavedChanges: (value: boolean) => void; hasUnappliedChanges: boolean; isEmbeddableRendered: boolean; + onAppLeave: AppMountParameters['onAppLeave']; visEditorRef: RefObject; originatingApp?: string; setOriginatingApp?: (originatingApp: string | undefined) => void; @@ -54,6 +56,7 @@ export const VisualizeEditorCommon = ({ setHasUnsavedChanges, hasUnappliedChanges, isEmbeddableRendered, + onAppLeave, originatingApp, setOriginatingApp, visualizationIdFromUrl, @@ -76,6 +79,7 @@ export const VisualizeEditorCommon = ({ stateContainer={appState} visualizationIdFromUrl={visualizationIdFromUrl} embeddableId={embeddableId} + onAppLeave={onAppLeave} /> )} {visInstance?.vis?.type?.stage === 'experimental' && } diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index dfd3c09f51ed..b207529c456a 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -19,7 +19,9 @@ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; -import { OverlayRef } from 'kibana/public'; +import { AppMountParameters, OverlayRef } from 'kibana/public'; +import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices, @@ -43,6 +45,7 @@ interface VisualizeTopNavProps { stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; embeddableId?: string; + onAppLeave: AppMountParameters['onAppLeave']; } const TopNav = ({ @@ -58,10 +61,11 @@ const TopNav = ({ stateContainer, visualizationIdFromUrl, embeddableId, + onAppLeave, }: VisualizeTopNavProps) => { const { services } = useKibana(); const { TopNavMenu } = services.navigation.ui; - const { setHeaderActionMenu } = services; + const { setHeaderActionMenu, visualizeCapabilities } = services; const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); const openInspector = useCallback(() => { @@ -93,6 +97,7 @@ const TopNav = ({ visualizationIdFromUrl, stateTransfer, embeddableId, + onAppLeave, }, services ); @@ -111,6 +116,7 @@ const TopNav = ({ services, embeddableId, stateTransfer, + onAppLeave, ]); const [indexPattern, setIndexPattern] = useState(vis.data.indexPattern); const showDatePicker = () => { @@ -131,6 +137,33 @@ const TopNav = ({ }; }, [inspectorSession]); + useEffect(() => { + onAppLeave((actions) => { + // Confirm when the user has made any changes to an existing visualizations + // or when the user has configured something without saving + if ( + ((originatingApp && originatingApp === 'dashboards') || originatingApp === 'canvas') && + (hasUnappliedChanges || hasUnsavedChanges) + ) { + return actions.confirm( + i18n.translate('visualize.confirmModal.confirmTextDescription', { + defaultMessage: 'Leave Visualize editor with unsaved changes?', + }), + i18n.translate('visualize.confirmModal.title', { + defaultMessage: 'Unsaved changes', + }) + ); + } + return actions.default(); + }); + }, [ + onAppLeave, + hasUnappliedChanges, + hasUnsavedChanges, + visualizeCapabilities.save, + originatingApp, + ]); + useEffect(() => { if (!vis.data.indexPattern) { services.data.indexPatterns.getDefault().then((index) => { diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx index 4bec244e6efc..1067fe613e46 100644 --- a/src/plugins/visualize/public/application/index.tsx +++ b/src/plugins/visualize/public/application/index.tsx @@ -27,7 +27,10 @@ import { VisualizeApp } from './app'; import { VisualizeServices } from './types'; import { addHelpMenuToAppChrome, addBadgeToAppChrome } from './utils'; -export const renderApp = ({ element }: AppMountParameters, services: VisualizeServices) => { +export const renderApp = ( + { element, onAppLeave }: AppMountParameters, + services: VisualizeServices +) => { // add help link to visualize docs into app chrome menu addHelpMenuToAppChrome(services.chrome, services.docLinks); // add readonly badge if saving restricted @@ -39,7 +42,7 @@ export const renderApp = ({ element }: AppMountParameters, services: VisualizeSe - + diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index cb68a647cb81..eadf404daf91 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -21,6 +21,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { TopNavMenuData } from 'src/plugins/navigation/public'; +import { AppMountParameters } from 'kibana/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { showSaveModal, @@ -51,6 +52,7 @@ interface TopNavConfigParams { visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; embeddableId?: string; + onAppLeave: AppMountParameters['onAppLeave']; } export const getTopNavConfig = ( @@ -66,6 +68,7 @@ export const getTopNavConfig = ( visualizationIdFromUrl, stateTransfer, embeddableId, + onAppLeave, }: TopNavConfigParams, { application, @@ -174,6 +177,12 @@ export const getTopNavConfig = ( stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { state }); }; + const navigateToOriginatingApp = () => { + if (originatingApp) { + application.navigateToApp(originatingApp); + } + }; + const topNavMenu: TopNavMenuData[] = [ { id: 'inspector', @@ -225,6 +234,31 @@ export const getTopNavConfig = ( // disable the Share button if no action specified disableButton: !share || !!embeddableId, }, + ...(originatingApp === 'dashboards' || originatingApp === 'canvas' + ? [ + { + id: 'cancel', + label: i18n.translate('visualize.topNavMenu.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + emphasize: false, + description: i18n.translate('visualize.topNavMenu.cancelButtonAriaLabel', { + defaultMessage: 'Return to the last app without saving changes', + }), + testId: 'visualizeCancelAndReturnButton', + tooltip() { + if (hasUnappliedChanges || hasUnsavedChanges) { + return i18n.translate('visualize.topNavMenu.cancelAndReturnButtonTooltip', { + defaultMessage: 'Discard your changes before finishing', + }); + } + }, + run: async () => { + return navigateToOriginatingApp(); + }, + }, + ] + : []), ...(visualizeCapabilities.save && !embeddableId ? [ { @@ -297,6 +331,9 @@ export const getTopNavConfig = ( /> ); const isSaveAsButton = anchorElement.classList.contains('saveAsButton'); + onAppLeave((actions) => { + return actions.default(); + }); if ( originatingApp === 'dashboards' && dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && @@ -342,6 +379,9 @@ export const getTopNavConfig = ( confirmOverwrite: false, returnToOrigin: true, }; + onAppLeave((actions) => { + return actions.default(); + }); if ( originatingApp === 'dashboards' && dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js new file mode 100644 index 000000000000..a9bd2e87bcad --- /dev/null +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -0,0 +1,100 @@ +/* + * 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 expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'visEditor']); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardVisualizations = getService('dashboardVisualizations'); + + const originalMarkdownText = 'Original markdown text'; + const modifiedMarkdownText = 'Modified markdown text'; + + const createMarkdownVis = async (title) => { + await testSubjects.click('dashboardAddNewPanelButton'); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); + await PageObjects.visEditor.clickGo(); + await PageObjects.visualize.saveVisualizationExpectSuccess(title, { + saveAsNew: true, + redirectToOrigin: true, + }); + }; + + const editMarkdownVis = async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visEditor.setMarkdownTxt(modifiedMarkdownText); + await PageObjects.visEditor.clickGo(); + }; + + describe('edit visualizations from dashboard', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + }); + + it('save button returns to dashboard after editing visualization with changes saved', async () => { + const title = 'test save'; + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(title); + + await editMarkdownVis(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(modifiedMarkdownText); + }); + + it('cancel button returns to dashboard after editing visualization without saving', async () => { + const title = 'test cancel'; + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(title); + + await editMarkdownVis(); + await PageObjects.visualize.cancelAndReturn(true); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); + }); + + it('cancel button returns to dashboard with no modal if there are no changes to apply', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.visualize.cancelAndReturn(false); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index de4b3df9c40e..f722e91dc0ae 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -53,6 +53,7 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); loadTestFile(require.resolve('./edit_embeddable_redirects')); + loadTestFile(require.resolve('./edit_visualizations')); loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); loadTestFile(require.resolve('./data_shared_attributes')); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 459f596b3025..41667e1f26c8 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -434,7 +434,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } - async getBodyText() { + async getJsonBodyText() { if (await find.existsByCssSelector('a[id=rawdata-tab]', defaultFindTimeout)) { // Firefox has 3 tabs and requires navigation to see Raw output await find.clickByCssSelector('a[id=rawdata-tab]'); @@ -449,6 +449,11 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } + async getBodyText() { + const body = await find.byCssSelector('body'); + return await body.getVisibleText(); + } + /** * Helper to detect an OSS licensed Kibana * Useful for functional testing in cloud environment diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 332ce835d0b1..bc256f55155d 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -26,17 +26,11 @@ export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { class ErrorPage { public async expectForbidden() { const messageText = await common.getBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 403, - error: 'Forbidden', - message: 'Forbidden', - }) - ); + expect(messageText).to.contain('You do not have permission to access the requested page'); } public async expectNotFound() { - const messageText = await common.getBodyText(); + const messageText = await common.getJsonBodyText(); expect(messageText).to.eql( JSON.stringify({ statusCode: 404, diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 6d94c3e581d6..9619c81370cd 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -363,6 +363,20 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide await header.waitUntilLoadingHasFinished(); await testSubjects.missingOrFail('visualizesaveAndReturnButton'); } + + public async cancelAndReturn(showConfirmModal: boolean) { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('visualizeCancelAndReturnButton'); + await testSubjects.click('visualizeCancelAndReturnButton'); + if (showConfirmModal) { + await retry.waitFor( + 'confirm modal to show', + async () => await testSubjects.exists('appLeaveConfirmModal') + ); + await testSubjects.exists('confirmModalConfirmButton'); + await testSubjects.click('confirmModalConfirmButton'); + } + } } return new VisualizePage(); diff --git a/x-pack/package.json b/x-pack/package.json index 1dc8b9aa7df5..941ebab2f3d6 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -73,7 +73,6 @@ "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", - "@types/dragselect": "^1.13.1", "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", @@ -165,7 +164,6 @@ "cypress-promise": "^1.1.0", "d3": "3.5.17", "d3-scale": "1.0.7", - "dragselect": "1.13.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-utils": "^1.13.0", diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts index b8bfeffb2293..75b9a9c80416 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts @@ -55,7 +55,7 @@ Then(`it should filter results based on query`, () => { listOfUrls.should('have.length', 1); const actualUrlsText = [ - 'http://opbeans-node:3000/customersPage views: 10Page load duration: 76 ms ', + 'http://opbeans-node:3000/customersPage views: 10Page load duration: 76 ms', ]; cy.get('li.euiSelectableListItem') diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx new file mode 100644 index 000000000000..efc52e7cb426 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -0,0 +1,55 @@ +/* + * 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 React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { EnvironmentFilter } from '../../../shared/EnvironmentFilter'; +import { ServiceNameFilter } from '../URLFilter/ServiceNameFilter'; +import { useFetcher } from '../../../../hooks/useFetcher'; +import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { UserPercentile } from '../UserPercentile'; + +export function MainFilters() { + const { + urlParams: { start, end }, + } = useUrlParams(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/rum-client/services', + params: { + query: { + start, + end, + uiFilters: JSON.stringify({ agentName: RUM_AGENT_NAMES }), + }, + }, + }); + } + }, + [start, end] + ); + + return ( + <> + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx deleted file mode 100644 index 6b3fcb3b0346..000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { DatePicker } from '../../../shared/DatePicker'; - -export function RumHeader({ children }: { children: ReactNode }) { - return ( - <> - - {children} - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index f30f9ba5af25..d1cfe1d63f88 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -8,9 +8,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { RumOverview } from '../RumDashboard'; -import { RumHeader } from './RumHeader'; -import { UserPercentile } from './UserPercentile'; import { CsmSharedContextProvider } from './CsmSharedContext'; +import { MainFilters } from './Panels/MainFilters'; +import { DatePicker } from '../../shared/DatePicker'; export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { defaultMessage: 'User Experience', @@ -19,18 +19,25 @@ export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { export function RumHome() { return ( - - - - -

{UX_LABEL}

-
-
- - - -
-
+ + + +

{UX_LABEL}

+
+
+ + + + + + + + +
); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx index f10c9e888a19..cf419f6edffc 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiHorizontalRule, - EuiSelect, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; @@ -66,22 +61,17 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { return ( <> - -

- {i18n.translate('xpack.apm.localFilters.titles.serviceName', { - defaultMessage: 'Service name', - })} -

-
- - - { updateServiceName(event.target.value); }} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index ebca1df17038..d9d3d2329937 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FormEvent, SetStateAction, useRef, useState } from 'react'; +import React, { + FormEvent, + SetStateAction, + useRef, + useState, + KeyboardEvent, +} from 'react'; import { - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, @@ -14,13 +19,41 @@ import { EuiPopoverTitle, EuiSelectable, EuiSelectableMessage, + EuiPopoverFooter, + EuiButton, + EuiText, + EuiIcon, + EuiBadge, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { useEvent } from 'react-use'; import { formatOptions, selectableRenderOptions, UrlOption, } from './RenderOption'; import { I18LABELS } from '../../translations'; +import { useUiSetting$ } from '../../../../../../../../../src/plugins/kibana_react/public'; + +const StyledRow = styled.div<{ + darkMode: boolean; +}>` + text-align: center; + padding: 8px 0px; + background-color: ${(props) => + props.darkMode + ? euiDarkVars.euiPageBackgroundColor + : euiLightVars.euiPageBackgroundColor}; + border-bottom: 1px solid + ${(props) => + props.darkMode + ? euiDarkVars.euiColorLightestShade + : euiLightVars.euiColorLightestShade}; +`; interface Props { data: { @@ -48,11 +81,23 @@ export function SelectableUrlList({ popoverIsOpen, setPopoverIsOpen, }: Props) { + const [darkMode] = useUiSetting$('theme:darkMode'); + const [popoverRef, setPopoverRef] = useState(null); const [searchRef, setSearchRef] = useState(null); const titleRef = useRef(null); + const onEnterKey = (evt: KeyboardEvent) => { + if (evt.key.toLowerCase() === 'enter') { + onTermChange(); + setPopoverIsOpen(false); + } + }; + + // @ts-ignore - not sure, why it's not working + useEvent('keydown', onEnterKey, searchRef); + const searchOnFocus = (e: React.FocusEvent) => { setPopoverIsOpen(true); }; @@ -102,22 +147,10 @@ export function SelectableUrlList({ function PopOverTitle() { return ( - + {loading ? : titleText} - - { - onTermChange(); - setPopoverIsOpen(false); - }} - > - {I18LABELS.matchThisQuery} - - ); @@ -142,6 +175,7 @@ export function SelectableUrlList({ listProps={{ rowHeight: 68, showIcons: true, + onFocusBadge: false, }} loadingMessage={loadingMessage} emptyMessage={emptyMessage} @@ -158,7 +192,43 @@ export function SelectableUrlList({ >
+ {searchValue && ( + + + {searchValue}, + icon: ( + + Enter + + ), + }} + /> + + + )} {list} + + + + { + onTermChange(); + closePopover(); + }} + > + {i18n.translate('xpack.apm.apply.label', { + defaultMessage: 'Apply', + })} + + + +
)} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx index 5ad666cd466b..661f4406990f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTitle } from '@elastic/eui'; import useDebounce from 'react-use/lib/useDebounce'; import React, { useEffect, useState, FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { EuiTitle } from '@elastic/eui'; import { useUrlParams } from '../../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../../hooks/useFetcher'; import { I18LABELS } from '../../translations'; @@ -24,7 +24,9 @@ interface Props { export function URLSearch({ onChange: onFilterChange }: Props) { const history = useHistory(); - const { uiFilters } = useUrlParams(); + const { uiFilters, urlParams } = useUrlParams(); + + const { searchTerm } = urlParams; const [popoverIsOpen, setPopoverIsOpen] = useState(false); @@ -84,6 +86,12 @@ export function URLSearch({ onChange: onFilterChange }: Props) { setCheckedUrls(uiFilters.transactionUrl || []); }, [uiFilters]); + useEffect(() => { + if (searchTerm && searchValue === '') { + updateSearchTerm(''); + } + }, [searchValue, updateSearchTerm, searchTerm]); + const onChange = (updatedOptions: UrlOption[]) => { const clickedItems = updatedOptions.filter( (option) => option.checked === 'on' @@ -121,7 +129,7 @@ export function URLSearch({ onChange: onFilterChange }: Props) { return ( <> - +

{I18LABELS.url}

{ const search = omit(toQuery(history.location.search), name); @@ -42,20 +32,6 @@ export function URLFilter() { }); }; - const updateSearchTerm = useCallback( - (searchTermN?: string) => { - const newLocation = { - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - searchTerm: searchTermN, - }), - }; - history.push(newLocation); - }, - [history] - ); - const name = 'transactionUrl'; const { uiFilters } = useUrlParams(); @@ -65,44 +41,25 @@ export function URLFilter() { return ( - { setFilterValue('transactionUrl', value); }} /> - - {searchTerm && ( + {filterValue.length > 0 && ( <> - { - updateSearchTerm(); - }} - onClickAriaLabel={removeSearchTermLabel} - iconOnClick={() => { - updateSearchTerm(); - }} - iconOnClickAriaLabel={removeSearchTermLabel} - iconType="cross" - iconSide="right" - > - *{searchTerm}* - + { + setFilterValue( + name, + filterValue.filter((v) => val !== v) + ); + }} + value={filterValue} + /> )} - {filterValue.length > 0 && ( - { - setFilterValue( - name, - filterValue.filter((v) => val !== v) - ); - }} - value={filterValue} - /> - )} - ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx index 2ce724e7fec8..75a018afa13d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx @@ -44,8 +44,7 @@ export function UserPercentile() { if (!percentile) { updatePercentile(DEFAULT_P); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const options = [ { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts index da2ac5260219..16396dc9fc15 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts @@ -15,7 +15,7 @@ export function useUxQuery() { const queryParams = useMemo(() => { const { serviceName } = uiFilters; - if (start && end && serviceName) { + if (start && end && serviceName && percentile) { return { start, end, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index a04d145555b1..ed084a91df6d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -5,22 +5,13 @@ */ import React, { useMemo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiSpacer, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { RumDashboard } from './RumDashboard'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { RUM_AGENT_NAMES } from '../../../../common/agent_name'; -import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; -import { URLFilter } from './URLFilter'; + import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { ServiceNameFilter } from './URLFilter/ServiceNameFilter'; +import { URLFilter } from './URLFilter'; export function RumOverview() { useTrackPageview({ app: 'ux', path: 'home' }); @@ -35,46 +26,14 @@ export function RumOverview() { return config; }, []); - const { - urlParams: { start, end }, - } = useUrlParams(); - - const { data, status } = useFetcher( - (callApmApi) => { - if (start && end) { - return callApmApi({ - pathname: '/api/apm/rum-client/services', - params: { - query: { - start, - end, - uiFilters: JSON.stringify({ agentName: RUM_AGENT_NAMES }), - }, - }, - }); - } - }, - [start, end] - ); - return ( <> - - - - <> - - - - {' '} - + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index afb09db7bd97..c8db011874a8 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -92,7 +92,7 @@ export const I18LABELS = { } ), searchByUrl: i18n.translate('xpack.apm.rum.filters.searchByUrl', { - defaultMessage: 'Search by url', + defaultMessage: 'Search by URL', }), getSearchResultsLabel: (total: number) => i18n.translate('xpack.apm.rum.filters.searchResults', { @@ -108,9 +108,6 @@ export const I18LABELS = { url: i18n.translate('xpack.apm.rum.filters.url', { defaultMessage: 'Url', }), - matchThisQuery: i18n.translate('xpack.apm.rum.filters.url.matchThisQuery', { - defaultMessage: 'Match this query', - }), loadingResults: i18n.translate('xpack.apm.rum.filters.url.loadingResults', { defaultMessage: 'Loading results', }), diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts index 73a24bc11fe6..2b3501554b8d 100644 --- a/x-pack/plugins/ml/common/constants/anomalies.ts +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -21,6 +21,15 @@ export enum ANOMALY_THRESHOLD { LOW = 0, } +export const SEVERITY_COLORS = { + CRITICAL: '#fe5050', + MAJOR: '#fba740', + MINOR: '#fdec25', + WARNING: '#8bc8fb', + LOW: '#d2e9f7', + BLANK: '#ffffff', +}; + export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; export const JOB_ID = 'job_id'; export const PARTITION_FIELD_VALUE = 'partition_field_value'; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index d808e4277f07..d527a9a9780a 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -5,6 +5,6 @@ */ export { SearchResponse7 } from './types/es_client'; -export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './constants/anomalies'; +export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies'; export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; export { composeValidators, patternValidator } from './util/validators'; diff --git a/x-pack/plugins/ml/common/types/inference.ts b/x-pack/plugins/ml/common/types/trained_models.ts similarity index 95% rename from x-pack/plugins/ml/common/types/inference.ts rename to x-pack/plugins/ml/common/types/trained_models.ts index ce2cfb1f78fd..35425e74759d 100644 --- a/x-pack/plugins/ml/common/types/inference.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -44,7 +44,7 @@ export interface TrainedModelStat { }; } -export interface ModelConfigResponse { +export interface TrainedModelConfigResponse { created_by: string; create_time: string; default_field_map: Record; @@ -79,5 +79,5 @@ export interface ModelPipelines { * Get inference response from the ES endpoint */ export interface InferenceConfigResponse { - trained_model_configs: ModelConfigResponse[]; + trained_model_configs: TrainedModelConfigResponse[]; } diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts index 16802040059a..28b2f50ae269 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.ts +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; -import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../constants/anomalies'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../constants/anomalies'; import { AnomalyRecordDoc } from '../types/anomalies'; export interface SeverityType { @@ -109,6 +109,13 @@ function getSeverityTypes() { }); } +/** + * Return formatted severity score. + */ +export function getFormattedSeverityScore(score: number): string { + return score < 1 ? '< 1' : String(parseInt(String(score), 10)); +} + // Returns a severity label (one of critical, major, minor, warning or unknown) // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverity(normalizedScore: number): SeverityType { @@ -168,17 +175,17 @@ export function getSeverityWithLow(normalizedScore: number): SeverityType { // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverityColor(normalizedScore: number): string { if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { - return '#fe5050'; + return SEVERITY_COLORS.CRITICAL; } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { - return '#fba740'; + return SEVERITY_COLORS.MAJOR; } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { - return '#fdec25'; + return SEVERITY_COLORS.MINOR; } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { - return '#8bc8fb'; + return SEVERITY_COLORS.WARNING; } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { - return '#d2e9f7'; + return SEVERITY_COLORS.LOW; } else { - return '#ffffff'; + return SEVERITY_COLORS.BLANK; } } diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss index 46e5d91e1cc8..25be39f3ea2d 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss @@ -1,7 +1,6 @@ .mlChartTooltip { @include euiToolTipStyle('s'); @include euiFontSizeXS; - position: absolute; padding: 0; transition: opacity $euiAnimSpeedNormal; pointer-events: none; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 0d94c5ccdfe0..d0ecf65bca44 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -23,6 +23,57 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; +/** + * Pure component for rendering the tooltip content with a custom layout across the ML plugin. + */ +export const FormattedTooltip: FC<{ tooltipData: TooltipData }> = ({ tooltipData }) => { + return ( +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
{renderHeader(tooltipData[0])}
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + // eslint-disable-next-line @typescript-eslint/naming-convention + echTooltip__rowHighlighted: isHighlighted, + }); + + const renderValue = Array.isArray(value) + ? value.map((v) =>
{v}
) + : value; + + return ( +
+ + + {label} + + + {renderValue} + + +
+ ); + })} +
+ )} +
+ ); +}; + +/** + * Tooltip component bundled with the {@link ChartTooltipService} + */ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { const [tooltipData, setData] = useState([]); const refCallback = useRef(); @@ -57,50 +108,9 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) =
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
{renderHeader(tooltipData[0])}
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - // eslint-disable-next-line @typescript-eslint/naming-convention - echTooltip__rowHighlighted: isHighlighted, - }); - - const renderValue = Array.isArray(value) - ? value.map((v) =>
{v}
) - : value; - - return ( -
- - - {label} - - - {renderValue} - - -
- ); - })} -
- )} +
); }) as TooltipTriggerProps['tooltip'], diff --git a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx index a3a67fbb8bb7..97fac052df1f 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx @@ -51,7 +51,7 @@ export const ColumnChart: FC = ({ chartData, columnType, dataTestSubj }) name="count" xScaleType={xScaleType} yScaleType="linear" - xAccessor="key" + xAccessor={'key_as_string'} yAccessors={['doc_count']} styleAccessor={(d) => d.datum.color} data={data} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index b36ee0fc3255..f613c5fdb345 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -45,7 +45,7 @@ export const DataGridTitle: FC<{ title: string }> = ({ title }) => ( interface PropsWithoutHeader extends UseIndexDataReturnType { baseline?: number; - analysisType?: DataFrameAnalysisConfigType; + analysisType?: DataFrameAnalysisConfigType | 'unknown'; resultsField?: string; dataTestSubj: string; toastNotifications: CoreSetup['notifications']['toasts']; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx index 6b5fbbb22120..a3169dc14a3a 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -67,7 +67,7 @@ export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYP interface NumericDataItem { key: number; - key_as_string?: string; + key_as_string?: string | number; doc_count: number; } @@ -231,11 +231,13 @@ export const useColumnChart = ( if (isOrdinalChartData(chartData)) { data = chartData.data.map((d: OrdinalDataItem) => ({ ...d, + key_as_string: d.key_as_string ?? d.key, color: getColor(d), })); } else if (isNumericChartData(chartData)) { data = chartData.data.map((d: NumericDataItem) => ({ ...d, + key_as_string: d.key_as_string || d.key, color: getColor(d), })); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 49f3f2311a93..6e42e3e2f51f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -159,11 +159,13 @@ interface LoadEvaluateResult { error: string | null; } -export const getAnalysisType = (analysis: AnalysisConfig): string => { +export const getAnalysisType = ( + analysis: AnalysisConfig +): DataFrameAnalysisConfigType | 'unknown' => { const keys = Object.keys(analysis); if (keys.length === 1) { - return keys[0]; + return keys[0] as DataFrameAnalysisConfigType; } return 'unknown'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts index 50be009d288f..7d2ca86a3808 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts @@ -19,7 +19,7 @@ import { DataFrameAnalyticsConfig } from '../common'; import { isGetDataFrameAnalyticsStatsResponseOk } from '../pages/analytics_management/services/analytics_service/get_analytics'; import { DATA_FRAME_TASK_STATE } from '../pages/analytics_management/components/analytics_list/common'; -import { useInferenceApiService } from '../../services/ml_api_service/inference'; +import { useTrainedModelsApiService } from '../../services/ml_api_service/trained_models'; import { TotalFeatureImportance } from '../../../../common/types/feature_importance'; import { getToastNotificationService } from '../../services/toast_notification_service'; import { @@ -29,7 +29,7 @@ import { export const useResultsViewConfig = (jobId: string) => { const mlContext = useMlContext(); - const inferenceApiService = useInferenceApiService(); + const trainedModelsApiService = useTrainedModelsApiService(); const [indexPattern, setIndexPattern] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); @@ -74,7 +74,7 @@ export const useResultsViewConfig = (jobId: string) => { isRegressionAnalysis(jobConfigUpdate.analysis) ) { try { - const inferenceModels = await inferenceApiService.getInferenceModel(`${jobId}*`, { + const inferenceModels = await trainedModelsApiService.getTrainedModels(`${jobId}*`, { include: 'total_feature_importance', }); const inferenceModel = inferenceModels.find( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 1d6a603caa81..b59fbe926aa4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -25,6 +25,8 @@ import { ANALYTICS_STEPS } from '../../page'; import { ml } from '../../../../../services/ml_api_service'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; +const DEFAULT_RESULTS_FIELD = 'ml'; + const indexNameExistsMessage = i18n.translate( 'xpack.ml.dataframe.analytics.create.destinationIndexHelpText', { @@ -64,6 +66,10 @@ export const DetailsStepForm: FC = ({ const [destIndexSameAsId, setDestIndexSameAsId] = useState( cloneJob === undefined && hasSwitchedToEditor === false ); + const [useResultsFieldDefault, setUseResultsFieldDefault] = useState( + (cloneJob === undefined && hasSwitchedToEditor === false && resultsField === undefined) || + (cloneJob !== undefined && resultsField === DEFAULT_RESULTS_FIELD) + ); const forceInput = useRef(null); @@ -266,22 +272,46 @@ export const DetailsStepForm: FC = ({ /> )} - - + setFormState({ resultsField: e.target.value })} - data-test-subj="mlAnalyticsCreateJobWizardResultsFieldInput" + name="mlDataFrameAnalyticsUseResultsFieldDefault" + label={i18n.translate('xpack.ml.dataframe.analytics.create.UseResultsFieldDefaultLabel', { + defaultMessage: 'Use results field default value "{defaultValue}"', + values: { defaultValue: DEFAULT_RESULTS_FIELD }, + })} + checked={useResultsFieldDefault === true} + onChange={() => setUseResultsFieldDefault(!useResultsFieldDefault)} + data-test-subj="mlAnalyticsCreateJobWizardUseResultsFieldDefault" /> + {useResultsFieldDefault === false && ( + + setFormState({ resultsField: e.target.value })} + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.create.resultsFieldInputAriaLabel', + { + defaultMessage: + 'The name of the field in which to store the results of the analysis.', + } + )} + data-test-subj="mlAnalyticsCreateJobWizardResultsFieldInput" + /> + + )} = ({ indexData.rowCount, colorRange ); + const analysisType = + jobConfig && jobConfig.analysis ? getAnalysisType(jobConfig.analysis) : undefined; const resultsSectionContent = ( <> {jobConfig !== undefined && needsDestIndexPattern && ( @@ -133,6 +140,7 @@ export const ExpandableSectionResults: FC = ({ {columnsWithCharts.length > 0 && tableItems.length > 0 && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx index c2f3e71e2e49..06bcdfd364d6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -163,7 +163,10 @@ export const ExplorationQueryBar: FC = ({ />
{filters && filters.options && ( - + = ({ } )} name="analyticsQueryBarFilterButtons" - data-test-subj="mlDFAnalyticsExplorationQueryBarFilterButtons" options={filters.options} type="multi" idToSelectedMap={idToSelectedMap} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx index b4efca39e200..0f8b71b112f1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx @@ -345,7 +345,7 @@ export const useNavigateToWizardWithClonedJob = () => { return async (item: DataFrameAnalyticsListRow) => { const sourceIndex = Array.isArray(item.config.source.index) - ? item.config.source.index[0] + ? item.config.source.index.join(',') : item.config.source.index; let sourceIndexId; @@ -363,6 +363,14 @@ export const useNavigateToWizardWithClonedJob = () => { ); if (ip !== undefined) { sourceIndexId = ip.id; + } else { + toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analyticsList.noSourceIndexPatternForClone', { + defaultMessage: + 'Unable to clone the analytics job. No index pattern exists for index {indexPattern}.', + values: { indexPattern: sourceIndex }, + }) + ); } } catch (e) { const error = extractErrorMessage(e); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx index 53043d4e503f..ab069c2d42e8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx @@ -24,9 +24,7 @@ export const useCloneAction = (canCreateDataFrameAnalytics: boolean) => { const action: DataFrameAnalyticsListAction = useMemo( () => ({ - name: (item: DataFrameAnalyticsListRow) => ( - - ), + name: () => , enabled: () => canCreateDataFrameAnalytics, description: cloneActionNameText, icon: 'copy', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx index 3c2ba13a1db2..571bda871d7e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx @@ -35,7 +35,7 @@ export const DeleteModelsModal: FC = ({ models, onClose = ({ models, onClose size="s" > = ({ models, onClose diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx index 803a2523a55e..f18f293ff953 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx @@ -120,7 +120,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'details', name: ( ), @@ -133,7 +133,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -156,7 +156,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'config', name: ( ), @@ -169,7 +169,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -190,7 +190,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -214,7 +214,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'stats', name: ( ), @@ -228,7 +228,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -248,7 +248,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -266,7 +266,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -300,7 +300,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -354,7 +354,7 @@ export const ExpandedRow: FC = ({ item }) => { name: ( <> {' '} {stats.pipeline_count} @@ -390,7 +390,7 @@ export const ExpandedRow: FC = ({ item }) => { }} > @@ -402,7 +402,7 @@ export const ExpandedRow: FC = ({ item }) => {
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index d5a7ca6e96c0..6dd55f1881d7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -24,16 +24,16 @@ import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/bas import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; import { Action } from '@elastic/eui/src/components/basic_table/action_types'; import { StatsBar, ModelsBarStats } from '../../../../../components/stats_bar'; -import { useInferenceApiService } from '../../../../../services/ml_api_service/inference'; +import { useTrainedModelsApiService } from '../../../../../services/ml_api_service/trained_models'; import { ModelsTableToConfigMapping } from './index'; import { DeleteModelsModal } from './delete_models_modal'; import { useMlKibana, useMlUrlGenerator, useNotifications } from '../../../../../contexts/kibana'; import { ExpandedRow } from './expanded_row'; import { - ModelConfigResponse, + TrainedModelConfigResponse, ModelPipelines, TrainedModelStat, -} from '../../../../../../../common/types/inference'; +} from '../../../../../../../common/types/trained_models'; import { getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, @@ -48,7 +48,7 @@ import { timeFormatter } from '../../../../../../../common/util/date_utils'; type Stats = Omit; -export type ModelItem = ModelConfigResponse & { +export type ModelItem = TrainedModelConfigResponse & { type?: string; stats?: Stats; pipelines?: ModelPipelines['pipelines'] | null; @@ -66,7 +66,7 @@ export const ModelsList: FC = () => { const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; - const inferenceApiService = useInferenceApiService(); + const trainedModelsApiService = useTrainedModelsApiService(); const { toasts } = useNotifications(); const [searchQueryText, setSearchQueryText] = useState(''); @@ -110,7 +110,7 @@ export const ModelsList: FC = () => { */ const fetchData = useCallback(async () => { try { - const response = await inferenceApiService.getInferenceModel(undefined, { + const response = await trainedModelsApiService.getTrainedModels(undefined, { with_pipelines: true, size: 1000, }); @@ -146,7 +146,7 @@ export const ModelsList: FC = () => { } } catch (error) { toasts.addError(new Error(error.body?.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchFailedErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchFailedErrorMessage', { defaultMessage: 'Models fetch failed', }), }); @@ -166,8 +166,8 @@ export const ModelsList: FC = () => { total: { show: true, value: items.length, - label: i18n.translate('xpack.ml.inference.modelsList.totalAmountLabel', { - defaultMessage: 'Total inference trained models', + label: i18n.translate('xpack.ml.trainedModels.modelsList.totalAmountLabel', { + defaultMessage: 'Total trained models', }), }, }; @@ -182,7 +182,7 @@ export const ModelsList: FC = () => { try { const { trained_model_stats: modelsStatsResponse, - } = await inferenceApiService.getInferenceModelStats(modelIdsToFetch); + } = await trainedModelsApiService.getTrainedModelStats(modelIdsToFetch); for (const { model_id: id, ...stats } of modelsStatsResponse) { const model = models.find((m) => m.model_id === id); @@ -191,7 +191,7 @@ export const ModelsList: FC = () => { return true; } catch (error) { toasts.addError(new Error(error.body.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchModelStatsErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage', { defaultMessage: 'Fetch model stats failed', }), }); @@ -221,7 +221,7 @@ export const ModelsList: FC = () => { setModelsToDelete(models as ModelItemFull[]); } else { toasts.addDanger( - i18n.translate('xpack.ml.inference.modelsList.unableToDeleteModelsErrorMessage', { + i18n.translate('xpack.ml.trainedModels.modelsList.unableToDeleteModelsErrorMessage', { defaultMessage: 'Unable to delete models', }) ); @@ -236,7 +236,7 @@ export const ModelsList: FC = () => { try { await Promise.all( - modelsToDeleteIds.map((modelId) => inferenceApiService.deleteInferenceModel(modelId)) + modelsToDeleteIds.map((modelId) => trainedModelsApiService.deleteTrainedModel(modelId)) ); setItems( items.filter( @@ -244,7 +244,7 @@ export const ModelsList: FC = () => { ) ); toasts.addSuccess( - i18n.translate('xpack.ml.inference.modelsList.successfullyDeletedMessage', { + i18n.translate('xpack.ml.trainedModels.modelsList.successfullyDeletedMessage', { defaultMessage: '{modelsCount, plural, one {Model {modelsToDeleteIds}} other {# models}} {modelsCount, plural, one {has} other {have}} been successfully deleted', values: { @@ -255,7 +255,7 @@ export const ModelsList: FC = () => { ); } catch (error) { toasts.addError(new Error(error?.body?.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchDeletionErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchDeletionErrorMessage', { defaultMessage: '{modelsCount, plural, one {Model} other {Models}} deletion failed', values: { modelsCount: modelsToDeleteIds.length, @@ -270,10 +270,10 @@ export const ModelsList: FC = () => { */ const actions: Array> = [ { - name: i18n.translate('xpack.ml.inference.modelsList.viewTrainingDataActionLabel', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { defaultMessage: 'View training data', }), - description: i18n.translate('xpack.ml.inference.modelsList.viewTrainingDataActionLabel', { + description: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { defaultMessage: 'View training data', }), icon: 'visTable', @@ -298,10 +298,10 @@ export const ModelsList: FC = () => { isPrimary: true, }, { - name: i18n.translate('xpack.ml.inference.modelsList.deleteModelActionLabel', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { defaultMessage: 'Delete model', }), - description: i18n.translate('xpack.ml.inference.modelsList.deleteModelActionLabel', { + description: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { defaultMessage: 'Delete model', }), icon: 'trash', @@ -341,10 +341,10 @@ export const ModelsList: FC = () => { onClick={toggleDetails.bind(null, item)} aria-label={ itemIdToExpandedRowMap[item.model_id] - ? i18n.translate('xpack.ml.inference.modelsList.collapseRow', { + ? i18n.translate('xpack.ml.trainedModels.modelsList.collapseRow', { defaultMessage: 'Collapse', }) - : i18n.translate('xpack.ml.inference.modelsList.expandRow', { + : i18n.translate('xpack.ml.trainedModels.modelsList.expandRow', { defaultMessage: 'Expand', }) } @@ -354,7 +354,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.id, - name: i18n.translate('xpack.ml.inference.modelsList.modelIdHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.modelIdHeader', { defaultMessage: 'ID', }), sortable: true, @@ -362,7 +362,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.type, - name: i18n.translate('xpack.ml.inference.modelsList.typeHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.typeHeader', { defaultMessage: 'Type', }), sortable: true, @@ -371,7 +371,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.createdAt, - name: i18n.translate('xpack.ml.inference.modelsList.createdAtHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.createdAtHeader', { defaultMessage: 'Created at', }), dataType: 'date', @@ -379,7 +379,7 @@ export const ModelsList: FC = () => { sortable: true, }, { - name: i18n.translate('xpack.ml.inference.modelsList.actionsHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.actionsHeader', { defaultMessage: 'Actions', }), actions, @@ -413,7 +413,7 @@ export const ModelsList: FC = () => {
@@ -423,7 +423,7 @@ export const ModelsList: FC = () => { @@ -438,10 +438,10 @@ export const ModelsList: FC = () => { ? { selectableMessage: (selectable, item) => { return selectable - ? i18n.translate('xpack.ml.inference.modelsList.selectableMessage', { + ? i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', { defaultMessage: 'Select a model', }) - : i18n.translate('xpack.ml.inference.modelsList.disableSelectableMessage', { + : i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', { defaultMessage: 'Model has associated pipelines', }); }, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 6fb3904e76ec..a277ae6e6a66 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -549,8 +549,7 @@ export function reducer(state: State, action: Action): State { } case ACTION.SWITCH_TO_ADVANCED_EDITOR: - let { jobConfig } = state; - jobConfig = getJobConfigFromFormState(state.form); + const jobConfig = getJobConfigFromFormState(state.form); const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); return validateAdvancedEditor({ diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 2a89c5a5fd68..f12427a8c3de 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -292,7 +292,6 @@ export function getFormStateFromJobConfig( isClone: boolean = true ): Partial { const jobType = getAnalysisType(analyticsJobConfig.analysis) as DataFrameAnalysisConfigType; - const resultState: Partial = { jobType, description: analyticsJobConfig.description ?? '', @@ -302,7 +301,8 @@ export function getFormStateFromJobConfig( : analyticsJobConfig.source.index, modelMemoryLimit: analyticsJobConfig.model_memory_limit, maxNumThreads: analyticsJobConfig.max_num_threads, - includes: analyticsJobConfig.analyzed_fields.includes, + includes: analyticsJobConfig.analyzed_fields?.includes ?? [], + jobConfigQuery: analyticsJobConfig.source.query || defaultSearchQuery, }; if (isClone === false) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 1c8bfafeb10f..0b88f52e555c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -285,7 +285,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { resetForm(); const config = extractCloningConfig(cloneJob); if (isAdvancedConfig(config)) { - setJobConfig(config); + setFormState(getFormStateFromJobConfig(config)); switchToAdvancedEditor(); } else { setFormState(getFormStateFromJobConfig(config)); diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap deleted file mode 100644 index 4adaac1319d5..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 63c471e66c49..d16a84a23c81 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -1,48 +1,10 @@ $borderRadius: $euiBorderRadius / 2; -.ml-swimlane-selector { - visibility: hidden; -} - .ml-explorer { width: 100%; display: inline-block; color: $euiColorDarkShade; - .visError { - h4 { - margin-top: 50px; - } - } - - .no-results-container { - text-align: center; - font-size: $euiFontSizeL; - - // SASSTODO: Use a proper calc - padding-top: 60px; - - .no-results { - background-color: $euiFocusBackgroundColor; - padding: $euiSize; - border-radius: $euiBorderRadius; - display: inline-block; - - // SASSTODO: Make a proper selector - i { - color: $euiColorPrimary; - margin-right: $euiSizeXS; - } - - - // SASSTODO: Make a proper selector - div:nth-child(2) { - margin-top: $euiSizeXS; - font-size: $euiFontSizeXS; - } - } - } - .mlAnomalyExplorer__filterBar { padding-right: $euiSize; padding-left: $euiSize; @@ -79,23 +41,6 @@ $borderRadius: $euiBorderRadius / 2; } } - .ml-controls { - padding-bottom: $euiSizeS; - - // SASSTODO: Make a proper selector - label { - font-size: $euiFontSizeXS; - padding: $euiSizeXS; - padding-top: 0; - } - - .kuiButtonGroup { - padding: 0px $euiSizeXS 0px 0px; - position: relative; - display: inline-block; - } - } - .ml-anomalies-controls { padding-top: $euiSizeXS; @@ -103,235 +48,19 @@ $borderRadius: $euiBorderRadius / 2; padding-top: $euiSizeL; } } - - // SASSTODO: This entire selector needs to be rewritten. - // It looks extremely brittle with very specific sizing units - .mlExplorerSwimlane { - user-select: none; - padding: 0; - - line.gridLine { - stroke: $euiBorderColor; - fill: none; - shape-rendering: crispEdges; - stroke-width: 1px; - } - - rect.gridCell { - shape-rendering: crispEdges; - } - - rect.hovered { - stroke: $euiColorDarkShade; - stroke-width: 2px; - } - - text.laneLabel { - font-size: 9pt; - font-family: $euiFontFamily; - fill: $euiColorDarkShade; - } - - text.timeLabel { - font-size: 8pt; - font-family: $euiFontFamily; - fill: $euiColorDarkShade; - } - } } -/* using !important in the following rule because other related legacy rules have more specifity. */ -.mlDragselectDragging { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - opacity: 0.6 !important; +.mlSwimLaneContainer { + /* Override legend styles */ + .echLegendListContainer { + height: 34px !important; } -} - -/* using !important in the following rule because other related legacy rules have more specifity. */ -.mlHideRangeSelection { - div.ml-swimlanes { - div.lane { - div.cells-container { - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border-width: 0px !important; - opacity: 1 !important; - } - - .sl-cell-inner.sl-cell-inner-selected { - border-width: $euiSizeXS / 2 !important; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.6 !important; - } - } - } - } - } -} - -.ml-swimlanes { - margin: 0px 0px 0px 10px; - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: $borderRadius; - white-space: nowrap; - - &:not(:first-child) { - margin-top: -1px; - } - - div.lane-label { - display: inline-block; - font-size: $euiFontSizeXS; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: $borderRadius; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: $borderRadius; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: $borderRadius; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } + .echLegendList { + display: flex !important; + justify-content: space-between !important; + flex-wrap: nowrap; + position: absolute; + right: 0; } } diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 45dada84de20..76f678554413 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiSpacer, EuiContextMenuItem, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -156,6 +157,16 @@ export const AnomalyTimeline: FC = React.memo( />
+ {selectedCells ? ( + + + + + + ) : null}
{viewByLoadedForTimeFormatted && ( @@ -211,6 +222,7 @@ export const AnomalyTimeline: FC = React.memo( = React.memo( onResize={explorerService.setSwimlaneContainerWidth} isLoading={loading} noDataWarning={} + showTimeline={false} /> {viewBySwimlaneOptions.length > 0 && ( { - const original = jest.requireActual('d3'); - - return { - ...original, - transform: jest.fn().mockReturnValue({ - translate: jest.fn().mockReturnValue(0), - }), - }; -}); - -jest.mock('@elastic/eui', () => { - return { - htmlIdGenerator: jest.fn(() => { - return jest.fn(() => { - return 'test-gen-id'; - }); - }), - }; -}); - -function getExplorerSwimlaneMocks() { - const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; - - const timeBuckets = ({ - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - } as unknown) as InstanceType; - - const tooltipService = ({ - show: jest.fn(), - hide: jest.fn(), - } as unknown) as ChartTooltipService; - - return { - timeBuckets, - swimlaneData, - tooltipService, - parentRef: {} as React.RefObject, - }; -} - -const mockChartWidth = 800; - -describe('ExplorerSwimlane', () => { - const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 } as DOMRect; - // @ts-ignore - const originalGetBBox = SVGElement.prototype.getBBox; - beforeEach(() => { - moment.tz.setDefault('UTC'); - // @ts-ignore - SVGElement.prototype.getBBox = () => mockedGetBBox; - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - // @ts-ignore - SVGElement.prototype.getBBox = originalGetBBox; - }); - - test('Minimal initialization', () => { - const mocks = getExplorerSwimlaneMocks(); - - const wrapper = mountWithIntl( - - ); - - expect(wrapper.html()).toBe( - '
' - ); - - // test calls to mock functions - // @ts-ignore - expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - }); - - test('Overall swimlane', () => { - const mocks = getExplorerSwimlaneMocks(); - - const wrapper = mountWithIntl( - - ); - - expect(wrapper.html()).toMatchSnapshot(); - - // test calls to mock functions - // @ts-ignore - expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx deleted file mode 100644 index 569709d648b3..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ /dev/null @@ -1,758 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * React component for rendering Explorer dashboard swimlanes. - */ - -import React from 'react'; -import './_explorer.scss'; -import { isEqual, uniq, get } from 'lodash'; -import d3 from 'd3'; -import moment from 'moment'; -import DragSelect from 'dragselect'; - -import { i18n } from '@kbn/i18n'; -import { Subject, Subscription } from 'rxjs'; -import { TooltipValue } from '@elastic/charts'; -import { htmlIdGenerator } from '@elastic/eui'; -import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; -import { numTicksForDateFormat } from '../util/chart_utils'; -import { getSeverityColor } from '../../../common/util/anomaly_utils'; -import { mlEscape } from '../util/string_utils'; -import { ALLOW_CELL_RANGE_SELECTION } from './explorer_dashboard_service'; -import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; -import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; -import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; -import { - ChartTooltipService, - ChartTooltipValue, -} from '../components/chart_tooltip/chart_tooltip_service'; -import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; - -const SCSS = { - mlDragselectDragging: 'mlDragselectDragging', - mlHideRangeSelection: 'mlHideRangeSelection', -}; - -interface NodeWithData extends Node { - __clickData__: { - time: number; - bucketScore: number; - laneLabel: string; - swimlaneType: string; - }; -} - -interface SelectedData { - bucketScore: number; - laneLabels: string[]; - times: number[]; -} - -export interface ExplorerSwimlaneProps { - chartWidth: number; - filterActive?: boolean; - maskAll?: boolean; - timeBuckets: InstanceType; - swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; - swimlaneType: SwimlaneType; - selection?: AppStateSelectedCells; - onCellsSelection: (payload?: AppStateSelectedCells) => void; - tooltipService: ChartTooltipService; - 'data-test-subj'?: string; - /** - * We need to be aware of the parent element in order to set - * the height so the swim lane widget doesn't jump during loading - * or page changes. - */ - parentRef: React.RefObject; -} - -export class ExplorerSwimlane extends React.Component { - // Since this component is mostly rendered using d3 and cellMouseoverActive is only - // relevant for d3 based interaction, we don't manage this using React's state - // and intentionally circumvent the component lifecycle when updating it. - cellMouseoverActive = true; - - selection: AppStateSelectedCells | undefined = undefined; - - dragSelectSubscriber: Subscription | null = null; - - rootNode = React.createRef(); - - isSwimlaneSelectActive = false; - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect$ = new Subject<{ - action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; - elements?: any[]; - }>(); - - /** - * Unique id for swim lane instance - */ - rootNodeId = htmlIdGenerator()(); - - /** - * Initialize drag select instance - */ - dragSelect = new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.querySelectorAll(`#${this.rootNodeId} .sl-cell`), - callback: (elements) => { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart: (e) => { - // make sure we don't trigger text selection on label - e.preventDefault(); - // clear previous selection - this.clearSelection(); - let target = e.target as HTMLElement; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode as HTMLElement; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect: () => { - if (ALLOW_CELL_RANGE_SELECTION) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - componentDidMount() { - // property for data comparison to be able to filter - // consecutive click events with the same data. - let previousSelectedData: any = null; - - // Listen for dragSelect events - this.dragSelectSubscriber = this.dragSelect$.subscribe(({ action, elements = [] }) => { - const element = d3.select(this.rootNode.current!.parentNode!); - const { swimlaneType } = this.props; - - if (action === DRAG_SELECT_ACTION.NEW_SELECTION && elements.length > 0) { - element.classed(SCSS.mlDragselectDragging, false); - const firstSelectedCell = (d3.select(elements[0]).node() as NodeWithData).__clickData__; - - if ( - typeof firstSelectedCell !== 'undefined' && - swimlaneType === firstSelectedCell.swimlaneType - ) { - const selectedData: SelectedData = elements.reduce( - (d, e) => { - const cell = (d3.select(e).node() as NodeWithData).__clickData__; - d.bucketScore = Math.max(d.bucketScore, cell.bucketScore); - d.laneLabels.push(cell.laneLabel); - d.times.push(cell.time); - return d; - }, - { - bucketScore: 0, - laneLabels: [], - times: [], - } - ); - - selectedData.laneLabels = uniq(selectedData.laneLabels); - selectedData.times = uniq(selectedData.times); - if (isEqual(selectedData, previousSelectedData) === false) { - // If no cells containing anomalies have been selected, - // immediately clear the selection, otherwise trigger - // a reload with the updated selected cells. - if (selectedData.bucketScore === 0) { - elements.map((e) => d3.select(e).classed('ds-selected', false)); - this.selectCell([], selectedData); - previousSelectedData = null; - } else { - this.selectCell(elements, selectedData); - previousSelectedData = selectedData; - } - } - } - - this.cellMouseoverActive = true; - } else if (action === DRAG_SELECT_ACTION.ELEMENT_SELECT) { - element.classed(SCSS.mlDragselectDragging, true); - } else if (action === DRAG_SELECT_ACTION.DRAG_START) { - previousSelectedData = null; - this.cellMouseoverActive = false; - this.props.tooltipService.hide(); - } - }); - - this.renderSwimlane(); - - this.dragSelect.stop(); - } - - componentDidUpdate() { - this.renderSwimlane(); - } - - componentWillUnmount() { - this.dragSelectSubscriber!.unsubscribe(); - // Remove selector element from DOM - this.dragSelect.selector.remove(); - // removes all mousedown event handlers - this.dragSelect.stop(true); - } - - selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) { - const { selection, swimlaneData, swimlaneType } = this.props; - - let triggerNewSelection = false; - - if (cellsToSelect.length > 1 || bucketScore > 0) { - triggerNewSelection = true; - } - - // Check if the same cells were selected again, if so clear the selection, - // otherwise activate the new selection. The two objects are built for - // comparison because we cannot simply compare to "appState.mlExplorerSwimlane" - // since it also includes the "viewBy" attribute which might differ depending - // on whether the overall or viewby swimlane was selected. - const oldSelection = { - selectedType: selection && selection.type, - selectedLanes: selection && selection.lanes, - selectedTimes: selection && selection.times, - }; - - const newSelection = { - selectedType: swimlaneType, - selectedLanes: laneLabels, - selectedTimes: d3.extent(times), - }; - - if (isEqual(oldSelection, newSelection)) { - triggerNewSelection = false; - } - - if (triggerNewSelection === false) { - this.swimLaneSelectionCompleted(); - return; - } - - const selectedCells = { - viewByFieldName: swimlaneData.fieldName, - lanes: laneLabels, - times: d3.extent(times), - type: swimlaneType, - }; - this.swimLaneSelectionCompleted(selectedCells); - } - - /** - * Highlights DOM elements of the swim lane cells - */ - highlightSwimLaneCells(selection: AppStateSelectedCells | undefined) { - const element = d3.select(this.rootNode.current!.parentNode!); - - const { swimlaneType, swimlaneData, filterActive, maskAll } = this.props; - - const { laneLabels: lanes, earliest: startTime, latest: endTime } = swimlaneData; - - // Check for selection and reselect the corresponding swimlane cell - // if the time range and lane label are still in view. - const selectionState = selection; - const selectedType = get(selectionState, 'type', undefined); - const selectionViewByFieldName = get(selectionState, 'viewByFieldName', ''); - - // If a selection was done in the other swimlane, add the "masked" classes - // to de-emphasize the swimlane cells. - if (swimlaneType !== selectedType && selectedType !== undefined) { - element.selectAll('.lane-label').classed('lane-label-masked', true); - element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); - } - - const cellsToSelect: Node[] = []; - const selectedLanes = get(selectionState, 'lanes', []); - const selectedTimes = get(selectionState, 'times', []); - const selectedTimeExtent = d3.extent(selectedTimes); - - if ( - (swimlaneType !== selectedType || - (swimlaneData.fieldName !== undefined && - swimlaneData.fieldName !== selectionViewByFieldName)) && - filterActive === false - ) { - // Not this swimlane which was selected. - return; - } - - selectedLanes.forEach((selectedLane) => { - if ( - lanes.indexOf(selectedLane) > -1 && - selectedTimeExtent[0] >= startTime && - selectedTimeExtent[1] <= endTime - ) { - // Locate matching cell - look for exact time, otherwise closest before. - const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); - - laneCells.each(function (this: HTMLElement) { - const cell = d3.select(this); - const cellTime = parseInt(cell.attr('data-time'), 10); - if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { - cellsToSelect.push(cell.node()); - } - }); - } - }); - - const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { - return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); - }, 0); - - const selectedCellTimes = cellsToSelect.map((e) => { - return (d3.select(e).node() as NodeWithData).__clickData__.time; - }); - - if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { - this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); - } else if (filterActive === true) { - this.maskIrrelevantSwimlanes(Boolean(maskAll)); - } else { - this.clearSelection(); - } - - // cache selection to prevent rerenders - this.selection = selection; - } - - highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { - // This selects the embeddable container - const wrapper = d3.select(`#${this.rootNodeId}`); - - wrapper.selectAll('.lane-label').classed('lane-label-masked', true); - wrapper - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - wrapper - .selectAll( - '.sl-cell-inner.sl-cell-inner-selected,.sl-cell-inner-dragselect.sl-cell-inner-selected' - ) - .classed('sl-cell-inner-selected', false); - - d3.selectAll(cellsToSelect) - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', false) - .classed('sl-cell-inner-selected', true); - - const rootParent = d3.select(this.rootNode.current!.parentNode!); - rootParent.selectAll('.lane-label').classed('lane-label-masked', function (this: HTMLElement) { - return laneLabels.indexOf(d3.select(this).text()) === -1; - }); - } - - /** - * TODO should happen with props instead of imperative check - * @param maskAll - */ - maskIrrelevantSwimlanes(maskAll: boolean) { - if (maskAll === true) { - // This selects both overall and viewby swimlane - const allSwimlanes = d3.selectAll('.mlExplorerSwimlane'); - allSwimlanes.selectAll('.lane-label').classed('lane-label-masked', true); - allSwimlanes - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - } else { - const overallSwimlane = d3.select('.ml-swimlane-overall'); - overallSwimlane.selectAll('.lane-label').classed('lane-label-masked', true); - overallSwimlane - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - } - } - - clearSelection() { - // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.mlExplorerSwimlane'); - - wrapper.selectAll('.lane-label').classed('lane-label-masked', false); - wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', false); - wrapper - .selectAll('.sl-cell-inner.sl-cell-inner-selected') - .classed('sl-cell-inner-selected', false); - wrapper - .selectAll('.sl-cell-inner-dragselect.sl-cell-inner-selected') - .classed('sl-cell-inner-selected', false); - wrapper.selectAll('.ds-selected').classed('sl-cell-inner-selected', false); - } - - renderSwimlane() { - const element = d3.select(this.rootNode.current!.parentNode!); - - // Consider the setting to support to select a range of cells - if (!ALLOW_CELL_RANGE_SELECTION) { - element.classed(SCSS.mlHideRangeSelection, true); - } - - // This getter allows us to fetch the current value in `cellMouseover()`. - // Otherwise it will just refer to the value when `cellMouseover()` was instantiated. - const getCellMouseoverActive = () => this.cellMouseoverActive; - - const { - chartWidth, - filterActive, - timeBuckets, - swimlaneData, - swimlaneType, - selection, - } = this.props; - - const { - laneLabels: lanes, - earliest: startTime, - latest: endTime, - interval: stepSecs, - points, - } = swimlaneData; - - const cellMouseover = ( - target: HTMLElement, - laneLabel: string, - bucketScore: number, - index: number, - time: number - ) => { - if (bucketScore === undefined || getCellMouseoverActive() === false) { - return; - } - - const displayScore = bucketScore > 1 ? parseInt(String(bucketScore), 10) : '< 1'; - - // Display date using same format as Kibana visualizations. - const formattedDate = formatHumanReadableDateTime(time * 1000); - const tooltipData: TooltipValue[] = [{ label: formattedDate } as TooltipValue]; - - if (swimlaneData.fieldName !== undefined) { - tooltipData.push({ - label: swimlaneData.fieldName, - value: laneLabel, - // @ts-ignore - seriesIdentifier: { - key: laneLabel, - }, - valueAccessor: 'fieldName', - }); - } - tooltipData.push({ - label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { - defaultMessage: 'Max anomaly score', - }), - value: displayScore, - color: colorScore(bucketScore), - // @ts-ignore - seriesIdentifier: { - key: laneLabel, - }, - valueAccessor: 'anomaly_score', - }); - - const offsets = target.className === 'sl-cell-inner' ? { x: 6, y: 0 } : { x: 8, y: 1 }; - - this.props.tooltipService.show(tooltipData, target, { - x: target.offsetWidth + offsets.x, - y: 6 + offsets.y, - }); - }; - - function colorScore(value: number): string { - return getSeverityColor(value); - } - - const numBuckets = Math.round((endTime - startTime) / stepSecs); - const cellHeight = 30; - const height = (lanes.length + 1) * cellHeight - 10; - // Set height for the wrapper element - if (this.props.parentRef.current) { - this.props.parentRef.current.style.height = `${height + 20}px`; - } - - const laneLabelWidth = 170; - const swimlanes = element.select('.ml-swimlanes'); - swimlanes.html(''); - - const cellWidth = Math.floor((chartWidth / numBuckets) * 100) / 100; - - const xAxisWidth = cellWidth * numBuckets; - const xAxisScale = d3.time - .scale() - .domain([new Date(startTime * 1000), new Date(endTime * 1000)]) - .range([0, xAxisWidth]); - - // Get the scaled date format to use for x axis tick labels. - timeBuckets.setInterval(`${stepSecs}s`); - const xAxisTickFormat = timeBuckets.getScaledDateFormat(); - - function cellMouseOverFactory(time: number, i: number) { - // Don't use an arrow function here because we need access to `this`, - // which is where d3 supplies a reference to the corresponding DOM element. - return function (this: HTMLElement, lane: string) { - const bucketScore = getBucketScore(lane, time); - if (bucketScore !== 0) { - lane = lane === '' ? EMPTY_FIELD_VALUE_LABEL : lane; - cellMouseover(this, lane, bucketScore, i, time); - } - }; - } - - const cellMouseleave = () => { - this.props.tooltipService.hide(); - }; - - const d3Lanes = swimlanes.selectAll('.lane').data(lanes); - const d3LanesEnter = d3Lanes.enter().append('div').classed('lane', true); - - const that = this; - - d3LanesEnter - .append('div') - .classed('lane-label', true) - .style('width', `${laneLabelWidth}px`) - .html((label: string) => { - const showFilterContext = filterActive === true && label === 'Overall'; - if (showFilterContext) { - return i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', { - defaultMessage: '{label} (unfiltered)', - values: { label: mlEscape(label) }, - }); - } else { - return label === '' ? `${EMPTY_FIELD_VALUE_LABEL}` : mlEscape(label); - } - }) - .on('click', () => { - if (selection && typeof selection.lanes !== 'undefined') { - this.swimLaneSelectionCompleted(); - } - }) - .each(function (this: HTMLElement) { - if (swimlaneData.fieldName !== undefined) { - d3.select(this) - .on('mouseover', (value) => { - that.props.tooltipService.show( - [ - { skipHeader: true } as ChartTooltipValue, - { - label: swimlaneData.fieldName!, - value: value === '' ? EMPTY_FIELD_VALUE_LABEL : value, - // @ts-ignore - seriesIdentifier: { key: value }, - valueAccessor: 'fieldName', - }, - ], - this, - { - x: laneLabelWidth, - y: 0, - } - ); - }) - .on('mouseout', () => { - that.props.tooltipService.hide(); - }) - .attr( - 'aria-label', - (value) => `${mlEscape(swimlaneData.fieldName!)}: ${mlEscape(value)}` - ); - } - }); - - const cellsContainer = d3LanesEnter.append('div').classed('cells-container', true); - - function getBucketScore(lane: string, time: number): number { - let bucketScore = 0; - const point = points.find((p) => { - return p.value > 0 && p.laneLabel === lane && p.time === time; - }); - if (typeof point !== 'undefined') { - bucketScore = point.value; - } - return bucketScore; - } - - // TODO - mark if zoomed in to bucket width? - let time = startTime; - Array(numBuckets || 0) - .fill(null) - .forEach((v, i) => { - const cell = cellsContainer - .append('div') - .classed('sl-cell', true) - .style('width', `${cellWidth}px`) - .attr('data-lane-label', (label: string) => mlEscape(label)) - .attr('data-time', time) - .attr('data-bucket-score', (lane: string) => { - return getBucketScore(lane, time); - }) - // use a factory here to bind the `time` and `i` values - // of this iteration to the event. - .on('mouseover', cellMouseOverFactory(time, i)) - .on('mouseleave', cellMouseleave) - .each(function (this: NodeWithData, laneLabel: string) { - this.__clickData__ = { - bucketScore: getBucketScore(laneLabel, time), - laneLabel, - swimlaneType, - time, - }; - }); - - // calls itself with each() to get access to lane (= d3 data) - cell.append('div').each(function (this: HTMLElement, lane: string) { - const el = d3.select(this); - - let color = 'none'; - let bucketScore = 0; - - const point = points.find((p) => { - return p.value > 0 && p.laneLabel === lane && p.time === time; - }); - - if (typeof point !== 'undefined') { - bucketScore = point.value; - color = colorScore(bucketScore); - el.classed('sl-cell-inner', true).style('background-color', color); - } else { - el.classed('sl-cell-inner-dragselect', true); - } - }); - - time += stepSecs; - }); - - // ['x-axis'] is just a placeholder so we have an array of 1. - const laneTimes = swimlanes - .selectAll('.time-tick-labels') - .data(['x-axis']) - .enter() - .append('div') - .classed('time-tick-labels', true); - - // height of .time-tick-labels - const svgHeight = 25; - const svg = laneTimes.append('svg').attr('width', chartWidth).attr('height', svgHeight); - - const xAxis = d3.svg - .axis() - .scale(xAxisScale) - .ticks(numTicksForDateFormat(chartWidth, xAxisTickFormat)) - .tickFormat((tick) => moment(tick).format(xAxisTickFormat)); - - const gAxis = svg.append('g').attr('class', 'x axis').call(xAxis); - - // remove overlapping labels - let overlapCheck = 0; - gAxis.selectAll('g.tick').each(function (this: HTMLElement) { - const tick = d3.select(this); - const xTransform = d3.transform(tick.attr('transform')).translate[0]; - const tickWidth = (tick.select('text').node() as SVGGraphicsElement).getBBox().width; - const xMinOffset = xTransform - tickWidth / 2; - const xMaxOffset = xTransform + tickWidth / 2; - // if the tick label overlaps the previous label - // (or overflows the chart to the left), remove it; - // otherwise pick that label's offset as the new offset to check against - if (xMinOffset < overlapCheck) { - tick.remove(); - } else { - overlapCheck = xTransform + tickWidth / 2; - } - // if the last tick label overflows the chart to the right, remove it - if (xMaxOffset > chartWidth) { - tick.remove(); - } - }); - - this.swimlaneRenderDoneListener(); - - this.highlightSwimLaneCells(selection); - } - - shouldComponentUpdate(nextProps: ExplorerSwimlaneProps) { - return ( - this.props.chartWidth !== nextProps.chartWidth || - !isEqual(this.props.swimlaneData, nextProps.swimlaneData) || - !isEqual(nextProps.selection, this.selection) - ); - } - - /** - * Listener for click events in the swim lane and execute a prop callback. - * @param selectedCellsUpdate - */ - swimLaneSelectionCompleted(selectedCellsUpdate?: AppStateSelectedCells) { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - this.highlightSwimLaneCells(selectedCellsUpdate); - - if (!selectedCellsUpdate) { - this.props.onCellsSelection(); - } else { - this.props.onCellsSelection(selectedCellsUpdate); - } - } - - /** - * Listens to render updates of the swim lanes to update dragSelect - */ - swimlaneRenderDoneListener() { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); - } - - setSwimlaneSelectActive(active: boolean) { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); - this.isSwimlaneSelectActive = active; - } - } - - render() { - const { swimlaneType } = this.props; - - return ( -
-
-
- ); - } -} diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 235e5d0f20f8..0a2791edb9c5 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useRef, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiText, EuiLoadingChart, @@ -15,47 +15,131 @@ import { } from '@elastic/eui'; import { throttle } from 'lodash'; -import { ExplorerSwimlane, ExplorerSwimlaneProps } from './explorer_swimlane'; +import { + Chart, + Settings, + Heatmap, + HeatmapElementEvent, + ElementClickListener, + TooltipValue, + HeatmapSpec, +} from '@elastic/charts'; +import moment from 'moment'; +import { HeatmapBrushEvent } from '@elastic/charts/dist/chart_types/heatmap/layout/types/config_types'; -import { MlTooltipComponent } from '../components/chart_tooltip'; +import { i18n } from '@kbn/i18n'; +import { TooltipSettings } from '@elastic/charts/dist/specs/settings'; import { SwimLanePagination } from './swimlane_pagination'; -import { ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../common'; +import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; +import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; +import { mlEscape } from '../util/string_utils'; +import { FormattedTooltip } from '../components/chart_tooltip/chart_tooltip'; +import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; +import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; + +import './_explorer.scss'; +import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ const RESIZE_IGNORED_DIFF_PX = 20; const RESIZE_THROTTLE_TIME_MS = 500; +const CELL_HEIGHT = 30; +const LEGEND_HEIGHT = 34; +const Y_AXIS_HEIGHT = 24; export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { return arg && arg.hasOwnProperty('cardinality'); } /** - * Anomaly swim lane container responsible for handling resizing, pagination and injecting - * tooltip service. - * - * @param children - * @param onResize - * @param perPage - * @param fromPage - * @param swimlaneLimit - * @param onPaginationChange - * @param props - * @constructor + * Provides a custom tooltip for the anomaly swim lane chart. */ -export const SwimlaneContainer: FC< - Omit & { - onResize: (width: number) => void; - fromPage?: number; - perPage?: number; - swimlaneLimit?: number; - onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void; - isLoading: boolean; - noDataWarning: string | JSX.Element | null; +const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => ({ values }) => { + const tooltipData: TooltipValue[] = []; + + if (values.length === 1 && fieldName) { + // Y-axis tooltip for viewBy swim lane + const [yAxis] = values; + // @ts-ignore + tooltipData.push({ skipHeader: true }); + tooltipData.push({ + label: fieldName, + value: yAxis.value, + // @ts-ignore + seriesIdentifier: { + key: yAxis.value, + }, + }); + } else if (values.length === 3) { + // Cell tooltip + const [xAxis, yAxis, cell] = values; + + // Display date using same format as Kibana visualizations. + const formattedDate = formatHumanReadableDateTime(parseInt(xAxis.value, 10)); + tooltipData.push({ label: formattedDate } as TooltipValue); + + if (fieldName !== undefined) { + tooltipData.push({ + label: fieldName, + value: yAxis.value, + // @ts-ignore + seriesIdentifier: { + key: yAxis.value, + }, + }); + } + tooltipData.push({ + label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { + defaultMessage: 'Max anomaly score', + }), + value: cell.formattedValue, + color: cell.color, + // @ts-ignore + seriesIdentifier: { + key: cell.value, + }, + }); } -> = ({ - children, + + return ; +}; + +export interface SwimlaneProps { + filterActive?: boolean; + maskAll?: boolean; + timeBuckets: InstanceType; + swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; + swimlaneType: SwimlaneType; + selection?: AppStateSelectedCells; + onCellsSelection: (payload?: AppStateSelectedCells) => void; + 'data-test-subj'?: string; + onResize: (width: number) => void; + fromPage?: number; + perPage?: number; + swimlaneLimit?: number; + onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void; + isLoading: boolean; + noDataWarning: string | JSX.Element | null; + /** + * Unique id of the chart + */ + id: string; + /** + * Enables/disables timeline on the X-axis. + */ + showTimeline?: boolean; +} + +/** + * Anomaly swim lane container responsible for handling resizing, pagination and + * providing swim lane vis with required props. + */ +export const SwimlaneContainer: FC = ({ + id, onResize, perPage, fromPage, @@ -63,10 +147,20 @@ export const SwimlaneContainer: FC< onPaginationChange, isLoading, noDataWarning, - ...props + filterActive, + swimlaneData, + swimlaneType, + selection, + onCellsSelection, + timeBuckets, + maskAll, + showTimeline = true, + 'data-test-subj': dataTestSubj, }) => { const [chartWidth, setChartWidth] = useState(0); - const wrapperRef = useRef(null); + + // Holds the container height for previously fetched data + const containerHeightRef = useRef(); const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { @@ -80,11 +174,28 @@ export const SwimlaneContainer: FC< [chartWidth] ); - const showSwimlane = - props.swimlaneData && - props.swimlaneData.laneLabels && - props.swimlaneData.laneLabels.length > 0 && - props.swimlaneData.points.length > 0; + const swimLanePoints = useMemo(() => { + const showFilterContext = filterActive === true && swimlaneType === SWIMLANE_TYPE.OVERALL; + + if (!swimlaneData?.points) { + return []; + } + + return swimlaneData.points + .map((v) => { + const formatted = { ...v, time: v.time * 1000 }; + if (showFilterContext) { + formatted.laneLabel = i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', { + defaultMessage: '{label} (unfiltered)', + values: { label: mlEscape(v.laneLabel) }, + }); + } + return formatted; + }) + .filter((v) => v.value > 0); + }, [swimlaneData?.points, filterActive, swimlaneType]); + + const showSwimlane = swimlaneData?.laneLabels?.length > 0 && swimLanePoints.length > 0; const isPaginationVisible = (showSwimlane || isLoading) && @@ -93,67 +204,230 @@ export const SwimlaneContainer: FC< fromPage && perPage; + const rowsCount = swimlaneData?.laneLabels?.length ?? 0; + + const containerHeight = useMemo(() => { + // Persists container height during loading to prevent page from jumping + return isLoading + ? containerHeightRef.current + : rowsCount * CELL_HEIGHT + LEGEND_HEIGHT + (showTimeline ? Y_AXIS_HEIGHT : 0); + }, [isLoading, rowsCount, showTimeline]); + + useEffect(() => { + if (!isLoading) { + containerHeightRef.current = containerHeight; + } + }, [isLoading, containerHeight]); + + const highlightedData: HeatmapSpec['highlightedData'] = useMemo(() => { + if (!selection || !swimlaneData) return; + + if ( + (swimlaneType !== selection.type || + (swimlaneData?.fieldName !== undefined && + swimlaneData.fieldName !== selection.viewByFieldName)) && + filterActive === false + ) { + // Not this swim lane which was selected. + return; + } + + return { x: selection.times.map((v) => v * 1000), y: selection.lanes }; + }, [selection, swimlaneData, swimlaneType]); + + const swimLaneConfig: HeatmapSpec['config'] = useMemo( + () => + showSwimlane + ? { + onBrushEnd: (e: HeatmapBrushEvent) => { + onCellsSelection({ + lanes: e.y as string[], + times: e.x.map((v) => (v as number) / 1000), + type: swimlaneType, + viewByFieldName: swimlaneData.fieldName, + }); + }, + grid: { + cellHeight: { + min: CELL_HEIGHT, + max: CELL_HEIGHT, + }, + stroke: { + width: 1, + color: '#D3DAE6', + }, + }, + cell: { + maxWidth: 'fill', + maxHeight: 'fill', + label: { + visible: false, + }, + border: { + stroke: '#D3DAE6', + strokeWidth: 0, + }, + }, + yAxisLabel: { + visible: true, + width: 170, + // eui color subdued + fill: `#6a717d`, + padding: 8, + formatter: (laneLabel: string) => { + return laneLabel === '' ? EMPTY_FIELD_VALUE_LABEL : laneLabel; + }, + }, + xAxisLabel: { + visible: showTimeline, + // eui color subdued + fill: `#98A2B3`, + formatter: (v: number) => { + timeBuckets.setInterval(`${swimlaneData.interval}s`); + const a = timeBuckets.getScaledDateFormat(); + return moment(v).format(a); + }, + }, + brushMask: { + fill: 'rgb(247 247 247 / 50%)', + }, + maxLegendHeight: LEGEND_HEIGHT, + } + : {}, + [showSwimlane, swimlaneType, swimlaneData?.fieldName] + ); + + // @ts-ignore + const onElementClick: ElementClickListener = useCallback( + (e: HeatmapElementEvent[]) => { + const cell = e[0][0]; + const startTime = (cell.datum.x as number) / 1000; + const payload = { + lanes: [String(cell.datum.y)], + times: [startTime, startTime + swimlaneData.interval], + type: swimlaneType, + viewByFieldName: swimlaneData.fieldName, + }; + onCellsSelection(payload); + }, + [swimlaneType, swimlaneData?.fieldName, swimlaneData?.interval] + ); + + const tooltipOptions: TooltipSettings = useMemo( + () => ({ + placement: 'auto', + fallbackPlacements: ['left'], + boundary: 'chart', + customTooltip: SwimLaneTooltip(swimlaneData?.fieldName), + }), + [swimlaneData?.fieldName] + ); + + // A resize observer is required to compute the bucket span based on the chart width to fetch the data accordingly return ( - <> - - {(resizeRef) => ( - { - resizeRef(el); + + {(resizeRef) => ( + + - -
- - {showSwimlane && !isLoading && ( - - {(tooltipService) => ( - - )} - - )} - {isLoading && ( - - - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}
} - /> - )} - - -
+
+ {showSwimlane && !isLoading && ( + + + + + )} - {isPaginationVisible && ( - - + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - - )} - - + )} +
+
+ + {isPaginationVisible && ( + + + + )} +
+ )} + ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 254c546df65b..7c94bfb746b9 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -9,6 +9,7 @@ import { mlNodesAvailable } from '../../../../ml_nodes_check/check_ml_nodes'; import { getIndexPatternNames } from '../../../../util/index_utils'; import { stopDatafeeds, cloneJob, closeJobs, isStartable, isStoppable, isClosable } from '../utils'; +import { getToastNotifications } from '../../../../util/dependency_cache'; import { i18n } from '@kbn/i18n'; export function actionsMenuContent( @@ -86,15 +87,24 @@ export function actionsMenuContent( // the indexPattern the job was created for. An indexPattern could either have been deleted // since the the job was created or the current user doesn't have the required permissions to // access the indexPattern. - const indexPatternNames = getIndexPatternNames(); - const jobIndicesAvailable = item.datafeedIndices.every((dfiName) => { - return indexPatternNames.some((ipName) => ipName === dfiName); - }); - - return item.deleting !== true && canCreateJob && jobIndicesAvailable; + return item.deleting !== true && canCreateJob; }, onClick: (item) => { - cloneJob(item.id); + const indexPatternNames = getIndexPatternNames(); + const indexPatternTitle = item.datafeedIndices.join(','); + const jobIndicesAvailable = indexPatternNames.includes(indexPatternTitle); + + if (!jobIndicesAvailable) { + getToastNotifications().addDanger( + i18n.translate('xpack.ml.jobsList.managementActions.noSourceIndexPatternForClone', { + defaultMessage: + 'Unable to clone the anomaly detection job {jobId}. No index pattern exists for index {indexPatternTitle}.', + values: { jobId: item.id, indexPatternTitle }, + }) + ); + } else { + cloneJob(item.id); + } closeMenu(true); }, 'data-test-subj': 'mlActionButtonCloneJob', diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts similarity index 73% rename from x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts rename to x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index ce211612fba6..ed5d7e37cd1c 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -10,10 +10,10 @@ import { HttpService } from '../http_service'; import { basePath } from './index'; import { useMlKibana } from '../../contexts/kibana'; import { - ModelConfigResponse, + TrainedModelConfigResponse, ModelPipelines, TrainedModelStat, -} from '../../../../common/types/inference'; +} from '../../../../common/types/trained_models'; export interface InferenceQueryParams { decompress_definition?: boolean; @@ -47,7 +47,7 @@ export interface InferenceStatsResponse { * Service with APIs calls to perform inference operations. * @param httpService */ -export function inferenceApiProvider(httpService: HttpService) { +export function trainedModelsApiProvider(httpService: HttpService) { const apiBasePath = basePath(); return { @@ -58,14 +58,14 @@ export function inferenceApiProvider(httpService: HttpService) { * Fetches all In case nothing is provided. * @param params - Optional query params */ - getInferenceModel(modelId?: string | string[], params?: InferenceQueryParams) { + getTrainedModels(modelId?: string | string[], params?: InferenceQueryParams) { let model = modelId ?? ''; if (Array.isArray(modelId)) { model = modelId.join(','); } - return httpService.http({ - path: `${apiBasePath}/inference${model && `/${model}`}`, + return httpService.http({ + path: `${apiBasePath}/trained_models${model && `/${model}`}`, method: 'GET', ...(params ? { query: params as HttpFetchQuery } : {}), }); @@ -78,14 +78,14 @@ export function inferenceApiProvider(httpService: HttpService) { * Fetches all In case nothing is provided. * @param params - Optional query params */ - getInferenceModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { + getTrainedModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { let model = modelId ?? '_all'; if (Array.isArray(modelId)) { model = modelId.join(','); } return httpService.http({ - path: `${apiBasePath}/inference/${model}/_stats`, + path: `${apiBasePath}/trained_models/${model}/_stats`, method: 'GET', }); }, @@ -95,14 +95,14 @@ export function inferenceApiProvider(httpService: HttpService) { * * @param modelId - Model ID, collection of Model IDs. */ - getInferenceModelPipelines(modelId: string | string[]) { + getTrainedModelPipelines(modelId: string | string[]) { let model = modelId; if (Array.isArray(modelId)) { model = modelId.join(','); } return httpService.http({ - path: `${apiBasePath}/inference/${model}/pipelines`, + path: `${apiBasePath}/trained_models/${model}/pipelines`, method: 'GET', }); }, @@ -112,25 +112,25 @@ export function inferenceApiProvider(httpService: HttpService) { * * @param modelId - Model ID */ - deleteInferenceModel(modelId: string) { + deleteTrainedModel(modelId: string) { return httpService.http({ - path: `${apiBasePath}/inference/${modelId}`, + path: `${apiBasePath}/trained_models/${modelId}`, method: 'DELETE', }); }, }; } -type InferenceApiService = ReturnType; +type TrainedModelsApiService = ReturnType; /** - * Hooks for accessing {@link InferenceApiService} in React components. + * Hooks for accessing {@link TrainedModelsApiService} in React components. */ -export function useInferenceApiService(): InferenceApiService { +export function useTrainedModelsApiService(): TrainedModelsApiService { const { services: { mlServices: { httpService }, }, } = useMlKibana(); - return useMemo(() => inferenceApiProvider(httpService), [httpService]); + return useMemo(() => trainedModelsApiProvider(httpService), [httpService]); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 0291fa1564a2..d638e2c23146 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -115,6 +115,7 @@ export const EmbeddableSwimLaneContainer: FC = ( data-test-subj="mlAnomalySwimlaneEmbeddableWrapper" > MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index d4ec9a0e0db5..22336a7db9a3 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -100,7 +100,7 @@ describe('initAPIAuthorization', () => { expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); - test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { + test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 403`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); @@ -129,7 +129,7 @@ describe('initAPIAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: [mockAuthz.actions.api.get('foo')], diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 9129330ec947..813ed8d064d9 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -37,7 +37,7 @@ export function initAPIAuthorization( return toolkit.next(); } - logger.warn(`User not authorized for "${request.url.path}": responding with 404`); - return response.notFound(); + logger.warn(`User not authorized for "${request.url.path}": responding with 403`); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index f40d502a9cd7..f035e6eaa365 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -170,7 +170,7 @@ describe('initAppAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index 4170fd2cdb38..713266fc3b5c 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -73,6 +73,6 @@ export function initAppAuthorization( } logger.debug(`not authorized for "${appId}"`); - return response.notFound(); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index c00127f7d122..33abc22fdf09 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -72,6 +72,7 @@ it(`#setup returns exposed services`, () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: mockFeaturesSetup, getSpacesService: mockGetSpacesService, getCurrentUser: jest.fn(), @@ -130,6 +131,7 @@ describe('#start', () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() @@ -201,6 +203,7 @@ it('#stop unsubscribes from license and ES updates.', async () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.tsx similarity index 80% rename from x-pack/plugins/security/server/authorization/authorization_service.ts rename to x-pack/plugins/security/server/authorization/authorization_service.tsx index fd3a60fb4d90..9547295af4df 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import querystring from 'querystring'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { Subscription, Observable } from 'rxjs'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; + import type { Capabilities as UICapabilities } from '../../../../../src/core/types'; + import { LoggerFactory, KibanaRequest, @@ -43,6 +50,8 @@ import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; import { CheckPrivilegesWithRequest } from './types'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { canRedirectRequest } from '../authentication'; +import { ResetSessionPage } from './reset_session_page'; import { AuthenticatedUser } from '..'; export { Actions } from './actions'; @@ -51,6 +60,7 @@ export { featurePrivilegeIterator } from './privileges'; interface AuthorizationServiceSetupParams { packageVersion: string; + buildNumber: number; http: HttpServiceSetup; capabilities: CapabilitiesSetup; clusterClient: ILegacyClusterClient; @@ -89,6 +99,7 @@ export class AuthorizationService { http, capabilities, packageVersion, + buildNumber, clusterClient, license, loggers, @@ -154,6 +165,35 @@ export class AuthorizationService { initAPIAuthorization(http, authz, loggers.get('api-authorization')); initAppAuthorization(http, authz, loggers.get('app-authorization'), features); + http.registerOnPreResponse((request, preResponse, toolkit) => { + if (preResponse.statusCode === 403 && canRedirectRequest(request)) { + const basePath = http.basePath.get(request); + const next = `${basePath}${request.url.path}`; + const regularBundlePath = `${basePath}/${buildNumber}/bundles`; + + const logoutUrl = http.basePath.prepend( + `/api/security/logout?${querystring.stringify({ next })}` + ); + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath}/ui/legacy_light_theme.css`, + ]; + + const body = renderToStaticMarkup( + + ); + + return toolkit.render({ body, headers: { 'Content-Security-Policy': http.csp.header } }); + } + return toolkit.next(); + }); + return authz; } diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx new file mode 100644 index 000000000000..5a15f4603cfc --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ResetSessionPage } from './reset_session_page'; + +jest.mock('../../../../../src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('ResetSessionPage', () => { + it('renders as expected', async () => { + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx new file mode 100644 index 000000000000..5ab6fe941ae1 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -0,0 +1,128 @@ +/* + * 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 React from 'react'; +// @ts-expect-error no definitions in component folder +import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; + +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +export function ResetSessionPage({ + logoutUrl, + styleSheetPaths, + basePath, +}: { + logoutUrl: string; + styleSheetPaths: string[]; + basePath: string; +}) { + const uiPublicUrl = `${basePath}/ui`; + return ( + + + {styleSheetPaths.map((path) => ( + + ))} + + + + + + + +