diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f2d6749813013..5fcb619af6570 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -252,6 +252,7 @@ /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security /src/plugins/spaces_oss/ @elastic/kibana-security +/src/plugins/user_setup/ @elastic/kibana-security /test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index b4be27eee5ed2..ffc918af92514 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -256,6 +256,10 @@ In general this plugin provides: |The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. +|{kib-repo}blob/{branch}/src/plugins/user_setup/README.md[userSetup] +|The plugin provides UI and APIs for the interactive setup mode. + + |{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] |The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md index 63302f50204fe..b944c9dcc02a2 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md @@ -18,5 +18,6 @@ export interface EmbeddableEditorState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.embeddableid.md) | string | | | [originatingApp](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingapp.md) | string | | +| [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [valueInput](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.valueinput.md) | EmbeddableInput | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md new file mode 100644 index 0000000000000..815055fe9f55d --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableEditorState](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) > [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) + +## EmbeddableEditorState.searchSessionId property + +Pass current search session id when navigating to an editor, Editors could use it continue previous search session + +Signature: + +```typescript +searchSessionId?: string; +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md index 1c0b1b8bf8b46..b3e851a6d0c30 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md @@ -18,5 +18,6 @@ export interface EmbeddablePackageState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.embeddableid.md) | string | | | [input](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.input.md) | Optional<EmbeddableInput, 'id'> | Optional<SavedObjectEmbeddableInput, 'id'> | | +| [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [type](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.type.md) | string | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md new file mode 100644 index 0000000000000..3c515b1fb6674 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddablePackageState](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) > [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md) + +## EmbeddablePackageState.searchSessionId property + +Pass current search session id when navigating to an editor, Editors could use it continue previous search session + +Signature: + +```typescript +searchSessionId?: string; +``` diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index 5e6a60f019bea..d9835b312f3ee 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -143,3 +143,6 @@ If you are you ready to add your own data, refer to <> + +If you want to try out {ml-features} with the sample data sets, refer to +{ml-docs}/ml-getting-started.html[Getting started with {ml}]. \ No newline at end of file diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index 3c463da842faa..b3606b122d750 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -80,7 +80,7 @@ browser so that it does not block pop-up windows or create an exception for your For more information about the {anomaly-detect} feature, see https://www.elastic.co/what-is/elastic-stack-machine-learning[{ml-cap} in the {stack}] -and {ml-docs}/xpack-ml.html[{ml-cap} {anomaly-detect}]. +and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index faa980fe833cb..5506e7ab375a2 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -65,6 +65,10 @@ image::user/security/images/kibana-login.png["Login Selector UI"] For more information, refer to <>. +TIP: If you have multiple authentication providers configured, you can use the `auth_provider_hint` URL query parameter to create a deep +link to any provider and bypass the Login Selector UI. Using the `kibana.yml` above as an example, you can add `?auth_provider_hint=basic1` +to the login page URL, which will take you directly to the basic login page. + [[basic-authentication]] ==== Basic authentication diff --git a/examples/locator_explorer/public/app.tsx b/examples/locator_explorer/public/app.tsx index 440e16302dff9..8e38c097a847e 100644 --- a/examples/locator_explorer/public/app.tsx +++ b/examples/locator_explorer/public/app.tsx @@ -19,7 +19,7 @@ import { EuiFieldText } from '@elastic/eui'; import { EuiPageHeader } from '@elastic/eui'; import { EuiLink } from '@elastic/eui'; import { AppMountParameters } from '../../../src/core/public'; -import { SharePluginSetup } from '../../../src/plugins/share/public'; +import { formatSearchParams, SharePluginSetup } from '../../../src/plugins/share/public'; import { HelloLocatorV1Params, HelloLocatorV2Params, @@ -34,6 +34,7 @@ interface MigratedLink { linkText: string; link: string; version: string; + params: HelloLocatorV1Params | HelloLocatorV2Params; } const ActionsExplorer = ({ share }: Props) => { @@ -93,6 +94,7 @@ const ActionsExplorer = ({ share }: Props) => { linkText: savedLink.linkText, link, version: savedLink.version, + params: savedLink.params, } as MigratedLink; }) ); @@ -157,7 +159,24 @@ const ActionsExplorer = ({ share }: Props) => { target="_blank" > {link.linkText} + {' '} + ( + + through redirect app + )
)) diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index ca1e2d69b1329..9d1a6077d53c1 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -8,7 +8,7 @@ import { resolve } from 'path'; -export const CA_CERT_PATH = resolve(__dirname, '../certs/ca.crt'); +export const CA_CERT_PATH = process.env.TEST_CA_CERT_PATH || resolve(__dirname, '../certs/ca.crt'); export const ES_KEY_PATH = resolve(__dirname, '../certs/elasticsearch.key'); export const ES_CERT_PATH = resolve(__dirname, '../certs/elasticsearch.crt'); export const ES_P12_PATH = resolve(__dirname, '../certs/elasticsearch.p12'); diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6627b644daec7..2c7f194d7da98 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,3 +112,4 @@ pageLoadAssetSize: visTypePie: 35583 expressionRevealImage: 25675 cases: 144442 + userSetup: 18532 diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6bb714e913838..305a06e60bc0b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -231,7 +231,7 @@ export class DocLinksService { ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, - anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, + anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-overview.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`, @@ -249,7 +249,7 @@ export class DocLinksService { customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, - outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-roc`, + outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-finding-outliers.html#ml-dfanalytics-roc`, regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`, classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-class-aucroc`, }, diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 345fcecbda445..87df54f2c6a8a 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -30,6 +30,7 @@ interface Params { const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; +const ZIP_CONTENT = /^(application\/zip)(;.*)?$/; const removedUndefined = (obj: Record | undefined) => { return omitBy(obj, (v) => v === undefined); @@ -153,7 +154,7 @@ export class Fetch { const contentType = response.headers.get('Content-Type') || ''; try { - if (NDJSON_CONTENT.test(contentType)) { + if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) { body = await response.blob(); } else if (JSON_CONTENT.test(contentType)) { body = await response.json(); diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 8db6a0e8a8c7f..638b1c83e9dc6 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -40,6 +40,7 @@ export function DashboardApp({ embeddable, onAppLeave, uiSettings, + data, } = useKibana().services; const kbnUrlStateStorage = useMemo( @@ -98,6 +99,13 @@ export function DashboardApp({ ]); }, [chrome, dashboardState.title, dashboardState.viewMode, redirectTo, savedDashboardId]); + // clear search session when leaving dashboard route + useEffect(() => { + return () => { + data.search.session.clear(); + }; + }, [data.search.session]); + return ( <> {isCompleteDashboardAppState(dashboardAppState) && ( diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index e77353000ced4..cb40b30542869 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -260,6 +260,7 @@ export async function mountApp({ } render(app, element); return () => { + dataStart.search.session.clear(); unlistenParentHistory(); unmountComponentAtNode(element); appUnMounted(); diff --git a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts index cb8c5ac5745e4..8b895d739e2d1 100644 --- a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts @@ -64,6 +64,11 @@ export const buildDashboardContainer = async ({ getLatestDashboardState, canStoreSearchSession: dashboardCapabilities.storeSearchSession, }); + + if (incomingEmbeddable?.searchSessionId) { + session.continue(incomingEmbeddable?.searchSessionId); + } + const searchSessionIdFromURL = getSearchSessionIdFromURL(history); if (searchSessionIdFromURL) { session.restore(searchSessionIdFromURL); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 5557bf25d9d85..7f72c77009cb9 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -87,11 +87,6 @@ export const DashboardListing = ({ }; }, [title, savedObjectsClient, redirectTo, data.query, kbnUrlStateStorage]); - // clear dangling session because they are not required here - useEffect(() => { - data.search.session.clear(); - }, [data.search.session]); - const hideWriteControls = dashboardCapabilities.hideWriteControls; const listingLimit = savedObjects.settings.getListingLimit(); const defaultFilter = title ? `"${title}"` : ''; diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e5f89bd6a8e90..dab74373efef5 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -204,10 +204,11 @@ export function DashboardTopNav({ path, state: { originatingApp: DashboardConstants.DASHBOARDS_ID, + searchSessionId: data.search.session.getSessionId(), }, }); }, - [trackUiMetric, stateTransferService] + [stateTransferService, data.search.session, trackUiMetric] ); const clearAddPanel = useCallback(() => { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 35094fac1cc0f..66d81d058fc77 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2783,7 +2783,7 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:436:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:62:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index 18d32463864e3..dee0216530205 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -47,5 +47,6 @@ export function getSessionServiceMock(): jest.Mocked { isSessionStorageReady: jest.fn(() => true), getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })), hasAccess: jest.fn(() => true), + continue: jest.fn(), }; } diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 7f388a29cd454..c2c4d1540c387 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -98,12 +98,12 @@ describe('Session service', () => { expect(nowProvider.reset).toHaveBeenCalled(); }); - it("Can clear other apps' session", async () => { + it("Can't clear other apps' session", async () => { sessionService.start(); expect(sessionService.getSessionId()).not.toBeUndefined(); currentAppId$.next('change'); sessionService.clear(); - expect(sessionService.getSessionId()).toBeUndefined(); + expect(sessionService.getSessionId()).not.toBeUndefined(); }); it("Can start a new session in case there is other apps' stale session", async () => { @@ -161,6 +161,72 @@ describe('Session service', () => { }); }); + it('Can continue previous session from another app', async () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.clear(); + currentAppId$.next('change'); + sessionService.continue(sessionId!); + + expect(sessionService.getSessionId()).toBe(sessionId); + }); + + it('Calling clear() more than once still allows previous session from another app to continue', async () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.clear(); + sessionService.clear(); + + currentAppId$.next('change'); + sessionService.continue(sessionId!); + + expect(sessionService.getSessionId()).toBe(sessionId); + }); + + it('Continue drops storage configuration', () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.enableStorage({ + getName: async () => 'Name', + getUrlGeneratorData: async () => ({ + urlGeneratorId: 'id', + initialState: {}, + restoreState: {}, + }), + }); + + expect(sessionService.isSessionStorageReady()).toBe(true); + + sessionService.clear(); + + sessionService.continue(sessionId!); + + expect(sessionService.isSessionStorageReady()).toBe(false); + }); + + // it might be that search requests finish after the session is cleared and before it was continued, + // to avoid "infinite loading" state after we continue the session we have to drop pending searches + it('Continue drops client side loading state', async () => { + const sessionId = sessionService.start(); + + sessionService.trackSearch({ abort: () => {} }); + expect(state$.getValue()).toBe(SearchSessionState.Loading); + + sessionService.clear(); // even allow to call clear multiple times + + expect(state$.getValue()).toBe(SearchSessionState.None); + + sessionService.continue(sessionId!); + expect(sessionService.getSessionId()).toBe(sessionId); + + // the original search was never `untracked`, + // but we still consider this a completed session until new search fire + expect(state$.getValue()).toBe(SearchSessionState.Completed); + }); + test('getSearchOptions infers isRestore & isStored from state', async () => { const sessionId = sessionService.start(); const someOtherId = 'some-other-id'; diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 629d76b07d7ca..32cd620a2adb2 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { ConfigSchema } from '../../../config'; import { createSessionStateContainer, SearchSessionState, + SessionStateInternal, SessionMeta, SessionStateContainer, } from './search_session_state'; @@ -35,6 +36,11 @@ export interface TrackSearchDescriptor { abort: () => void; } +/** + * Represents a search session state in {@link SessionService} in any given moment of time + */ +export type SessionSnapshot = SessionStateInternal; + /** * Provide info about current search session to be stored in the Search Session saved object */ @@ -88,6 +94,13 @@ export class SessionService { private toastService?: ToastService; + /** + * Holds snapshot of last cleared session so that it can be continued + * Can be used to re-use a session between apps + * @private + */ + private lastSessionSnapshot?: SessionSnapshot; + constructor( initializerContext: PluginInitializerContext, getStartServices: StartServicesAccessor, @@ -128,6 +141,21 @@ export class SessionService { this.subscription.add( coreStart.application.currentAppId$.subscribe((newAppName) => { this.currentApp = newAppName; + if (!this.getSessionId()) return; + + // Apps required to clean up their sessions before unmounting + // Make sure that apps don't leave sessions open by throwing an error in DEV mode + const message = `Application '${ + this.state.get().appName + }' had an open session while navigating`; + if (initializerContext.env.mode.dev) { + coreStart.fatalErrors.add(message); + } else { + // this should never happen in prod because should be caught in dev mode + // in case this happen we don't want to throw fatal error, as most likely possible bugs are not that critical + // eslint-disable-next-line no-console + console.warn(message); + } }) ); }); @@ -158,6 +186,7 @@ export class SessionService { public destroy() { this.subscription.unsubscribe(); this.clear(); + this.lastSessionSnapshot = undefined; } /** @@ -198,7 +227,9 @@ export class SessionService { */ public start() { if (!this.currentApp) throw new Error('this.currentApp is missing'); + this.state.transitions.start({ appName: this.currentApp }); + return this.getSessionId()!; } @@ -211,10 +242,52 @@ export class SessionService { this.refreshSearchSessionSavedObject(); } + /** + * Continue previous search session + * Can be used to share a running search session between different apps, so they can reuse search cache + * + * This is different from {@link restore} as it reuses search session state and search results held in client memory instead of restoring search results from elasticsearch + * @param sessionId + */ + public continue(sessionId: string) { + if (this.lastSessionSnapshot?.sessionId === sessionId) { + this.state.set({ + ...this.lastSessionSnapshot, + // have to change a name, so that current app can cancel a session that it continues + appName: this.currentApp, + // also have to drop all pending searches which are used to derive client side state of search session indicator, + // if we weren't dropping this searches, then we would get into "infinite loading" state when continuing a session that was cleared with pending searches + // possible solution to this problem is to refactor session service to support multiple sessions + pendingSearches: [], + }); + this.lastSessionSnapshot = undefined; + } else { + // eslint-disable-next-line no-console + console.warn( + `Continue search session: last known search session id: "${this.lastSessionSnapshot?.sessionId}", but received ${sessionId}` + ); + } + } + /** * Cleans up current state */ public clear() { + // make sure apps can't clear other apps' sessions + const currentSessionApp = this.state.get().appName; + if (currentSessionApp && currentSessionApp !== this.currentApp) { + // eslint-disable-next-line no-console + console.warn( + `Skip clearing session "${this.getSessionId()}" because it belongs to a different app. current: "${ + this.currentApp + }", owner: "${currentSessionApp}"` + ); + return; + } + + if (this.getSessionId()) { + this.lastSessionSnapshot = this.state.get(); + } this.state.transitions.clear(); this.searchSessionInfoProvider = undefined; this.searchSessionIndicatorUiConfig = undefined; diff --git a/src/plugins/data/server/autocomplete/terms_agg.test.ts b/src/plugins/data/server/autocomplete/terms_agg.test.ts index e4652c2c422e2..ae991e289a715 100644 --- a/src/plugins/data/server/autocomplete/terms_agg.test.ts +++ b/src/plugins/data/server/autocomplete/terms_agg.test.ts @@ -32,6 +32,8 @@ const mockResponse = { }, } as ApiResponse>; +jest.mock('../index_patterns'); + describe('terms agg suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -86,4 +88,50 @@ describe('terms agg suggestions', () => { ] `); }); + + it('calls the _search API with a terms agg and fallback to fieldName when field is null', async () => { + const result = await termsAggSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.search.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "suggestions": Object { + "terms": Object { + "execution_hint": "map", + "field": "fieldName", + "include": "query.*", + "shard_size": 10, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + "size": 0, + "terminate_after": 98430, + "timeout": "4513ms", + }, + "index": "index", + } + `); + expect(result).toMatchInlineSnapshot(` + Array [ + "whoa", + "amazing", + ] + `); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.test.ts b/src/plugins/data/server/autocomplete/terms_enum.test.ts index be8f179db29c0..41eaf3f4032ab 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.test.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.test.ts @@ -22,6 +22,8 @@ const mockResponse = { body: { terms: ['whoa', 'amazing'] }, }; +jest.mock('../index_patterns'); + describe('_terms_enum suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -71,4 +73,45 @@ describe('_terms_enum suggestions', () => { `); expect(result).toEqual(mockResponse.body.terms); }); + + it('calls the _terms_enum API and fallback to fieldName when field is null', async () => { + const result = await termsEnumSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.transport.request.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "field": "fieldName", + "index_filter": Object { + "bool": Object { + "must": Array [ + Object { + "terms": Object { + "_tier": Array [ + "data_hot", + "data_warm", + "data_content", + ], + }, + }, + ], + }, + }, + "string": "query", + }, + "method": "POST", + "path": "/index/_terms_enum", + } + `); + expect(result).toEqual(mockResponse.body.terms); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.ts b/src/plugins/data/server/autocomplete/terms_enum.ts index c2452b0a099d0..40329586a3621 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.ts @@ -36,7 +36,7 @@ export async function termsEnumSuggestions( method: 'POST', path: encodeURI(`/${index}/_terms_enum`), body: { - field: field?.name ?? field, + field: field?.name ?? fieldName, string: query, index_filter: { bool: { diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 058fd832e15db..ea90307ef57a1 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -111,6 +111,7 @@ export class EditPanelAction implements Action { originatingApp: this.currentAppId, valueInput: byValueMode ? this.getExplicitInput({ embeddable }) : undefined, embeddableId: embeddable.id, + searchSessionId: embeddable.getInput().searchSessionId, }; return { app, path, state }; } diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 5e5ef9c360a64..98cf6e70284cd 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -19,6 +19,12 @@ export interface EmbeddableEditorState { originatingApp: string; embeddableId?: string; valueInput?: EmbeddableInput; + + /** + * Pass current search session id when navigating to an editor, + * Editors could use it continue previous search session + */ + searchSessionId?: string; } export function isEmbeddableEditorState(state: unknown): state is EmbeddableEditorState { @@ -35,6 +41,12 @@ export interface EmbeddablePackageState { type: string; input: Optional | Optional; embeddableId?: string; + + /** + * Pass current search session id when navigating to an editor, + * Editors could use it continue previous search session + */ + searchSessionId?: string; } export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState { diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 98c48dbd848b0..a810b1f48a07c 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -365,6 +365,7 @@ export interface EmbeddableEditorState { embeddableId?: string; // (undocumented) originatingApp: string; + searchSessionId?: string; // (undocumented) valueInput?: EmbeddableInput; } @@ -467,6 +468,7 @@ export interface EmbeddablePackageState { embeddableId?: string; // (undocumented) input: Optional | Optional; + searchSessionId?: string; // (undocumented) type: string; } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 65857f02c883d..54a3fe9e4399c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -129,6 +129,7 @@ export const applicationUsageSchema = { error: commonSchema, status: commonSchema, kibanaOverview: commonSchema, + r: commonSchema, // X-Pack apm: commonSchema, diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 809cb15c3e960..18f59186f6183 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -6,87 +6,5 @@ * Side Public License, v 1. */ -import { SavedObjectReference } from '../../../../core/types'; - -export type SerializableValue = string | number | boolean | null | undefined | SerializableState; -export type Serializable = SerializableValue | SerializableValue[]; - -export type SerializableState = { - [key: string]: Serializable; -}; - -export type MigrateFunction< - FromVersion extends SerializableState = SerializableState, - ToVersion extends SerializableState = SerializableState -> = (state: FromVersion) => ToVersion; - -export type MigrateFunctionsObject = { - [key: string]: MigrateFunction; -}; - -export interface PersistableStateService

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * migrateToLatest function receives state of older version and should migrate to the latest version - * @param state - * @param version - */ - migrateToLatest?: (state: SerializableState, version: string) => P; - - /** - * migrate function runs the specified migration - * @param state - * @param version - */ - migrate: (state: SerializableState, version: string) => SerializableState; -} - -export interface PersistableState

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * list of all migrations per semver - */ - migrations: MigrateFunctionsObject; -} - -export type PersistableStateDefinition

= Partial< - PersistableState

->; +export * from './types'; +export { migrateToLatest } from './migrate_to_latest'; diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts new file mode 100644 index 0000000000000..2ae376e787d2f --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableState, MigrateFunction } from './types'; +import { migrateToLatest } from './migrate_to_latest'; + +interface StateV1 extends SerializableState { + name: string; +} + +interface StateV2 extends SerializableState { + firstName: string; + lastName: string; +} + +interface StateV3 extends SerializableState { + firstName: string; + lastName: string; + isAdmin: boolean; + age: number; +} + +const migrationV2: MigrateFunction = ({ name }) => { + return { + firstName: name, + lastName: '', + }; +}; + +const migrationV3: MigrateFunction = ({ firstName, lastName }) => { + return { + firstName, + lastName, + isAdmin: false, + age: 0, + }; +}; + +test('returns the same object if there are no migrations to be applied', () => { + const migrated = migrateToLatest( + {}, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(migrated).toEqual({ + state: { name: 'Foo' }, + version: '0.0.1', + }); +}); + +test('applies a single migration', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + }); + expect(newVersion).toEqual('0.0.2'); +}); + +test('does not apply migration if it has the same version as state', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.54': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.54', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.0.54'); +}); + +test('does not apply migration if it has lower version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.2.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.3.1', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.3.1'); +}); + +test('applies two migrations consecutively', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '7.13.4', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); + +test('applies only migrations which are have higher semver version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, // not applied + '7.14.1': (() => ({})) as MigrateFunction, // not applied + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { firstName: 'FooBar', lastName: 'Baz' }, + version: '7.14.1', + } + ); + + expect(newState).toEqual({ + firstName: 'FooBar', + lastName: 'Baz', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts new file mode 100644 index 0000000000000..c16392164e3e4 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { compare } from 'semver'; +import { SerializableState, VersionedState, MigrateFunctionsObject } from './types'; + +export function migrateToLatest( + migrations: MigrateFunctionsObject, + { state, version: oldVersion }: VersionedState +): VersionedState { + const versions = Object.keys(migrations || {}) + .filter((v) => compare(v, oldVersion) > 0) + .sort(compare); + + if (!versions.length) return { state, version: oldVersion } as VersionedState; + + for (const version of versions) { + state = migrations[version]!(state); + } + + return { + state: state as S, + version: versions[versions.length - 1], + }; +} diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts new file mode 100644 index 0000000000000..f7168b46e7fca --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectReference } from '../../../../core/types'; + +/** + * Serializable state is something is a POJO JavaScript object that can be + * serialized to a JSON string. + */ +export type SerializableState = { + [key: string]: Serializable; +}; +export type SerializableValue = string | number | boolean | null | undefined | SerializableState; +export type Serializable = SerializableValue | SerializableValue[]; + +/** + * Versioned state is a POJO JavaScript object that can be serialized to JSON, + * and which also contains the version information. The version is stored in + * semver format and corresponds to the Kibana release version when the object + * was created. The version can be used to apply migrations to the object. + * + * For example: + * + * ```ts + * const obj: VersionedState<{ dashboardId: string }> = { + * version: '7.14.0', + * state: { + * dashboardId: '123', + * }, + * }; + * ``` + */ +export interface VersionedState { + version: string; + state: S; +} + +/** + * Persistable state interface can be implemented by something that persists + * (stores) state, for example, in a saved object. Once implemented that thing + * will gain ability to "extract" and "inject" saved object references, which + * are necessary for various saved object tasks, such as export. It will also be + * able to do state migrations across Kibana versions, if the shape of the state + * would change over time. + * + * @todo Maybe rename it to `PersistableStateItem`? + */ +export interface PersistableState

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry: (state: P, stats: Record) => Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject: (state: P, references: SavedObjectReference[]) => P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract: (state: P) => { state: P; references: SavedObjectReference[] }; + + /** + * A list of migration functions, which migrate the persistable state + * serializable object to the next version. Migration functions should are + * keyed by the Kibana version using semver, where the version indicates to + * which version the state will be migrated to. + */ + migrations: MigrateFunctionsObject; +} + +/** + * Collection of migrations that a given type of persistable state object has + * accumulated over time. Migration functions are keyed using semver version + * of Kibana releases. + */ +export type MigrateFunctionsObject = { [semver: string]: MigrateFunction }; +export type MigrateFunction< + FromVersion extends SerializableState = SerializableState, + ToVersion extends SerializableState = SerializableState +> = (state: FromVersion) => ToVersion; + +/** + * @todo Shall we remove this? + */ +export type PersistableStateDefinition

= Partial< + PersistableState

+>; + +/** + * @todo Add description. + */ +export interface PersistableStateService

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry(state: P, collector: Record): Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject(state: P, references: SavedObjectReference[]): P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract(state: P): { state: P; references: SavedObjectReference[] }; + + /** + * Migrate function runs a specified migration of a {@link PersistableState} + * item. + * + * When using this method it is up to consumer to make sure that the + * migration function are executed in the right semver order. To avoid such + * potentially error prone complexity, prefer using `migrateToLatest` method + * instead. + * + * @param state The old persistable state serializable state object, which + * needs a migration. + * @param version Semver version of the migration to execute. + * @returns Persistable state object updated with the specified migration + * applied to it. + */ + migrate(state: SerializableState, version: string): SerializableState; + + /** + * A function which receives the state of an older object and version and + * should migrate the state of the object to the latest possible version using + * the `.migrations` dictionary provided on a {@link PersistableState} item. + * + * @param state The persistable state serializable state object. + * @param version Current semver version of the `state`. + * @returns A serializable state object migrated to the latest state. + */ + migrateToLatest?: (state: VersionedState) => VersionedState

; +} diff --git a/src/plugins/share/common/mocks.ts b/src/plugins/share/common/mocks.ts new file mode 100644 index 0000000000000..6768c1aff810a --- /dev/null +++ b/src/plugins/share/common/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_service/mocks'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 680fb2231fc48..bae57b6d8a31d 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -30,7 +30,7 @@ export interface LocatorDependencies { getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise; } -export class Locator

implements PersistableState

, LocatorPublic

{ +export class Locator

implements LocatorPublic

{ public readonly migrations: PersistableState

['migrations']; constructor( diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts new file mode 100644 index 0000000000000..be86cfe401713 --- /dev/null +++ b/src/plugins/share/common/url_service/mocks.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import type { LocatorDefinition, KibanaLocation } from '.'; +import { UrlService } from '.'; + +export class MockUrlService extends UrlService { + constructor() { + super({ + navigate: async () => {}, + getUrl: async ({ app, path }, { absolute }) => { + return `${absolute ? 'https://example.com' : ''}/app/${app}${path}`; + }, + }); + } +} + +export class MockLocatorDefinition implements LocatorDefinition { + constructor(public readonly id: string) {} + + public readonly getLocation = async (): Promise => { + return { + app: 'test', + path: '/test', + state: { + foo: 'bar', + }, + }; + }; +} diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 5ee3156534c5e..1f999b59ddb61 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -9,6 +9,7 @@ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; +export { parseSearchParams, formatSearchParams } from './url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts new file mode 100644 index 0000000000000..eb9c6d0d10906 --- /dev/null +++ b/src/plugins/share/public/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../common/mocks'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 893108b56bcfa..adc28556d7a3c 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -19,6 +19,7 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; import { UrlService } from '../common/url_service'; +import { RedirectManager } from './url_service'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -86,6 +87,11 @@ export class SharePlugin implements Plugin { }, }); + const redirectManager = new RedirectManager({ + url: this.url, + }); + redirectManager.registerRedirectApp(core); + return { ...this.shareMenuRegistry.setup(), urlGenerators: this.urlGeneratorsService.setup(core), diff --git a/src/plugins/share/public/url_service/index.ts b/src/plugins/share/public/url_service/index.ts new file mode 100644 index 0000000000000..8fa88e9c570bd --- /dev/null +++ b/src/plugins/share/public/url_service/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect'; diff --git a/src/plugins/share/public/url_service/redirect/README.md b/src/plugins/share/public/url_service/redirect/README.md new file mode 100644 index 0000000000000..cd31f2b80099b --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/README.md @@ -0,0 +1,18 @@ +# Redirect endpoint + +This folder contains implementation of *the Redirect Endpoint*. The Redirect +Endpoint receives parameters of a locator and then "redirects" the user using +navigation without page refresh to the location targeted by the locator. While +using the locator, it is also possible to set the *location state* of the +target page. Location state is a serializable object which can be passed to +the destination app while navigating without a page reload. + +``` +/app/r?l=MY_LOCATOR&v=7.14.0&p=(dashboardId:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +``` + +For example: + +``` +/app/r?l=DISCOVER_APP_LOCATOR&v=7.14.0&p={%22indexPatternId%22:%22d3d7af60-4c81-11e8-b3d7-01146121b73d%22} +``` diff --git a/src/plugins/share/public/url_service/redirect/components/error.tsx b/src/plugins/share/public/url_service/redirect/components/error.tsx new file mode 100644 index 0000000000000..716848427c638 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/error.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { + EuiEmptyPrompt, + EuiCallOut, + EuiCodeBlock, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultTitle = i18n.translate('share.urlService.redirect.components.Error.title', { + defaultMessage: 'Redirection error', + description: + 'Title displayed to user in redirect endpoint when redirection cannot be performed successfully.', +}); + +export interface ErrorProps { + title?: string; + error: Error; +} + +export const Error: React.FC = ({ title = defaultTitle, error }) => { + return ( + {title}} + body={ + + + + {error.message} + + + + + {error.stack ? error.stack : ''} + + + } + /> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/page.tsx b/src/plugins/share/public/url_service/redirect/components/page.tsx new file mode 100644 index 0000000000000..805213b73fdd0 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/page.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiPageTemplate } from '@elastic/eui'; +import { Error } from './error'; +import { RedirectManager } from '../redirect_manager'; +import { Spinner } from './spinner'; + +export interface PageProps { + manager: Pick; +} + +export const Page: React.FC = ({ manager }) => { + const error = useObservable(manager.error$); + + if (error) { + return ( + + + + ); + } + + return ( + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/spinner.tsx b/src/plugins/share/public/url_service/redirect/components/spinner.tsx new file mode 100644 index 0000000000000..a70ae5eb096af --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/spinner.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const text = i18n.translate('share.urlService.redirect.components.Spinner.label', { + defaultMessage: 'Redirecting…', + description: 'Redirect endpoint spinner label.', +}); + +export const Spinner: React.FC = () => { + return ( + + + + + + + + + {text} + + + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/index.ts b/src/plugins/share/public/url_service/redirect/index.ts new file mode 100644 index 0000000000000..8dbc5f4e0ab1c --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect_manager'; +export { formatSearchParams } from './util/format_search_params'; +export { parseSearchParams } from './util/parse_search_params'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts new file mode 100644 index 0000000000000..f610268f529bc --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectManager } from './redirect_manager'; +import { MockUrlService } from '../../mocks'; +import { MigrateFunction } from 'src/plugins/kibana_utils/common'; + +const setup = () => { + const url = new MockUrlService(); + const locator = url.locators.create({ + id: 'TEST_LOCATOR', + getLocation: async () => { + return { + app: '', + path: '', + state: {}, + }; + }, + migrations: { + '0.0.2': ((({ num }: { num: number }) => ({ num: num * 2 })) as unknown) as MigrateFunction, + }, + }); + const manager = new RedirectManager({ + url, + }); + + return { + url, + locator, + manager, + }; +}; + +describe('on page mount', () => { + test('execute locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + expect(spy).toHaveBeenCalledTimes(0); + manager.onMount(`l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}`); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('passes arguments provided in URL to locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent( + JSON.stringify({ + foo: 'bar', + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + foo: 'bar', + }); + }); + + test('migrates parameters on-the-fly to the latest version', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.1&p=${encodeURIComponent( + JSON.stringify({ + num: 1, + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + num: 2, + }); + }); + + test('throws if locator does not exist', async () => { + const { manager } = setup(); + + expect(() => + manager.onMount( + `l=TEST_LOCATOR_WHICH_DOES_NOT_EXIST&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}` + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator [ID = TEST_LOCATOR_WHICH_DOES_NOT_EXIST] does not exist."` + ); + }); +}); diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts new file mode 100644 index 0000000000000..6148249f5a047 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { migrateToLatest } from '../../../../kibana_utils/common'; +import type { SerializableState } from '../../../../kibana_utils/common'; +import type { UrlService } from '../../../common/url_service'; +import { render } from './render'; +import { parseSearchParams } from './util/parse_search_params'; + +export interface RedirectOptions { + /** Locator ID. */ + id: string; + + /** Kibana version when locator params where generated. */ + version: string; + + /** Locator params. */ + params: unknown & SerializableState; +} + +export interface RedirectManagerDependencies { + url: UrlService; +} + +export class RedirectManager { + public readonly error$ = new BehaviorSubject(null); + + constructor(public readonly deps: RedirectManagerDependencies) {} + + public registerRedirectApp(core: CoreSetup) { + core.application.register({ + id: 'r', + title: 'Redirect endpoint', + chromeless: true, + mount: (params) => { + const unmount = render(params.element, { manager: this }); + this.onMount(params.history.location.search); + return () => { + unmount(); + }; + }, + }); + } + + public onMount(urlLocationSearch: string) { + const options = this.parseSearchParams(urlLocationSearch); + const locator = this.deps.url.locators.get(options.id); + + if (!locator) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.locatorNotFound', { + defaultMessage: 'Locator [ID = {id}] does not exist.', + values: { + id: options.id, + }, + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator does not exist.', + }); + const error = new Error(message); + this.error$.next(error); + throw error; + } + + const { state: migratedParams } = migrateToLatest(locator.migrations, { + state: options.params, + version: options.version, + }); + + locator + .navigate(migratedParams) + .then() + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Redirect endpoint failed to execute locator redirect.'); + // eslint-disable-next-line no-console + console.error(error); + }); + } + + protected parseSearchParams(urlLocationSearch: string): RedirectOptions { + try { + return parseSearchParams(urlLocationSearch); + } catch (error) { + this.error$.next(error); + throw error; + } + } +} diff --git a/src/plugins/share/public/url_service/redirect/render.ts b/src/plugins/share/public/url_service/redirect/render.ts new file mode 100644 index 0000000000000..2b9c3a50758e4 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/render.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Page, PageProps } from './components/page'; + +export const render = (container: HTMLElement, props: PageProps) => { + ReactDOM.render(React.createElement(Page, props), container); + + return () => { + ReactDOM.unmountComponentAtNode(container); + }; +}; diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts new file mode 100644 index 0000000000000..f8d8d6a6295d9 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { formatSearchParams } from './format_search_params'; +import { parseSearchParams } from './parse_search_params'; + +test('can format typical locator settings as URL path search params', () => { + const search = formatSearchParams({ + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }); + + expect(search.get('l')).toBe('LOCATOR_ID'); + expect(search.get('v')).toBe('7.21.3'); + expect(JSON.parse(search.get('p')!)).toEqual({ + dashboardId: '123', + mode: 'edit', + }); +}); + +test('can format and then parse redirect options', () => { + const options = { + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }; + const formatted = formatSearchParams(options); + const parsed = parseSearchParams(formatted.toString()); + + expect(parsed).toEqual(options); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts new file mode 100644 index 0000000000000..12c6424182a87 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectOptions } from '../redirect_manager'; + +export function formatSearchParams(opts: RedirectOptions): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set('l', opts.id); + searchParams.set('v', opts.version); + searchParams.set('p', JSON.stringify(opts.params)); + + return searchParams; +} diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts new file mode 100644 index 0000000000000..418e21cfd4053 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseSearchParams } from './parse_search_params'; + +test('parses a well constructed URL path search part', () => { + const res = parseSearchParams(`?l=LOCATOR&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`); + + expect(res).toEqual({ + id: 'LOCATOR', + version: '0.0.0', + params: { + foo: 'bar', + }, + }); +}); + +test('throws on missing locator ID', () => { + expect(() => + parseSearchParams(`?v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); + + expect(() => + parseSearchParams(`?l=&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); +}); + +test('throws on missing version', () => { + expect(() => + parseSearchParams(`?l=LOCATOR&v=&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); + + expect(() => + parseSearchParams(`?l=LOCATOR&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); +}); + +test('throws on missing params', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); + + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); +}); + +test('throws if params are not JSON', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=asdf`)).toThrowErrorMatchingInlineSnapshot( + `"Could not parse locator params. Locator params must be serialized as JSON and set at \\"p\\" URL search parameter."` + ); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts new file mode 100644 index 0000000000000..a60c1d1b68a97 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import { i18n } from '@kbn/i18n'; +import type { RedirectOptions } from '../redirect_manager'; + +/** + * Parses redirect endpoint URL path search parameters. Expects them in the + * following form: + * + * ``` + * /r?l=&v=&p= + * ``` + * + * @param urlSearch Search part of URL path. + * @returns Parsed out locator ID, version, and locator params. + */ +export function parseSearchParams(urlSearch: string): RedirectOptions { + const search = new URLSearchParams(urlSearch); + const id = search.get('l'); + const version = search.get('v'); + const paramsJson = search.get('p'); + + if (!id) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamLocator', + { + defaultMessage: + 'Locator ID not specified. Specify "l" search parameter in the URL, which should be an existing locator ID.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing locator ID.', + } + ); + throw new Error(message); + } + + if (!version) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamVersion', + { + defaultMessage: + 'Locator params version not specified. Specify "v" search parameter in the URL, which should be the release version of Kibana when locator params were generated.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing version parameter.', + } + ); + throw new Error(message); + } + + if (!paramsJson) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.missingParamParams', { + defaultMessage: + 'Locator params not specified. Specify "p" search parameter in the URL, which should be JSON serialized object of locator params.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing params parameter.', + }); + throw new Error(message); + } + + let params: unknown & SerializableState; + try { + params = JSON.parse(paramsJson); + } catch { + const message = i18n.translate('share.urlService.redirect.RedirectManager.invalidParamParams', { + defaultMessage: + 'Could not parse locator params. Locator params must be serialized as JSON and set at "p" URL search parameter.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator parameters could not be parsed as JSON.', + }); + throw new Error(message); + } + + return { + id, + version, + params, + }; +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d11e1cf78c960..13caa3c33fa82 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1743,6 +1743,137 @@ } } }, + "r": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "apm": { "properties": { "appId": { diff --git a/src/plugins/user_setup/README.md b/src/plugins/user_setup/README.md new file mode 100644 index 0000000000000..61ec964f5bb80 --- /dev/null +++ b/src/plugins/user_setup/README.md @@ -0,0 +1,3 @@ +# `userSetup` plugin + +The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/user_setup/jest.config.js b/src/plugins/user_setup/jest.config.js new file mode 100644 index 0000000000000..75e355e230c5d --- /dev/null +++ b/src/plugins/user_setup/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/user_setup'], +}; diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json new file mode 100644 index 0000000000000..192fd42cd3e26 --- /dev/null +++ b/src/plugins/user_setup/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "userSetup", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin provides UI and APIs for the interactive setup mode.", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["userSetup"], + "server": true, + "ui": true +} diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx new file mode 100644 index 0000000000000..2b6b708953972 --- /dev/null +++ b/src/plugins/user_setup/public/app.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; + +export const App = () => { + return ( + + + Kibana server is not ready yet. + + + ); +}; diff --git a/src/plugins/user_setup/public/index.ts b/src/plugins/user_setup/public/index.ts new file mode 100644 index 0000000000000..153bc92a0dd08 --- /dev/null +++ b/src/plugins/user_setup/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UserSetupPlugin } from './plugin'; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx new file mode 100644 index 0000000000000..677c27cc456dc --- /dev/null +++ b/src/plugins/user_setup/public/plugin.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { App } from './app'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'userSetup', + title: 'User Setup', + chromeless: true, + mount: (params) => { + ReactDOM.render(, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); + }, + }); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/user_setup/server/config.ts b/src/plugins/user_setup/server/config.ts new file mode 100644 index 0000000000000..b16c51bcbda09 --- /dev/null +++ b/src/plugins/user_setup/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); diff --git a/src/plugins/user_setup/server/index.ts b/src/plugins/user_setup/server/index.ts new file mode 100644 index 0000000000000..2a43cbbf65c9d --- /dev/null +++ b/src/plugins/user_setup/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'src/core/server'; + +import { ConfigSchema } from './config'; +import { UserSetupPlugin } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, +}; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts new file mode 100644 index 0000000000000..918c9a2007935 --- /dev/null +++ b/src/plugins/user_setup/server/plugin.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/server'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json new file mode 100644 index 0000000000000..d211a70f12df3 --- /dev/null +++ b/src/plugins/user_setup/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*", "server/**/*"], + "references": [{ "path": "../../core/tsconfig.json" }] +} diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 7f72d44c50ea0..2efc145b12561 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -21,7 +21,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); const kibanaServer = getService('kibanaServer'); - describe('discover - context - back navigation', function contextSize() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104364 + describe.skip('discover - context - back navigation', function contextSize() { before(async function () { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index a09be8b35ba8f..6a2298ba48cb4 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); - describe('context link in discover', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104413 + describe.skip('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index 5bcec338aad1e..33d015a4c6019 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'timePicker', 'discover']); - describe('dashboard saved search embeddable', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104365 + describe.skip('dashboard saved search embeddable', () => { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index b29b07f9df4e4..1ca70112c3d1e 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); - describe('dashboard view edit mode', function viewEditModeTests() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104467 + describe.skip('dashboard view edit mode', function viewEditModeTests() { before(async () => { await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index bb75b4441f880..245b895d75b3a 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -38,7 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('query', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104409 + describe.skip('query', function () { const queryName1 = 'Query # 1'; it('should show correct time range string by timepicker', async function () { diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 338d17ba31ff4..5ab6495686726 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -33,7 +33,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104466 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 20f2cab907d9b..29073c5fe4ebb 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -40,7 +40,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('saved query management component functionality', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104366 + describe.skip('saved query management component functionality', function () { before(async function () { // set up a query with filters and a time filter log.debug('set up a query with filters to save'); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 1d4d4fee0175e..ca310493960f5 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -125,7 +125,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('switch index patterns', () => { + // FLAKY: https://github.com/elastic/kibana/issues/103252 + describe.skip('switch index patterns', () => { before(async () => { await esArchiver.loadIfNeeded( 'test/functional/fixtures/es_archiver/index_pattern_without_timefield' diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts index 25090e14ebf91..3912b60dd56ed 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts @@ -9,23 +9,28 @@ import Path from 'path'; const ES_ARCHIVE_DIR = './cypress/fixtures/es_archiver'; +// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +const NODE_TLS_REJECT_UNAUTHORIZED = '1'; + export const esArchiverLoad = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js` + `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverUnload = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js` + `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverResetKibana = () => { cy.exec( `node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.js`, - { failOnNonZeroExit: false } + { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index be3895967d4dc..5a56b64374537 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -157,9 +157,9 @@ export function DetailView({ errorGroup, urlParams }: Props) { { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts index 30995fbd13397..0e78e44eedf77 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts @@ -7,30 +7,17 @@ import { i18n } from '@kbn/i18n'; import { IBasePath } from 'kibana/public'; -import { isEmpty } from 'lodash'; import moment from 'moment'; import { APIReturnType } from '../../../../../services/rest/createCallApmApi'; import { getInfraHref } from '../../../../shared/Links/InfraLink'; +import { + Action, + getNonEmptySections, + SectionRecord, +} from '../../../../shared/transaction_action_menu/sections_helper'; type InstaceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; -interface Action { - key: string; - label: string; - href?: string; - onClick?: () => void; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record; - function getInfraMetricsQuery(timestamp?: string) { if (!timestamp) { return { from: 0, to: 0 }; @@ -189,15 +176,5 @@ export function getMenuSections({ apm: [{ key: 'apm', actions: apmActions }], }; - // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts index 0e30cfe3168f1..ebc48e1e9faf4 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts @@ -17,6 +17,7 @@ import { getDiscoverHref } from '../Links/DiscoverLinks/DiscoverLink'; import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; import { getInfraHref } from '../Links/InfraLink'; import { fromQuery } from '../Links/url_helpers'; +import { SectionRecord, getNonEmptySections, Action } from './sections_helper'; function getInfraMetricsQuery(transaction: Transaction) { const timestamp = new Date(transaction['@timestamp']).getTime(); @@ -28,22 +29,6 @@ function getInfraMetricsQuery(transaction: Transaction) { }; } -interface Action { - key: string; - label: string; - href: string; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record; - export const getSections = ({ transaction, basePath, @@ -296,14 +281,5 @@ export const getSections = ({ }; // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); }; diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts new file mode 100644 index 0000000000000..741a66d71be14 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getNonEmptySections } from './sections_helper'; + +describe('getNonEmptySections', () => { + it('returns empty when no section is available', () => { + expect(getNonEmptySections({})).toEqual([]); + }); + it("returns empty when section doesn't have actions", () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + }) + ).toEqual([]); + }); + + it('returns only sections with actions with condition true', () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + bar: [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + { + key: 'bar_action_2', + label: 'Bar Action 2', + condition: false, + }, + ], + }, + ], + }) + ).toEqual([ + [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + ], + }, + ], + ]); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts new file mode 100644 index 0000000000000..1632fdb678013 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; + +export interface Action { + key: string; + label: string; + href?: string; + onClick?: () => void; + condition: boolean; +} + +interface Section { + key: string; + title?: string; + subtitle?: string; + actions: Action[]; +} + +export type SectionRecord = Record; + +/** Filter out actions that shouldnt be shown and sections without any actions. */ +export function getNonEmptySections(sectionRecord: SectionRecord) { + return Object.values(sectionRecord) + .map((sections) => + sections + .map((section) => ({ + ...section, + actions: section.actions.filter((action) => action.condition), + })) + .filter((section) => !isEmpty(section.actions)) + ) + .filter((sections) => !isEmpty(sections)); +} diff --git a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts index 8bfb137c1689c..60ce36a85235e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts @@ -6,7 +6,7 @@ */ import { SearchAggregatedTransactionSetting } from '../../../../common/aggregated_transactions'; -import { rangeQuery } from '../../../../server/utils/queries'; +import { kqlQuery, rangeQuery } from '../../../../server/utils/queries'; import { ProcessorEvent } from '../../../../common/processor_event'; import { TRANSACTION_DURATION, @@ -19,10 +19,12 @@ export async function getHasAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }) { const response = await apmEventClient.search( 'get_has_aggregated_transactions', @@ -36,6 +38,7 @@ export async function getHasAggregatedTransactions({ filter: [ { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, ...(start && end ? rangeQuery(start, end) : []), + ...kqlQuery(kuery), ], }, }, @@ -56,19 +59,22 @@ export async function getSearchAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { config: APMConfig; start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }): Promise { const searchAggregatedTransactions = config['xpack.apm.searchAggregatedTransactions']; if ( + kuery || searchAggregatedTransactions === SearchAggregatedTransactionSetting.auto ) { - return getHasAggregatedTransactions({ start, end, apmEventClient }); + return getHasAggregatedTransactions({ start, end, apmEventClient, kuery }); } return ( diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts index 5820fd952c449..7a511fc60fd06 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts @@ -9,7 +9,7 @@ import { shuffle, range } from 'lodash'; import type { ElasticsearchClient } from 'src/core/server'; import { fetchTransactionDurationFieldCandidates } from './query_field_candidates'; import { fetchTransactionDurationFieldValuePairs } from './query_field_value_pairs'; -import { fetchTransactionDurationPecentiles } from './query_percentiles'; +import { fetchTransactionDurationPercentiles } from './query_percentiles'; import { fetchTransactionDurationCorrelation } from './query_correlation'; import { fetchTransactionDurationHistogramRangesteps } from './query_histogram_rangesteps'; import { fetchTransactionDurationRanges, HistogramItem } from './query_ranges'; @@ -59,7 +59,7 @@ export const asyncSearchServiceProvider = ( const fetchCorrelations = async () => { try { // 95th percentile to be displayed as a marker in the log log chart - const percentileThreshold = await fetchTransactionDurationPecentiles( + const percentileThreshold = await fetchTransactionDurationPercentiles( esClient, params, params.percentileThreshold ? [params.percentileThreshold] : undefined @@ -93,7 +93,7 @@ export const asyncSearchServiceProvider = ( // Create an array of ranges [2, 4, 6, ..., 98] const percents = Array.from(range(2, 100, 2)); - const percentilesRecords = await fetchTransactionDurationPecentiles( + const percentilesRecords = await fetchTransactionDurationPercentiles( esClient, params, percents diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts new file mode 100644 index 0000000000000..12e897ab3eec9 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getQueryWithParams } from './get_query_with_params'; + +describe('correlations', () => { + describe('getQueryWithParams', () => { + it('returns the most basic query filtering on processor.event=transaction', () => { + const query = getQueryWithParams({ params: { index: 'apm-*' } }); + expect(query).toEqual({ + bool: { + filter: [{ term: { 'processor.event': 'transaction' } }], + }, + }); + }); + + it('returns a query considering additional params', () => { + const query = getQueryWithParams({ + params: { + index: 'apm-*', + serviceName: 'actualServiceName', + transactionName: 'actualTransactionName', + start: '01-01-2021', + end: '31-01-2021', + environment: 'dev', + percentileThresholdValue: 75, + }, + }); + expect(query).toEqual({ + bool: { + filter: [ + { term: { 'processor.event': 'transaction' } }, + { + term: { + 'service.name': 'actualServiceName', + }, + }, + { + term: { + 'transaction.name': 'actualTransactionName', + }, + }, + { + range: { + '@timestamp': { + gte: '01-01-2021', + lte: '31-01-2021', + }, + }, + }, + { + term: { + 'service.environment': 'dev', + }, + }, + { + range: { + 'transaction.duration.us': { + gte: 75, + }, + }, + }, + ], + }, + }); + }); + + it('returns a query considering a custom field/value pair', () => { + const query = getQueryWithParams({ + params: { index: 'apm-*' }, + fieldName: 'actualFieldName', + fieldValue: 'actualFieldValue', + }); + expect(query).toEqual({ + bool: { + filter: [ + { term: { 'processor.event': 'transaction' } }, + { + term: { + actualFieldName: 'actualFieldValue', + }, + }, + ], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts index e7cf8173b5bac..08ba4b23fec35 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts @@ -43,6 +43,10 @@ const getRangeQuery = ( start?: string, end?: string ): estypes.QueryDslQueryContainer[] => { + if (start === undefined && end === undefined) { + return []; + } + return [ { range: { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts new file mode 100644 index 0000000000000..24741ebaa2dae --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationCorrelation, + getTransactionDurationCorrelationRequest, + BucketCorrelation, +} from './query_correlation'; + +const params = { index: 'apm-*' }; +const expectations = [1, 3, 5]; +const ranges = [{ to: 1 }, { from: 1, to: 3 }, { from: 3, to: 5 }, { from: 5 }]; +const fractions = [1, 2, 4, 5]; +const totalDocCount = 1234; + +describe('query_correlation', () => { + describe('getTransactionDurationCorrelationRequest', () => { + it('applies options to the returned query with aggregations for correlations and k-test', () => { + const query = getTransactionDurationCorrelationRequest( + params, + expectations, + ranges, + fractions, + totalDocCount + ); + + expect(query.index).toBe(params.index); + + expect(query?.body?.aggs?.latency_ranges?.range?.field).toBe( + 'transaction.duration.us' + ); + expect(query?.body?.aggs?.latency_ranges?.range?.ranges).toEqual(ranges); + + expect( + (query?.body?.aggs?.transaction_duration_correlation as { + bucket_correlation: BucketCorrelation; + })?.bucket_correlation.function.count_correlation.indicator + ).toEqual({ + fractions, + expectations, + doc_count: totalDocCount, + }); + + expect( + (query?.body?.aggs?.ks_test as any)?.bucket_count_ks_test?.fractions + ).toEqual(fractions); + }); + }); + + describe('fetchTransactionDurationCorrelation', () => { + it('returns the data from the aggregations', async () => { + const latencyRangesBuckets = [{ to: 1 }, { from: 1, to: 2 }, { from: 2 }]; + const transactionDurationCorrelationValue = 0.45; + const KsTestLess = 0.01; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + latency_ranges: { + buckets: latencyRangesBuckets, + }, + transaction_duration_correlation: { + value: transactionDurationCorrelationValue, + }, + ks_test: { less: KsTestLess }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationCorrelation( + esClientMock, + params, + expectations, + ranges, + fractions, + totalDocCount + ); + + expect(resp).toEqual({ + correlation: transactionDurationCorrelationValue, + ksTest: KsTestLess, + ranges: latencyRangesBuckets, + }); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts index 9894ac54eccb6..f63c36f90d728 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts @@ -26,7 +26,7 @@ interface ResponseHit { _source: ResponseHitSource; } -interface BucketCorrelation { +export interface BucketCorrelation { buckets_path: string; function: { count_correlation: { @@ -80,8 +80,7 @@ export const getTransactionDurationCorrelationRequest = ( // KS test p value = ks_test.less ks_test: { bucket_count_ks_test: { - // Remove 0 after https://github.com/elastic/elasticsearch/pull/74624 is merged - fractions: [0, ...fractions], + fractions, buckets_path: 'latency_ranges>_count', alternative: ['less', 'greater', 'two_sided'], }, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts new file mode 100644 index 0000000000000..89bdd4280d324 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationFieldCandidates, + getRandomDocsRequest, + hasPrefixToInclude, + shouldBeExcluded, +} from './query_field_candidates'; + +const params = { index: 'apm-*' }; + +describe('query_field_candidates', () => { + describe('shouldBeExcluded', () => { + it('does not exclude a completely custom field name', () => { + expect(shouldBeExcluded('myFieldName')).toBe(false); + }); + + it(`excludes a field if it's one of FIELDS_TO_EXCLUDE_AS_CANDIDATE`, () => { + expect(shouldBeExcluded('transaction.type')).toBe(true); + }); + + it(`excludes a field if it's prefixed with one of FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE`, () => { + expect(shouldBeExcluded('observer.myFieldName')).toBe(true); + }); + }); + + describe('hasPrefixToInclude', () => { + it('identifies if a field name is prefixed to be included', () => { + expect(hasPrefixToInclude('myFieldName')).toBe(false); + expect(hasPrefixToInclude('somePrefix.myFieldName')).toBe(false); + expect(hasPrefixToInclude('cloud.myFieldName')).toBe(true); + expect(hasPrefixToInclude('labels.myFieldName')).toBe(true); + expect(hasPrefixToInclude('user_agent.myFieldName')).toBe(true); + }); + }); + + describe('getRandomDocsRequest', () => { + it('returns the most basic request body for a sample of random documents', () => { + const req = getRandomDocsRequest(params); + + expect(req).toEqual({ + body: { + _source: false, + fields: ['*'], + query: { + function_score: { + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + random_score: {}, + }, + }, + size: 1000, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationFieldCandidates', () => { + it('returns field candidates and total hits', async () => { + const esClientFieldCapsMock = jest.fn(() => ({ + body: { + fields: { + myIpFieldName: { ip: {} }, + myKeywordFieldName: { keyword: {} }, + myUnpopulatedKeywordFieldName: { keyword: {} }, + myNumericFieldName: { number: {} }, + }, + }, + })); + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + hits: { + hits: [ + { + fields: { + myIpFieldName: '1.1.1.1', + myKeywordFieldName: 'myKeywordFieldValue', + myNumericFieldName: 1234, + }, + }, + ], + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + fieldCaps: esClientFieldCapsMock, + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFieldCandidates( + esClientMock, + params + ); + + expect(resp).toEqual({ + fieldCandidates: [ + // default field candidates + 'service.version', + 'service.node.name', + 'service.framework.version', + 'service.language.version', + 'service.runtime.version', + 'kubernetes.pod.name', + 'kubernetes.pod.uid', + 'container.id', + 'source.ip', + 'client.ip', + 'host.ip', + 'service.environment', + 'process.args', + 'http.response.status_code', + // field candidates identified by sample documents + 'myIpFieldName', + 'myKeywordFieldName', + ], + }); + expect(esClientFieldCapsMock).toHaveBeenCalledTimes(1); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts index 4f1840971da7d..0fbdfef405e0d 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts @@ -21,7 +21,7 @@ import { POPULATED_DOC_COUNT_SAMPLE_SIZE, } from './constants'; -const shouldBeExcluded = (fieldName: string) => { +export const shouldBeExcluded = (fieldName: string) => { return ( FIELDS_TO_EXCLUDE_AS_CANDIDATE.has(fieldName) || FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE.some((prefix) => @@ -30,7 +30,7 @@ const shouldBeExcluded = (fieldName: string) => { ); }; -const hasPrefixToInclude = (fieldName: string) => { +export const hasPrefixToInclude = (fieldName: string) => { return FIELD_PREFIX_TO_ADD_AS_CANDIDATE.some((prefix) => fieldName.startsWith(prefix) ); @@ -50,8 +50,6 @@ export const getRandomDocsRequest = ( random_score: {}, }, }, - // Required value for later correlation queries - track_total_hits: true, size: POPULATED_DOC_COUNT_SAMPLE_SIZE, }, }); @@ -59,7 +57,7 @@ export const getRandomDocsRequest = ( export const fetchTransactionDurationFieldCandidates = async ( esClient: ElasticsearchClient, params: SearchServiceParams -): Promise<{ fieldCandidates: Field[]; totalHits: number }> => { +): Promise<{ fieldCandidates: Field[] }> => { const { index } = params; // Get all fields with keyword mapping const respMapping = await esClient.fieldCaps({ @@ -100,6 +98,5 @@ export const fetchTransactionDurationFieldCandidates = async ( return { fieldCandidates: [...finalFieldCandidates], - totalHits: (resp.body.hits.total as estypes.SearchTotalHits).value, }; }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts new file mode 100644 index 0000000000000..ea5a1f55bc924 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import type { AsyncSearchProviderProgress } from '../../../../common/search_strategies/correlations/types'; + +import { + fetchTransactionDurationFieldValuePairs, + getTermsAggRequest, +} from './query_field_value_pairs'; + +const params = { index: 'apm-*' }; + +describe('query_field_value_pairs', () => { + describe('getTermsAggRequest', () => { + it('returns the most basic request body for a terms aggregation', () => { + const fieldName = 'myFieldName'; + const req = getTermsAggRequest(params, fieldName); + expect(req?.body?.aggs?.attribute_terms?.terms?.field).toBe(fieldName); + }); + }); + + describe('fetchTransactionDurationFieldValuePairs', () => { + it('returns field/value pairs for field candidates', async () => { + const fieldCandidates = [ + 'myFieldCandidate1', + 'myFieldCandidate2', + 'myFieldCandidate3', + ]; + const progress = { + loadedFieldValuePairs: 0, + } as AsyncSearchProviderProgress; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + attribute_terms: { + buckets: [{ key: 'myValue1' }, { key: 'myValue2' }], + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFieldValuePairs( + esClientMock, + params, + fieldCandidates, + progress + ); + + expect(progress.loadedFieldValuePairs).toBe(1); + expect(resp).toEqual([ + { field: 'myFieldCandidate1', value: 'myValue1' }, + { field: 'myFieldCandidate1', value: 'myValue2' }, + { field: 'myFieldCandidate2', value: 'myValue1' }, + { field: 'myFieldCandidate2', value: 'myValue2' }, + { field: 'myFieldCandidate3', value: 'myValue1' }, + { field: 'myFieldCandidate3', value: 'myValue2' }, + ]); + expect(esClientSearchMock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts index 703a203c89207..8fde9d3ab1378 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts @@ -52,7 +52,7 @@ export const fetchTransactionDurationFieldValuePairs = async ( ): Promise => { const fieldValuePairs: FieldValuePairs = []; - let fieldValuePairsProgress = 0; + let fieldValuePairsProgress = 1; for (let i = 0; i < fieldCandidates.length; i++) { const fieldName = fieldCandidates[i]; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts new file mode 100644 index 0000000000000..6052841d277c3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationFractions, + getTransactionDurationRangesRequest, +} from './query_fractions'; + +const params = { index: 'apm-*' }; +const ranges = [{ to: 1 }, { from: 1, to: 3 }, { from: 3, to: 5 }, { from: 5 }]; + +describe('query_fractions', () => { + describe('getTransactionDurationRangesRequest', () => { + it('returns the request body for the transaction duration ranges aggregation', () => { + const req = getTransactionDurationRangesRequest(params, ranges); + + expect(req?.body?.aggs?.latency_ranges?.range?.field).toBe( + 'transaction.duration.us' + ); + expect(req?.body?.aggs?.latency_ranges?.range?.ranges).toEqual(ranges); + }); + }); + + describe('fetchTransactionDurationFractions', () => { + it('computes the actual percentile bucket counts and actual fractions', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + latency_ranges: { + buckets: [{ doc_count: 1 }, { doc_count: 2 }], + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFractions( + esClientMock, + params, + ranges + ); + + expect(resp).toEqual({ + fractions: [0.3333333333333333, 0.6666666666666666], + totalDocCount: 3, + }); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts new file mode 100644 index 0000000000000..2be9446352260 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogram, + getTransactionDurationHistogramRequest, +} from './query_histogram'; + +const params = { index: 'apm-*' }; +const interval = 100; + +describe('query_histogram', () => { + describe('getTransactionDurationHistogramRequest', () => { + it('returns the request body for the histogram request', () => { + const req = getTransactionDurationHistogramRequest(params, interval); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_histogram: { + histogram: { + field: 'transaction.duration.us', + interval, + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: 'apm-*', + }); + }); + }); + + describe('fetchTransactionDurationHistogram', () => { + it('returns the buckets from the histogram aggregation', async () => { + const histogramBucket = [ + { + key: 0.0, + doc_count: 1, + }, + ]; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_histogram: { + buckets: histogramBucket, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogram( + esClientMock, + params, + interval + ); + + expect(resp).toEqual(histogramBucket); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts new file mode 100644 index 0000000000000..9ed529ccabddb --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogramInterval, + getHistogramIntervalRequest, +} from './query_histogram_interval'; + +const params = { index: 'apm-*' }; + +describe('query_histogram_interval', () => { + describe('getHistogramIntervalRequest', () => { + it('returns the request body for the transaction duration ranges aggregation', () => { + const req = getHistogramIntervalRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_max: { + max: { + field: 'transaction.duration.us', + }, + }, + transaction_duration_min: { + min: { + field: 'transaction.duration.us', + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationHistogramInterval', () => { + it('fetches the interval duration for histograms', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_max: { + value: 10000, + }, + transaction_duration_min: { + value: 10, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogramInterval( + esClientMock, + params + ); + + expect(resp).toEqual(10); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts new file mode 100644 index 0000000000000..bb366ea29fed4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogramRangesteps, + getHistogramIntervalRequest, +} from './query_histogram_rangesteps'; + +const params = { index: 'apm-*' }; + +describe('query_histogram_rangesteps', () => { + describe('getHistogramIntervalRequest', () => { + it('returns the request body for the histogram interval request', () => { + const req = getHistogramIntervalRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_max: { + max: { + field: 'transaction.duration.us', + }, + }, + transaction_duration_min: { + min: { + field: 'transaction.duration.us', + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationHistogramRangesteps', () => { + it('fetches the range steps for the log histogram', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_max: { + value: 10000, + }, + transaction_duration_min: { + value: 10, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogramRangesteps( + esClientMock, + params + ); + + expect(resp.length).toEqual(100); + expect(resp[0]).toEqual(9.260965422132594); + expect(resp[99]).toEqual(18521.930844265193); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts new file mode 100644 index 0000000000000..0c319aee0fb2b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationPercentiles, + getTransactionDurationPercentilesRequest, +} from './query_percentiles'; + +const params = { index: 'apm-*' }; + +describe('query_percentiles', () => { + describe('getTransactionDurationPercentilesRequest', () => { + it('returns the request body for the duration percentiles request', () => { + const req = getTransactionDurationPercentilesRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_percentiles: { + percentiles: { + field: 'transaction.duration.us', + hdr: { + number_of_significant_value_digits: 3, + }, + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationPercentiles', () => { + it('fetches the percentiles', async () => { + const percentilesValues = { + '1.0': 5.0, + '5.0': 25.0, + '25.0': 165.0, + '50.0': 445.0, + '75.0': 725.0, + '95.0': 945.0, + '99.0': 985.0, + }; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_percentiles: { + values: percentilesValues, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationPercentiles( + esClientMock, + params + ); + + expect(resp).toEqual(percentilesValues); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts index 013c1ba3cbc23..18dcefb59a11a 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts @@ -55,7 +55,7 @@ export const getTransactionDurationPercentilesRequest = ( }; }; -export const fetchTransactionDurationPecentiles = async ( +export const fetchTransactionDurationPercentiles = async ( esClient: ElasticsearchClient, params: SearchServiceParams, percents?: number[], @@ -73,7 +73,7 @@ export const fetchTransactionDurationPecentiles = async ( if (resp.body.aggregations === undefined) { throw new Error( - 'fetchTransactionDurationPecentiles failed, did not return aggregations.' + 'fetchTransactionDurationPercentiles failed, did not return aggregations.' ); } return ( diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts new file mode 100644 index 0000000000000..9451928e47ded --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationRanges, + getTransactionDurationRangesRequest, +} from './query_ranges'; + +const params = { index: 'apm-*' }; +const rangeSteps = [1, 3, 5]; + +describe('query_ranges', () => { + describe('getTransactionDurationRangesRequest', () => { + it('returns the request body for the duration percentiles request', () => { + const req = getTransactionDurationRangesRequest(params, rangeSteps); + + expect(req).toEqual({ + body: { + aggs: { + logspace_ranges: { + range: { + field: 'transaction.duration.us', + ranges: [ + { + to: 0, + }, + { + from: 0, + to: 1, + }, + { + from: 1, + to: 3, + }, + { + from: 3, + to: 5, + }, + { + from: 5, + }, + ], + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationRanges', () => { + it('fetches the percentiles', async () => { + const logspaceRangesBuckets = [ + { + key: '*-100.0', + to: 100.0, + doc_count: 2, + }, + { + key: '100.0-200.0', + from: 100.0, + to: 200.0, + doc_count: 2, + }, + { + key: '200.0-*', + from: 200.0, + doc_count: 3, + }, + ]; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + logspace_ranges: { + buckets: logspaceRangesBuckets, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationRanges( + esClientMock, + params, + rangeSteps + ); + + expect(resp).toEqual([ + { doc_count: 2, key: 100 }, + { doc_count: 3, key: 200 }, + ]); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts index 88256f79150fc..9074e7e0809bf 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts @@ -42,7 +42,9 @@ export const getTransactionDurationRangesRequest = ( }, [{ to: 0 }] as Array<{ from?: number; to?: number }> ); - ranges.push({ from: ranges[ranges.length - 1].to }); + if (ranges.length > 0) { + ranges.push({ from: ranges[ranges.length - 1].to }); + } return { index: params.index, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts new file mode 100644 index 0000000000000..6d4bfcdde9994 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import { SearchStrategyDependencies } from 'src/plugins/data/server'; + +import { + apmCorrelationsSearchStrategyProvider, + PartialSearchRequest, +} from './search_strategy'; + +// helper to trigger promises in the async search service +const flushPromises = () => new Promise(setImmediate); + +const clientFieldCapsMock = () => ({ body: { fields: [] } }); + +// minimal client mock to fulfill search requirements of the async search service to succeed +const clientSearchMock = ( + req: estypes.SearchRequest +): { body: estypes.SearchResponse } => { + let aggregations: + | { + transaction_duration_percentiles: estypes.AggregationsTDigestPercentilesAggregate; + } + | { + transaction_duration_min: estypes.AggregationsValueAggregate; + transaction_duration_max: estypes.AggregationsValueAggregate; + } + | { + logspace_ranges: estypes.AggregationsMultiBucketAggregate<{ + from: number; + doc_count: number; + }>; + } + | { + latency_ranges: estypes.AggregationsMultiBucketAggregate<{ + doc_count: number; + }>; + } + | undefined; + + if (req?.body?.aggs !== undefined) { + const aggs = req.body.aggs; + // fetchTransactionDurationPercentiles + if (aggs.transaction_duration_percentiles !== undefined) { + aggregations = { transaction_duration_percentiles: { values: {} } }; + } + + // fetchTransactionDurationHistogramInterval + if ( + aggs.transaction_duration_min !== undefined && + aggs.transaction_duration_max !== undefined + ) { + aggregations = { + transaction_duration_min: { value: 0 }, + transaction_duration_max: { value: 1234 }, + }; + } + + // fetchTransactionDurationCorrelation + if (aggs.logspace_ranges !== undefined) { + aggregations = { logspace_ranges: { buckets: [] } }; + } + + // fetchTransactionDurationFractions + if (aggs.latency_ranges !== undefined) { + aggregations = { latency_ranges: { buckets: [] } }; + } + } + + return { + body: { + _shards: { + failed: 0, + successful: 1, + total: 1, + }, + took: 162, + timed_out: false, + hits: { + hits: [], + total: { + value: 0, + relation: 'eq', + }, + }, + ...(aggregations !== undefined ? { aggregations } : {}), + }, + }; +}; + +describe('APM Correlations search strategy', () => { + describe('strategy interface', () => { + it('returns a custom search strategy with a `search` and `cancel` function', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + expect(typeof searchStrategy.search).toBe('function'); + expect(typeof searchStrategy.cancel).toBe('function'); + }); + }); + + describe('search', () => { + let mockClientFieldCaps: jest.Mock; + let mockClientSearch: jest.Mock; + let mockDeps: SearchStrategyDependencies; + let params: Required['params']; + + beforeEach(() => { + mockClientFieldCaps = jest.fn(clientFieldCapsMock); + mockClientSearch = jest.fn(clientSearchMock); + mockDeps = ({ + esClient: { + asCurrentUser: { + fieldCaps: mockClientFieldCaps, + search: mockClientSearch, + }, + }, + } as unknown) as SearchStrategyDependencies; + params = { + index: 'apm-*', + }; + }); + + describe('async functionality', () => { + describe('when no params are provided', () => { + it('throws an error', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + expect(() => searchStrategy.search({}, {}, mockDeps)).toThrow( + 'Invalid request parameters.' + ); + }); + }); + + describe('when no ID is provided', () => { + it('performs a client search with params', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + await searchStrategy.search({ params }, {}, mockDeps).toPromise(); + const [[request]] = mockClientSearch.mock.calls; + + expect(request.index).toEqual('apm-*'); + expect(request.body).toEqual( + expect.objectContaining({ + aggs: { + transaction_duration_percentiles: { + percentiles: { + field: 'transaction.duration.us', + hdr: { number_of_significant_value_digits: 3 }, + }, + }, + }, + query: { + bool: { + filter: [{ term: { 'processor.event': 'transaction' } }], + }, + }, + size: 0, + }) + ); + }); + }); + + describe('when an ID with params is provided', () => { + it('retrieves the current request', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + const response = await searchStrategy + .search({ id: 'my-search-id', params }, {}, mockDeps) + .toPromise(); + + expect(response).toEqual( + expect.objectContaining({ id: 'my-search-id' }) + ); + }); + }); + + describe('if the client throws', () => { + it('does not emit an error', async () => { + mockClientSearch + .mockReset() + .mockRejectedValueOnce(new Error('client error')); + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + const response = await searchStrategy + .search({ params }, {}, mockDeps) + .toPromise(); + + expect(response).toEqual( + expect.objectContaining({ isRunning: true }) + ); + }); + }); + + it('triggers the subscription only once', async () => { + expect.assertions(1); + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + searchStrategy + .search({ params }, {}, mockDeps) + .subscribe((response) => { + expect(response).toEqual( + expect.objectContaining({ loaded: 0, isRunning: true }) + ); + }); + }); + }); + + describe('response', () => { + it('sends an updated response on consecutive search calls', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + + const response1 = await searchStrategy + .search({ params }, {}, mockDeps) + .toPromise(); + + expect(typeof response1.id).toEqual('string'); + expect(response1).toEqual( + expect.objectContaining({ loaded: 0, isRunning: true }) + ); + + await flushPromises(); + + const response2 = await searchStrategy + .search({ id: response1.id, params }, {}, mockDeps) + .toPromise(); + + expect(response2.id).toEqual(response1.id); + expect(response2).toEqual( + expect.objectContaining({ loaded: 10, isRunning: false }) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts new file mode 100644 index 0000000000000..63de0a59d4894 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { computeExpectationsAndRanges } from './aggregation_utils'; + +describe('aggregation utils', () => { + describe('computeExpectationsAndRanges', () => { + it('returns expectations and ranges based on given percentiles #1', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([0, 1]); + expect(expectations).toEqual([0, 0.5, 1]); + expect(ranges).toEqual([{ to: 0 }, { from: 0, to: 1 }, { from: 1 }]); + }); + it('returns expectations and ranges based on given percentiles #2', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([1, 3, 5]); + expect(expectations).toEqual([1, 2, 4, 5]); + expect(ranges).toEqual([ + { to: 1 }, + { from: 1, to: 3 }, + { from: 3, to: 5 }, + { from: 5 }, + ]); + }); + it('returns expectations and ranges with adjusted fractions', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([ + 1, + 3, + 3, + 5, + ]); + expect(expectations).toEqual([ + 1, + 2.333333333333333, + 3.666666666666667, + 5, + ]); + expect(ranges).toEqual([ + { to: 1 }, + { from: 1, to: 3 }, + { from: 3, to: 3 }, + { from: 3, to: 5 }, + { from: 5 }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts index 34e5ae2795d58..8d83b8fc29b05 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts @@ -31,14 +31,16 @@ export const computeExpectationsAndRanges = ( const ranges = percentiles.reduce((p, to) => { const from = p[p.length - 1]?.to; - if (from) { + if (from !== undefined) { p.push({ from, to }); } else { p.push({ to }); } return p; }, [] as Array<{ from?: number; to?: number }>); - ranges.push({ from: ranges[ranges.length - 1].to }); + if (ranges.length > 0) { + ranges.push({ from: ranges[ranges.length - 1].to }); + } const expectations = [tempPercentiles[0]]; for (let i = 1; i < tempPercentiles.length; i++) { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts new file mode 100644 index 0000000000000..ed4107b9d602a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRandomInt } from './math_utils'; + +describe('math utils', () => { + describe('getRandomInt', () => { + it('returns a random integer within the given range', () => { + const min = 0.9; + const max = 11.1; + const randomInt = getRandomInt(min, max); + expect(Number.isInteger(randomInt)).toBe(true); + expect(randomInt > min).toBe(true); + expect(randomInt < max).toBe(true); + }); + + it('returns 1 if given range only allows this integer', () => { + const randomInt = getRandomInt(0.9, 1.1); + expect(randomInt).toBe(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 4384d2be78ca0..3329119726bb5 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -51,9 +51,10 @@ const servicesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params, logger } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServices({ environment, @@ -405,9 +406,10 @@ const serviceThroughputRoute = createApmServerRoute({ comparisonStart, comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -477,9 +479,10 @@ const serviceInstancesMainStatisticsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -552,9 +555,10 @@ const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({ latencyAggregationType, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServiceInstancesDetailedStatisticsPeriods({ environment, @@ -593,9 +597,10 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({ const { serviceName, serviceNodeName } = resources.params.path; const { transactionType, environment, kuery } = resources.params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getServiceInstanceMetadataDetails({ searchAggregatedTransactions, diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 7fce04644f220..bed7252dd20fd 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -26,9 +26,10 @@ const tracesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { environment, kuery, type: 'top_traces', searchAggregatedTransactions }, diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index bcc554e552fc3..c20de31847e8a 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -56,9 +56,10 @@ const transactionGroupsRoute = createApmServerRoute({ const { serviceName } = params.path; const { environment, kuery, transactionType } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { @@ -95,16 +96,16 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ handler: async (resources) => { const { params } = resources; const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return getServiceTransactionGroups({ environment, kuery, @@ -140,11 +141,6 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, handler: async (resources) => { const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { params } = resources; const { @@ -161,6 +157,11 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return await getServiceTransactionGroupDetailedStatisticsPeriods({ environment, kuery, @@ -208,9 +209,10 @@ const transactionLatencyChartsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const options = { environment, @@ -276,9 +278,10 @@ const transactionThroughputChartsRoute = createApmServerRoute({ transactionName, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getThroughputCharts({ environment, @@ -327,9 +330,10 @@ const transactionChartsDistributionRoute = createApmServerRoute({ traceId = '', } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionDistribution({ environment, @@ -411,9 +415,10 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getErrorRatePeriods({ environment, diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index a55762dce2d20..8b6697e78ca37 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -17,30 +17,6 @@ export const ErrorStrings = { }, }), }, - downloadWorkpad: { - getDownloadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { - defaultMessage: "Couldn't download workpad", - }), - getDownloadRenderedWorkpadFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', - { - defaultMessage: "Couldn't download rendered workpad", - } - ), - getDownloadRuntimeFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { - defaultMessage: "Couldn't download Shareable Runtime", - }), - getDownloadZippedRuntimeFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', - { - defaultMessage: "Couldn't download ZIP file", - } - ), - }, esPersist: { getSaveFailureTitle: () => i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts index c4267a9857490..dde9a06e4851d 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/index.ts +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -8,7 +8,6 @@ export { useCloneWorkpad } from './use_clone_workpad'; export { useCreateWorkpad } from './use_create_workpad'; export { useDeleteWorkpads } from './use_delete_workpad'; -export { useDownloadWorkpad } from './use_download_workpad'; export { useFindTemplates } from './use_find_templates'; export { useFindWorkpads } from './use_find_workpad'; export { useImportWorkpad } from './use_upload_workpad'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx index e5d83039a87eb..6d88691f2eabe 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -11,7 +11,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; import { usePlatformService } from '../../../services'; -import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; +import { useCloneWorkpad } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTable as Component } from './workpad_table.component'; import { WorkpadsContext } from './my_workpads'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx index 62d84adfc2649..02b4ee61ea0ca 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -10,7 +10,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; -import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; +import { useDeleteWorkpads } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTableTools as Component, diff --git a/x-pack/plugins/canvas/public/components/hooks/index.tsx b/x-pack/plugins/canvas/public/components/hooks/index.tsx new file mode 100644 index 0000000000000..e420ab4cd698c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx new file mode 100644 index 0000000000000..50d527036560a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDownloadWorkpad, useDownloadRenderedWorkpad } from './use_download_workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts new file mode 100644 index 0000000000000..b688bb5a3b1a5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { useNotifyService, useWorkpadService } from '../../../services'; +import { CanvasWorkpad } from '../../../../types'; +import { CanvasRenderedWorkpad } from '../../../../shareable_runtime/types'; + +const strings = { + getDownloadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { + defaultMessage: "Couldn't download workpad", + }), + getDownloadRenderedWorkpadFailureErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', + { + defaultMessage: "Couldn't download rendered workpad", + } + ), +}; + +export const useDownloadWorkpad = () => { + const notifyService = useNotifyService(); + const workpadService = useWorkpadService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpadId: string) => { + try { + const workpad = await workpadService.get(workpadId); + + download(workpad, `canvas-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); + } + }, + [workpadService, notifyService, download] + ); +}; + +export const useDownloadRenderedWorkpad = () => { + const notifyService = useNotifyService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpad: CanvasRenderedWorkpad) => { + try { + download(workpad, `canvas-embed-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadRenderedWorkpadFailureErrorMessage(), + }); + } + }, + [notifyService, download] + ); +}; + +const useDownloadWorkpadBlob = () => { + return useCallback((workpad: CanvasWorkpad | CanvasRenderedWorkpad, filename: string) => { + const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); + fileSaver.saveAs(jsonBlob, `${filename}.json`); + }, []); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx index be337a6dcf00c..52e80c316c1ef 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { EuiText, EuiSpacer, @@ -24,35 +24,21 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { arrayBufferFetch } from '../../../../../common/lib/fetch'; -import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants'; import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; -import { - downloadRenderedWorkpad, - downloadRuntime, - downloadZippedRuntime, -} from '../../../../lib/download_workpad'; +import { useDownloadRenderedWorkpad } from '../../../hooks'; +import { useDownloadRuntime, useDownloadZippedRuntime } from './hooks'; import { ZIP, CANVAS, HTML } from '../../../../../i18n/constants'; import { OnCloseFn } from '../share_menu.component'; import { WorkpadStep } from './workpad_step'; import { RuntimeStep } from './runtime_step'; import { SnippetsStep } from './snippets_step'; -import { useNotifyService, usePlatformService } from '../../../../services'; +import { useNotifyService } from '../../../../services'; const strings = { getCopyShareConfigMessage: () => i18n.translate('xpack.canvas.workpadHeaderShareMenu.copyShareConfigMessage', { defaultMessage: 'Copied share markup to clipboard', }), - getShareableZipErrorTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { - defaultMessage: - "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", - values: { - ZIP, - workpadName, - }, - }), getUnknownExportErrorMessage: (type: string) => i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { defaultMessage: 'Unknown export type: {type}', @@ -121,33 +107,33 @@ export const ShareWebsiteFlyout: FC = ({ renderedWorkpad, }) => { const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const onCopy = () => { - notifyService.info(strings.getCopyShareConfigMessage()); - }; - const onDownload = (type: 'share' | 'shareRuntime' | 'shareZip') => { - switch (type) { - case 'share': - downloadRenderedWorkpad(renderedWorkpad); - return; - case 'shareRuntime': - downloadRuntime(platformService.getBasePath()); - case 'shareZip': - const basePath = platformService.getBasePath(); - arrayBufferFetch - .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) - .then((blob) => downloadZippedRuntime(blob.data)) - .catch((err: Error) => { - notifyService.error(err, { - title: strings.getShareableZipErrorTitle(renderedWorkpad.name), - }); - }); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }; + const onCopy = useCallback(() => notifyService.info(strings.getCopyShareConfigMessage()), [ + notifyService, + ]); + + const downloadRenderedWorkpad = useDownloadRenderedWorkpad(); + const downloadRuntime = useDownloadRuntime(); + const downloadZippedRuntime = useDownloadZippedRuntime(); + + const onDownload = useCallback( + (type: 'share' | 'shareRuntime' | 'shareZip') => { + switch (type) { + case 'share': + downloadRenderedWorkpad(renderedWorkpad); + return; + case 'shareRuntime': + downloadRuntime(); + return; + case 'shareZip': + downloadZippedRuntime(renderedWorkpad); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadRenderedWorkpad, downloadRuntime, downloadZippedRuntime, renderedWorkpad] + ); const link = ( { @@ -35,12 +34,6 @@ const getUnsupportedRenderers = (state: State) => { return renderers; }; -const mapStateToProps = (state: State) => ({ - renderedWorkpad: getRenderedWorkpad(state), - unsupportedRenderers: getUnsupportedRenderers(state), - workpad: getWorkpad(state), -}); - interface Props { onClose: OnCloseFn; renderedWorkpad: CanvasRenderedWorkpad; @@ -48,14 +41,18 @@ interface Props { workpad: CanvasWorkpad; } -export const ShareWebsiteFlyout = compose>( - connect(mapStateToProps), - withKibana, - withProps( - ({ unsupportedRenderers, renderedWorkpad, onClose, workpad }: Props): ComponentProps => ({ - renderedWorkpad, - unsupportedRenderers, - onClose, - }) - ) -)(Component); +export const ShareWebsiteFlyout: FC> = ({ onClose }) => { + const { renderedWorkpad, unsupportedRenderers } = useSelector((state: State) => ({ + renderedWorkpad: getRenderedWorkpad(state), + unsupportedRenderers: getUnsupportedRenderers(state), + workpad: getWorkpad(state), + })); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts new file mode 100644 index 0000000000000..a4243c9fff7e1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './use_download_runtime'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts new file mode 100644 index 0000000000000..dc2e4ff685ca5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../../../../../common/lib/constants'; +import { ZIP } from '../../../../../../i18n/constants'; + +import { usePlatformService, useNotifyService, useWorkpadService } from '../../../../../services'; +import { CanvasRenderedWorkpad } from '../../../../../../shareable_runtime/types'; + +const strings = { + getDownloadRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download Shareable Runtime", + }), + getDownloadZippedRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download ZIP file", + }), + getShareableZipErrorTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { + defaultMessage: + "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", + values: { + ZIP, + workpadName, + }, + }), +}; + +export const useDownloadRuntime = () => { + const platformService = usePlatformService(); + const notifyService = useNotifyService(); + + const downloadRuntime = useCallback(() => { + try { + const path = `${platformService.getBasePath()}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; + window.open(path); + return; + } catch (err) { + notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); + } + }, [platformService, notifyService]); + + return downloadRuntime; +}; + +export const useDownloadZippedRuntime = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + const downloadZippedRuntime = useCallback( + (workpad: CanvasRenderedWorkpad) => { + const downloadZip = async () => { + try { + let runtimeZipBlob: Blob | undefined; + try { + runtimeZipBlob = await workpadService.getRuntimeZip(workpad); + } catch (err) { + notifyService.error(err, { + title: strings.getShareableZipErrorTitle(workpad.name), + }); + } + + if (runtimeZipBlob) { + fileSaver.saveAs(runtimeZipBlob, 'canvas-workpad-embed.zip'); + } + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadZippedRuntimeFailureErrorMessage(), + }); + } + }; + + downloadZip(); + }, + [notifyService, workpadService] + ); + return downloadZippedRuntime; +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts deleted file mode 100644 index f514f813599b6..0000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { i18n } from '@kbn/i18n'; - -import { CanvasWorkpad, State } from '../../../../types'; -import { downloadWorkpad } from '../../../lib/download_workpad'; -import { withServices, WithServicesProps } from '../../../services'; -import { getPages, getWorkpad } from '../../../state/selectors/workpad'; -import { Props as ComponentProps, ShareMenu as Component } from './share_menu.component'; - -const strings = { - getUnknownExportErrorMessage: (type: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { - defaultMessage: 'Unknown export type: {type}', - values: { - type, - }, - }), -}; - -const mapStateToProps = (state: State) => ({ - workpad: getWorkpad(state), - pageCount: getPages(state).length, -}); - -interface Props { - workpad: CanvasWorkpad; - pageCount: number; -} - -export const ShareMenu = compose( - connect(mapStateToProps), - withServices, - withProps( - ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => { - const { - reporting: { start: reporting }, - } = services; - - return { - sharingServices: { reporting }, - sharingData: { workpad, pageCount }, - onExport: (type) => { - switch (type) { - case 'pdf': - // notifications are automatically handled by the Reporting plugin - break; - case 'json': - downloadWorkpad(workpad.id); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }; - } - ) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx new file mode 100644 index 0000000000000..0083ff1659c58 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { State } from '../../../../types'; +import { useReportingService } from '../../../services'; +import { getPages, getWorkpad } from '../../../state/selectors/workpad'; +import { useDownloadWorkpad } from '../../hooks'; +import { ShareMenu as ShareMenuComponent } from './share_menu.component'; + +const strings = { + getUnknownExportErrorMessage: (type: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { + defaultMessage: 'Unknown export type: {type}', + values: { + type, + }, + }), +}; + +export const ShareMenu: FC = () => { + const { workpad, pageCount } = useSelector((state: State) => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, + })); + + const reportingService = useReportingService(); + const downloadWorkpad = useDownloadWorkpad(); + + const sharingServices = { + reporting: reportingService.start, + }; + + const sharingData = { + workpad, + pageCount, + }; + + const onExport = useCallback( + (type: string) => { + switch (type) { + case 'pdf': + // notifications are automatically handled by the Reporting plugin + break; + case 'json': + downloadWorkpad(workpad.id); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadWorkpad, workpad] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts deleted file mode 100644 index a346de3322d09..0000000000000 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import fileSaver from 'file-saver'; -import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; -import { ErrorStrings } from '../../i18n'; - -// TODO: clint - convert this whole file to hooks -import { pluginServices } from '../services'; - -// @ts-expect-error untyped local -import * as workpadService from './workpad_service'; -import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; - -const { downloadWorkpad: strings } = ErrorStrings; - -export const downloadWorkpad = async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); - fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); - } -}; - -export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWorkpad) => { - try { - const jsonBlob = new Blob([JSON.stringify(renderedWorkpad)], { type: 'application/json' }); - fileSaver.saveAs( - jsonBlob, - `canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json` - ); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); - } -}; - -export const downloadRuntime = async (basePath: string) => { - try { - const path = `${basePath}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; - window.open(path); - return; - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); - } -}; - -export const downloadZippedRuntime = async (data: any) => { - try { - const zip = new Blob([data], { type: 'octet/stream' }); - fileSaver.saveAs(zip, 'canvas-workpad-embed.zip'); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); - } -}; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js deleted file mode 100644 index 20ad82860f1fa..0000000000000 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// TODO: clint - move to workpad service. -import { - API_ROUTE_WORKPAD, - API_ROUTE_WORKPAD_ASSETS, - API_ROUTE_WORKPAD_STRUCTURES, - DEFAULT_WORKPAD_CSS, -} from '../../common/lib/constants'; -import { fetch } from '../../common/lib/fetch'; -import { pluginServices } from '../services'; - -/* - Remove any top level keys from the workpad which will be rejected by validation -*/ -const validKeys = [ - '@created', - '@timestamp', - 'assets', - 'colors', - 'css', - 'variables', - 'height', - 'id', - 'isWriteable', - 'name', - 'page', - 'pages', - 'width', -]; - -const sanitizeWorkpad = function (workpad) { - const workpadKeys = Object.keys(workpad); - - for (const key of workpadKeys) { - if (!validKeys.includes(key)) { - delete workpad[key]; - } - } - - return workpad; -}; - -const getApiPath = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD}`; -}; - -const getApiPathStructures = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; -}; - -const getApiPathAssets = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; -}; - -export function create(workpad) { - return fetch.post(getApiPath(), { - ...sanitizeWorkpad({ ...workpad }), - assets: workpad.assets || {}, - variables: workpad.variables || [], - }); -} - -export async function createFromTemplate(templateId) { - return fetch.post(getApiPath(), { - templateId, - }); -} - -export function get(workpadId) { - return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { - // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; - }); -} - -// TODO: I think this function is never used. Look into and remove the corresponding route as well -export function update(id, workpad) { - return fetch.put(`${getApiPath()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateWorkpad(id, workpad) { - return fetch.put(`${getApiPathStructures()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateAssets(id, workpadAssets) { - return fetch.put(`${getApiPathAssets()}/${id}`, workpadAssets); -} - -export function remove(id) { - return fetch.delete(`${getApiPath()}/${id}`); -} - -export function find(searchTerm) { - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return fetch - .get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) - .then(({ data: workpads }) => workpads); -} diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx new file mode 100644 index 0000000000000..3ef93905f7e31 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { useWorkpadPersist } from './use_workpad_persist'; + +const mockGetState = jest.fn(); +const mockUpdateWorkpad = jest.fn(); +const mockUpdateAssets = jest.fn(); +const mockUpdate = jest.fn(); +const mockNotifyError = jest.fn(); + +// Mock the hooks and actions used by the UseWorkpad hook +jest.mock('react-redux', () => ({ + useSelector: (selector: any) => selector(mockGetState()), +})); + +jest.mock('../../../services', () => ({ + useWorkpadService: () => ({ + updateWorkpad: mockUpdateWorkpad, + updateAssets: mockUpdateAssets, + update: mockUpdate, + }), + useNotifyService: () => ({ + error: mockNotifyError, + }), +})); + +describe('useWorkpadPersist', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('initial render does not persist state', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + renderHook(useWorkpadPersist); + + expect(mockUpdateWorkpad).not.toBeCalled(); + expect(mockUpdateAssets).not.toBeCalled(); + expect(mockUpdate).not.toBeCalled(); + }); + + test('changes to workpad cause a workpad update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + persistent: { + workpad: { new: 'workpad' }, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateWorkpad).toHaveBeenCalled(); + }); + + test('changes to assets cause an asset update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateAssets).toHaveBeenCalled(); + }); + + test('changes to both assets and workpad causes a full update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad' }, + }, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).toHaveBeenCalled(); + }); + + test('non changes causes no updated', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); + + test('non write permissions causes no updates', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad value' }, + }, + assets: { + asset3: 'something', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts new file mode 100644 index 0000000000000..62c83e0411848 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useCallback } from 'react'; +import { isEqual } from 'lodash'; +import usePrevious from 'react-use/lib/usePrevious'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { CanvasWorkpad, State } from '../../../../types'; +import { getWorkpad, getFullWorkpadPersisted } from '../../../state/selectors/workpad'; +import { canUserWrite } from '../../../state/selectors/app'; +import { getAssetIds } from '../../../state/selectors/assets'; +import { useWorkpadService, useNotifyService } from '../../../services'; + +const strings = { + getSaveFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { + defaultMessage: "Couldn't save your changes to Elasticsearch", + }), + getTooLargeErrorMessage: () => + i18n.translate('xpack.canvas.error.esPersist.tooLargeErrorMessage', { + defaultMessage: + 'The server gave a response that the workpad data was too large. This usually means uploaded image assets that are too large for Kibana or a proxy. Try removing some assets in the asset manager.', + }), + getUpdateFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.updateFailureTitle', { + defaultMessage: "Couldn't update workpad", + }), +}; + +export const useWorkpadPersist = () => { + const service = useWorkpadService(); + const notifyService = useNotifyService(); + const notifyError = useCallback( + (err: any) => { + const statusCode = err.response && err.response.status; + switch (statusCode) { + case 400: + return notifyService.error(err.response, { + title: strings.getSaveFailureTitle(), + }); + case 413: + return notifyService.error(strings.getTooLargeErrorMessage(), { + title: strings.getSaveFailureTitle(), + }); + default: + return notifyService.error(err, { + title: strings.getUpdateFailureTitle(), + }); + } + }, + [notifyService] + ); + + // Watch for workpad state or workpad assets to change and then persist those changes + const [workpad, assetIds, fullWorkpad, canWrite]: [ + CanvasWorkpad, + Array, + CanvasWorkpad, + boolean + ] = useSelector((state: State) => [ + getWorkpad(state), + getAssetIds(state), + getFullWorkpadPersisted(state), + canUserWrite(state), + ]); + + const previousWorkpad = usePrevious(workpad); + const previousAssetIds = usePrevious(assetIds); + + const workpadChanged = previousWorkpad && workpad !== previousWorkpad; + const assetsChanged = previousAssetIds && !isEqual(assetIds, previousAssetIds); + + useEffect(() => { + if (canWrite) { + if (workpadChanged && assetsChanged) { + service.update(workpad.id, fullWorkpad).catch(notifyError); + } + if (workpadChanged) { + service.updateWorkpad(workpad.id, workpad).catch(notifyError); + } else if (assetsChanged) { + service.updateAssets(workpad.id, fullWorkpad.assets).catch(notifyError); + } + } + }, [service, workpad, fullWorkpad, workpadChanged, assetsChanged, canWrite, notifyError]); +}; diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 95caba08517ee..2c1ad4fcb6aa1 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -20,6 +20,7 @@ import { useWorkpad } from './hooks/use_workpad'; import { useRestoreHistory } from './hooks/use_restore_history'; import { useWorkpadHistory } from './hooks/use_workpad_history'; import { usePageSync } from './hooks/use_page_sync'; +import { useWorkpadPersist } from './hooks/use_workpad_persist'; import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.'; import { WorkpadRoutingContextComponent } from './workpad_routing_context'; import { WorkpadPresentationHelper } from './workpad_presentation_helper'; @@ -88,6 +89,7 @@ export const WorkpadHistoryManager: FC = ({ children }) => { useRestoreHistory(); useWorkpadHistory(); usePageSync(); + useWorkpadPersist(); return <>{children}; }; diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index 36ad1c568f9e6..8609d5055cb83 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -14,6 +14,9 @@ import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS, API_ROUTE_TEMPLATES, + API_ROUTE_WORKPAD_ASSETS, + API_ROUTE_WORKPAD_STRUCTURES, + API_ROUTE_SHAREABLE_ZIP, } from '../../../common/lib/constants'; import { CanvasWorkpad } from '../../../types'; @@ -93,5 +96,25 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, remove: (id: string) => { return coreStart.http.delete(`${getApiPath()}/${id}`); }, + update: (id, workpad) => { + return coreStart.http.put(`${getApiPath()}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateWorkpad: (id, workpad) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateAssets: (id, assets) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, { + body: JSON.stringify(assets), + }); + }, + getRuntimeZip: (workpad) => { + return coreStart.http.post(API_ROUTE_SHAREABLE_ZIP, { + body: JSON.stringify(workpad), + }); + }, }; }; diff --git a/x-pack/plugins/canvas/public/services/legacy/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx index 2f472afd7d3c1..fb30a9d418df8 100644 --- a/x-pack/plugins/canvas/public/services/legacy/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -26,13 +26,14 @@ const defaultContextValue = { search: {}, }; -const context = createContext(defaultContextValue as CanvasServices); +export const ServicesContext = createContext(defaultContextValue as CanvasServices); -export const useServices = () => useContext(context); +export const useServices = () => useContext(ServicesContext); export const useEmbeddablesService = () => useServices().embeddables; export const useExpressionsService = () => useServices().expressions; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; +export const useReportingService = () => useServices().reporting; export const withServices = (type: ComponentType) => { const EnhancedType: FC = (props) => @@ -53,5 +54,5 @@ export const LegacyServicesProvider: FC<{ reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), }; - return {children}; + return {children}; }; diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts index a494f634141bc..cdf4137e1d84c 100644 --- a/x-pack/plugins/canvas/public/services/storybook/workpad.ts +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -97,4 +97,18 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ action('workpadService.remove')(id); return Promise.resolve(); }, + update: (id, workpad) => { + action('worpadService.update')(workpad, id); + return Promise.resolve(); + }, + updateWorkpad: (id, workpad) => { + action('workpadService.updateWorkpad')(workpad, id); + return Promise.resolve(); + }, + updateAssets: (id, assets) => { + action('workpadService.updateAssets')(assets, id); + return Promise.resolve(); + }, + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index eef7508e7c1eb..2f2598563d49b 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -96,4 +96,9 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), find: findNoWorkpads(), remove: (_id: string) => Promise.resolve(), + update: (id, workpad) => Promise.resolve(), + updateWorkpad: (id, workpad) => Promise.resolve(), + updateAssets: (id, assets) => Promise.resolve(), + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 6b90cc346834b..c0e948669647c 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -6,6 +6,7 @@ */ import { CanvasWorkpad, CanvasTemplate } from '../../types'; +import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; export type FoundWorkpads = Array>; export type FoundWorkpad = FoundWorkpads[number]; @@ -24,4 +25,8 @@ export interface CanvasWorkpadService { find: (term: string) => Promise; remove: (id: string) => Promise; findTemplates: () => Promise; + update: (id: string, workpad: CanvasWorkpad) => Promise; + updateWorkpad: (id: string, workpad: CanvasWorkpad) => Promise; + updateAssets: (id: string, assets: CanvasWorkpad['assets']) => Promise; + getRuntimeZip: (workpad: CanvasRenderedWorkpad) => Promise; } diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js deleted file mode 100644 index 17d0c9649b912..0000000000000 --- a/x-pack/plugins/canvas/public/state/middleware/es_persist.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEqual } from 'lodash'; -import { ErrorStrings } from '../../../i18n'; -import { getWorkpad, getFullWorkpadPersisted, getWorkpadPersisted } from '../selectors/workpad'; -import { getAssetIds } from '../selectors/assets'; -import { appReady } from '../actions/app'; -import { setWorkpad, setRefreshInterval, resetWorkpad } from '../actions/workpad'; -import { setAssets, resetAssets } from '../actions/assets'; -import * as transientActions from '../actions/transient'; -import * as resolvedArgsActions from '../actions/resolved_args'; -import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service'; -import { pluginServices } from '../../services'; -import { canUserWrite } from '../selectors/app'; - -const { esPersist: strings } = ErrorStrings; - -const workpadChanged = (before, after) => { - const workpad = getWorkpad(before); - return getWorkpad(after) !== workpad; -}; - -const assetsChanged = (before, after) => { - const assets = getAssetIds(before); - return !isEqual(assets, getAssetIds(after)); -}; - -export const esPersistMiddleware = ({ getState }) => { - // these are the actions we don't want to trigger a persist call - const skippedActions = [ - appReady, // there's no need to resave the workpad once we've loaded it. - resetWorkpad, // used for resetting the workpad in state - setWorkpad, // used for loading and creating workpads - setAssets, // used when loading assets - resetAssets, // used when creating new workpads - setRefreshInterval, // used to set refresh time interval which is a transient value - ...Object.values(resolvedArgsActions), // no resolved args affect persisted values - ...Object.values(transientActions), // no transient actions cause persisted state changes - ].map((a) => a.toString()); - - return (next) => (action) => { - // if the action is in the skipped list, do not persist - if (skippedActions.indexOf(action.type) >= 0) { - return next(action); - } - - // capture state before and after the action - const curState = getState(); - next(action); - const newState = getState(); - - // skips the update request if user doesn't have write permissions - if (!canUserWrite(newState)) { - return; - } - - const notifyError = (err) => { - const statusCode = err.response && err.response.status; - const notifyService = pluginServices.getServices().notify; - - switch (statusCode) { - case 400: - return notifyService.error(err.response, { - title: strings.getSaveFailureTitle(), - }); - case 413: - return notifyService.error(strings.getTooLargeErrorMessage(), { - title: strings.getSaveFailureTitle(), - }); - default: - return notifyService.error(err, { - title: strings.getUpdateFailureTitle(), - }); - } - }; - - const changedWorkpad = workpadChanged(curState, newState); - const changedAssets = assetsChanged(curState, newState); - - if (changedWorkpad && changedAssets) { - // if both the workpad and the assets changed, save it in its entirety to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return update(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedWorkpad) { - // if the workpad changed, save it to elasticsearch - const persistedWorkpad = getWorkpadPersisted(getState()); - return updateWorkpad(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedAssets) { - // if the assets changed, save it to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return updateAssets(persistedWorkpad.id, persistedWorkpad.assets).catch(notifyError); - } - }; -}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index 713232543fab1..fbed2fbb3741b 100644 --- a/x-pack/plugins/canvas/public/state/middleware/index.js +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -8,21 +8,13 @@ import { applyMiddleware, compose as reduxCompose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { getWindow } from '../../lib/get_window'; -import { esPersistMiddleware } from './es_persist'; import { inFlight } from './in_flight'; import { workpadUpdate } from './workpad_update'; import { elementStats } from './element_stats'; import { resolvedArgs } from './resolved_args'; const middlewares = [ - applyMiddleware( - thunkMiddleware, - elementStats, - resolvedArgs, - esPersistMiddleware, - inFlight, - workpadUpdate - ), + applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate), ]; // compose with redux devtools, if extension is installed diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index e1cebeb65bd21..9cfccf3fc5598 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -7,6 +7,7 @@ import { get, omit } from 'lodash'; import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; +import { CanvasRenderedWorkpad } from '../../../shareable_runtime/types'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; import { @@ -500,7 +501,7 @@ export function getRenderedWorkpad(state: State) { return { pages: renderedPages, ...rest, - }; + } as CanvasRenderedWorkpad; } export function getRenderedWorkpadExpressions(state: State) { diff --git a/x-pack/plugins/canvas/shareable_runtime/types.ts b/x-pack/plugins/canvas/shareable_runtime/types.ts index ac8f140b7f11d..751fb3f795524 100644 --- a/x-pack/plugins/canvas/shareable_runtime/types.ts +++ b/x-pack/plugins/canvas/shareable_runtime/types.ts @@ -24,15 +24,14 @@ export interface CanvasRenderedElement { * Represents a Page within a Canvas Workpad that is made up of ready-to- * render Elements. */ -export interface CanvasRenderedPage extends Omit, 'groups'> { +export interface CanvasRenderedPage extends Omit { elements: CanvasRenderedElement[]; - groups: CanvasRenderedElement[][]; } /** * A Canvas Workpad made up of ready-to-render Elements. */ -export interface CanvasRenderedWorkpad extends Omit { +export interface CanvasRenderedWorkpad extends Omit { pages: CanvasRenderedPage[]; } diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 6e27093379e31..cc42839ddfac7 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -94,7 +94,7 @@ interface PersistentState { export interface State { app: StoreAppState; - assets: { [assetKey: string]: AssetType | undefined }; + assets: { [assetKey: string]: AssetType }; transient: TransientState; persistent: PersistentState; } diff --git a/x-pack/plugins/data_visualizer/public/api/index.ts b/x-pack/plugins/data_visualizer/public/api/index.ts index 746b43ac86e30..3b96e4caad340 100644 --- a/x-pack/plugins/data_visualizer/public/api/index.ts +++ b/x-pack/plugins/data_visualizer/public/api/index.ts @@ -8,11 +8,12 @@ import { lazyLoadModules } from '../lazy_load_bundle'; import type { FileDataVisualizerSpec, IndexDataVisualizerSpec } from '../application'; -export async function getFileDataVisualizerComponent(): Promise { +export async function getFileDataVisualizerComponent(): Promise<() => FileDataVisualizerSpec> { const modules = await lazyLoadModules(); - return modules.FileDataVisualizer; + return () => modules.FileDataVisualizer; } -export async function getIndexDataVisualizerComponent(): Promise { + +export async function getIndexDataVisualizerComponent(): Promise<() => IndexDataVisualizerSpec> { const modules = await lazyLoadModules(); - return modules.IndexDataVisualizer; + return () => modules.IndexDataVisualizer; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx index 6c9df5cf2eba7..e43199fabf76c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -73,7 +73,12 @@ export const FilebeatConfigFlyout: FC = ({ - + ; + canDisplay(params?: any): Promise; + dataTestSubj?: string; +} + interface Props { fieldStats: FindFileStructureResponse['field_stats']; index: string; @@ -25,6 +38,7 @@ interface Props { timeFieldName?: string; createIndexPattern: boolean; showFilebeatFlyout(): void; + additionalLinks: ResultLink[]; } interface GlobalState { @@ -41,6 +55,7 @@ export const ResultsLinks: FC = ({ timeFieldName, createIndexPattern, showFilebeatFlyout, + additionalLinks, }) => { const { services: { fileUpload }, @@ -55,6 +70,7 @@ export const ResultsLinks: FC = ({ const [discoverLink, setDiscoverLink] = useState(''); const [indexManagementLink, setIndexManagementLink] = useState(''); const [indexPatternManagementLink, setIndexPatternManagementLink] = useState(''); + const [generatedLinks, setGeneratedLinks] = useState>({}); const { services: { @@ -100,6 +116,23 @@ export const ResultsLinks: FC = ({ getDiscoverUrl(); + Promise.all( + additionalLinks.map(async ({ canDisplay, getUrl }) => { + if ((await canDisplay({ indexPatternId })) === false) { + return null; + } + return getUrl({ globalState, indexPatternId }); + }) + ).then((urls) => { + const linksById = urls.reduce((acc, url, i) => { + if (url !== null) { + acc[additionalLinks[i].id] = url; + } + return acc; + }, {} as Record); + setGeneratedLinks(linksById); + }); + if (!unmounted) { setIndexManagementLink( getUrlForApp('management', { path: '/data/index_management/indices' }) @@ -231,6 +264,19 @@ export const ResultsLinks: FC = ({ onClick={showFilebeatFlyout} /> + {additionalLinks + .filter(({ id }) => generatedLinks[id] !== undefined) + .map((link) => ( + + } + data-test-subj="fileDataVisLink" + title={link.title} + description={link.description} + href={generatedLinks[link.id]} + /> + + ))} ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js index 99b6ef602985f..054416ad7ba36 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js @@ -372,6 +372,7 @@ export class FileDataVisualizerView extends Component { hideBottomBar={this.hideBottomBar} savedObjectsClient={this.savedObjectsClient} fileUpload={this.props.fileUpload} + resultsLinks={this.props.resultsLinks} /> {bottomBarVisible && ( diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js index 232a32c75dc29..7e3c6d0c65d3e 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js @@ -591,6 +591,7 @@ export class ImportView extends Component { timeFieldName={timeFieldName} createIndexPattern={createIndexPattern} showFilebeatFlyout={this.showFilebeatFlyout} + additionalLinks={this.props.resultsLinks ?? []} /> {isFilebeatFlyoutVisible && ( diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx index b3f7e8531ebf5..3644f7053f1e8 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx @@ -11,9 +11,14 @@ import { getCoreStart, getPluginsStart } from '../../kibana_services'; // @ts-ignore import { FileDataVisualizerView } from './components/file_data_visualizer_view/index'; +import { ResultLink } from '../common/components/results_links'; + +interface Props { + additionalLinks?: ResultLink[]; +} export type FileDataVisualizerSpec = typeof FileDataVisualizer; -export const FileDataVisualizer: FC = () => { +export const FileDataVisualizer: FC = ({ additionalLinks }) => { const coreStart = getCoreStart(); const { data, maps, embeddable, share, security, fileUpload } = getPluginsStart(); const services = { @@ -33,6 +38,7 @@ export const FileDataVisualizer: FC = () => { savedObjectsClient={coreStart.savedObjects.client} http={coreStart.http} fileUpload={fileUpload} + resultsLinks={additionalLinks} /> ); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx index 4b208b0a59ef1..48410aff54577 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx @@ -18,18 +18,26 @@ import type { IndexPattern } from '../../../../../../../../src/plugins/data/comm import { useDataVisualizerKibana } from '../../../kibana_context'; import { useUrlState } from '../../../common/util/url_state'; import { LinkCard } from '../../../common/components/link_card'; +import { ResultLink } from '../../../common/components/results_links'; interface Props { indexPattern: IndexPattern; searchString?: string | { [key: string]: any }; searchQueryLanguage?: string; + additionalLinks: ResultLink[]; } -// @todo: Add back create job card in a follow up PR -export const ActionsPanel: FC = ({ indexPattern, searchString, searchQueryLanguage }) => { +export const ActionsPanel: FC = ({ + indexPattern, + searchString, + searchQueryLanguage, + additionalLinks, +}) => { const [globalState] = useUrlState('_g'); const [discoverLink, setDiscoverLink] = useState(''); + const [generatedLinks, setGeneratedLinks] = useState>({}); + const { services: { application: { capabilities }, @@ -76,17 +84,56 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer } }; + Promise.all( + additionalLinks.map(async ({ canDisplay, getUrl }) => { + if ((await canDisplay({ indexPatternId })) === false) { + return null; + } + return getUrl({ globalState, indexPatternId }); + }) + ).then((urls) => { + const linksById = urls.reduce((acc, url, i) => { + if (url !== null) { + acc[additionalLinks[i].id] = url; + } + return acc; + }, {} as Record); + setGeneratedLinks(linksById); + }); + getDiscoverUrl(); return () => { unmounted = true; }; - }, [indexPattern, searchString, searchQueryLanguage, globalState, capabilities, getUrlGenerator]); + }, [ + indexPattern, + searchString, + searchQueryLanguage, + globalState, + capabilities, + getUrlGenerator, + additionalLinks, + ]); // Note we use display:none for the DataRecognizer section as it needs to be // passed the recognizerResults object, and then run the recognizer check which // controls whether the recognizer section is ultimately displayed. return (

+ {additionalLinks + .filter(({ id }) => generatedLinks[id] !== undefined) + .map((link) => ( + <> + + + + ))} {discoverLink && ( <> diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index b116b25670ad2..c9ae3cf7f69a7 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -67,6 +67,7 @@ import { HelpMenu } from '../../../common/components/help_menu'; import { TimeBuckets } from '../../services/time_buckets'; import { extractSearchData } from '../../utils/saved_search_utils'; import { DataVisualizerIndexPatternManagement } from '../index_pattern_management'; +import { ResultLink } from '../../../common/components/results_links'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -120,6 +121,7 @@ export const getDefaultDataVisualizerListState = (): Required = (dataVi dataVisualizerProps.currentSavedSearch ); - const { currentIndexPattern } = dataVisualizerProps; + const { currentIndexPattern, additionalLinks } = dataVisualizerProps; useEffect(() => { if (dataVisualizerProps?.currentSavedSearch !== undefined) { @@ -886,6 +888,7 @@ export const IndexDataVisualizerView: FC = (dataVi indexPattern={currentIndexPattern} searchQueryLanguage={searchQueryLanguage} searchString={searchString} + additionalLinks={additionalLinks ?? []} /> diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index f9e9aece48a06..8e0230a9bc6f9 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -30,15 +30,18 @@ import { } from '../common/util/url_state'; import { useDataVisualizerKibana } from '../kibana_context'; import { IndexPattern } from '../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { ResultLink } from '../common/components/results_links'; export type IndexDataVisualizerSpec = typeof IndexDataVisualizer; export interface DataVisualizerUrlStateContextProviderProps { IndexDataVisualizerComponent: FC; + additionalLinks: ResultLink[]; } export const DataVisualizerUrlStateContextProvider: FC = ({ IndexDataVisualizerComponent, + additionalLinks, }) => { const { services: { @@ -168,6 +171,7 @@ export const DataVisualizerUrlStateContextProvider: FC ) : (
@@ -176,7 +180,7 @@ export const DataVisualizerUrlStateContextProvider: FC { +export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ additionalLinks }) => { const coreStart = getCoreStart(); const { data, @@ -204,6 +208,7 @@ export const IndexDataVisualizer: FC = () => { ); diff --git a/x-pack/plugins/data_visualizer/public/index.ts b/x-pack/plugins/data_visualizer/public/index.ts index b0a622dfe490b..1a045f144c015 100644 --- a/x-pack/plugins/data_visualizer/public/index.ts +++ b/x-pack/plugins/data_visualizer/public/index.ts @@ -13,4 +13,9 @@ export function plugin() { export { DataVisualizerPluginStart } from './plugin'; -export type { IndexDataVisualizerViewProps } from './application'; +export type { + FileDataVisualizerSpec, + IndexDataVisualizerSpec, + IndexDataVisualizerViewProps, +} from './application'; +export type { ResultLink } from './application/common/components/results_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx index 0cb3f77c681a1..78e4954a4bf16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx @@ -30,6 +30,11 @@ const activityFeed = [ target: 'http://localhost:3002/ws/org/sources', timestamp: '2020-06-24 16:34:16', }, + { + id: '(foo@example.com)', + message: 'joined the organization', + timestamp: '2021-07-02 16:38:27', + }, ]; describe('RecentActivity', () => { @@ -46,13 +51,14 @@ describe('RecentActivity', () => { it('renders an activityFeed with links', () => { setMockValues({ activityFeed }); const wrapper = shallow(); - const activity = wrapper.find(RecentActivityItem).dive(); + const sourceActivityItem = wrapper.find(RecentActivityItem).first().dive(); + const newUserActivityItem = wrapper.find(RecentActivityItem).last().dive(); - expect(activity).toHaveLength(1); - - const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + const link = sourceActivityItem.find('[data-test-subj="viewSourceDetailsLink"]'); link.simulate('click'); expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + + expect(newUserActivityItem.find('[data-test-subj="newUserTextWrapper"]')).toHaveLength(1); }); it('renders activity item error state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 8bda7c2843b9e..51a6508986037 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -29,7 +29,7 @@ export interface FeedActivity { id: string; message: string; timestamp: string; - sourceId: string; + sourceId?: string; } export const RecentActivity: React.FC = () => { @@ -98,23 +98,29 @@ export const RecentActivityItem: React.FC = ({ return (
- - {id} {message} - {status === 'error' && ( - - {' '} - - - )} - + {sourceId ? ( + + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + + ) : ( +
+ {id} {message} +
+ )}
{moment.utc(timestamp).fromNow()}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 8be6232733def..5ac594842d392 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -221,7 +221,7 @@ export const FleetAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 254885ea71b1e..c0c425447e556 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -42,7 +42,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -50,7 +50,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -59,7 +59,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { text: policyName }, @@ -69,7 +69,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { @@ -100,7 +100,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx index 7ad034b1cc059..dd15020adcc75 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx @@ -49,7 +49,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'agent_policies', @@ -60,7 +60,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'enrollment_tokens', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts index e9e7e09207992..b4e7982c52f7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts @@ -31,8 +31,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" `); }); @@ -78,9 +78,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` --fleet-server-policy=policy-1" `); }); @@ -137,14 +137,14 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \\\\ - -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ - --fleet-server-policy=policy-1 \\\\ - --certificate-authorities= \\\\ - --fleet-server-es-ca= \\\\ - --fleet-server-cert= \\\\ + ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` + -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --certificate-authorities= \` + --fleet-server-es-ca= \` + --fleet-server-cert= \` --fleet-server-cert-key=" `); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts index b91c4b60aa713..e129d7a4d5b4e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts @@ -16,22 +16,23 @@ export function getInstallCommandForPlatform( isProductionDeployment?: boolean ) { let commandArguments = ''; + const newLineSeparator = platform === 'windows' ? '`' : '\\'; if (isProductionDeployment && fleetServerHost) { - commandArguments += `--url=${fleetServerHost} \\\n`; + commandArguments += `--url=${fleetServerHost} ${newLineSeparator}\n`; } - commandArguments += ` -f \\\n --fleet-server-es=${esHost}`; - commandArguments += ` \\\n --fleet-server-service-token=${serviceToken}`; + commandArguments += ` -f ${newLineSeparator}\n --fleet-server-es=${esHost}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-service-token=${serviceToken}`; if (policyId) { - commandArguments += ` \\\n --fleet-server-policy=${policyId}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-policy=${policyId}`; } if (isProductionDeployment) { - commandArguments += ` \\\n --certificate-authorities=`; - commandArguments += ` \\\n --fleet-server-es-ca=`; - commandArguments += ` \\\n --fleet-server-cert=`; - commandArguments += ` \\\n --fleet-server-cert-key=`; + commandArguments += ` ${newLineSeparator}\n --certificate-authorities=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-es-ca=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert-key=`; } switch (platform) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index cad51a54d7074..ae59d33e44b82 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -196,7 +196,7 @@ export const IntegrationsAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index cff70737be6ee..8302983316316 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -773,10 +773,10 @@ class AgentPolicyService { ) { const names: string[] = []; if (fullAgentPolicy.agent.monitoring.logs) { - names.push(`logs-elastic_agent.*-${monitoringNamespace}`); + names.push(`logs-elastic_agent*-${monitoringNamespace}`); } if (fullAgentPolicy.agent.monitoring.metrics) { - names.push(`metrics-elastic_agent.*-${monitoringNamespace}`); + names.push(`metrics-elastic_agent*-${monitoringNamespace}`); } permissions._elastic_agent_checks.indices = [ diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 1fd12460ba3b6..12522402116c9 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -161,6 +161,7 @@ export async function mountApp( embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, type: LENS_EMBEDDABLE_TYPE, input, + searchSessionId: data.search.session.getSessionId(), }, }); } else { @@ -178,6 +179,10 @@ export async function mountApp( if (!initialContext) { data.query.filterManager.setAppFilters([]); } + + if (embeddableEditorIncomingState?.searchSessionId) { + data.search.session.continue(embeddableEditorIncomingState.searchSessionId); + } const { datasourceMap, visualizationMap } = instance; const initialDatasourceId = getInitialDatasourceId(datasourceMap); @@ -298,6 +303,7 @@ export async function mountApp( params.element ); return () => { + data.search.session.clear(); unmountComponentAtNode(params.element); unlistenParentHistory(); lensStore.dispatch(navigateAway()); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index 9806cdaad637e..445df21a6067e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -106,18 +106,20 @@ export const tinymathFunctions: Record< type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.addFunction.markdown', { + defaultMessage: ` Adds up two numbers. Also works with + symbol Example: Calculate the sum of two fields -${'`sum(price) + sum(tax)`'} +\`sum(price) + sum(tax)\` Example: Offset count by a static value -${'`add(count(), 5)`'} +\`add(count(), 5)\` `, + }), }, subtract: { positionalArguments: [ @@ -130,13 +132,15 @@ ${'`add(count(), 5)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.subtractFunction.markdown', { + defaultMessage: ` Subtracts the first number from the second number. -Also works with ${'`-`'} symbol +Also works with \`-\` symbol Example: Calculate the range of a field -${'`subtract(max(bytes), min(bytes))`'} +\`subtract(max(bytes), min(bytes))\` `, + }), }, multiply: { positionalArguments: [ @@ -149,16 +153,18 @@ ${'`subtract(max(bytes), min(bytes))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.multiplyFunction.markdown', { + defaultMessage: ` Multiplies two numbers. -Also works with ${'`*`'} symbol. +Also works with \`*\` symbol. Example: Calculate price after current tax rate -${'`sum(bytes) * last_value(tax_rate)`'} +\`sum(bytes) * last_value(tax_rate)\` Example: Calculate price after constant tax rate -${'`multiply(sum(price), 1.2)`'} +\`multiply(sum(price), 1.2)\` `, + }), }, divide: { positionalArguments: [ @@ -171,15 +177,17 @@ ${'`multiply(sum(price), 1.2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.divideFunction.markdown', { + defaultMessage: ` Divides the first number by the second number. -Also works with ${'`/`'} symbol +Also works with \`/\` symbol Example: Calculate profit margin -${'`sum(profit) / sum(revenue)`'} +\`sum(profit) / sum(revenue)\` -Example: ${'`divide(sum(bytes), 2)`'} +Example: \`divide(sum(bytes), 2)\` `, + }), }, abs: { positionalArguments: [ @@ -188,11 +196,13 @@ Example: ${'`divide(sum(bytes), 2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.absFunction.markdown', { + defaultMessage: ` Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same. -Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} +Example: Calculate average distance to sea level \`abs(average(altitude))\` `, + }), }, cbrt: { positionalArguments: [ @@ -201,12 +211,14 @@ Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cbrtFunction.markdown', { + defaultMessage: ` Cube root of value. Example: Calculate side length from volume -${'`cbrt(last_value(volume))`'} +\`cbrt(last_value(volume))\` `, + }), }, ceil: { positionalArguments: [ @@ -215,13 +227,14 @@ ${'`cbrt(last_value(volume))`'} type: getTypeI18n('number'), }, ], - // signature: 'ceil(value: number)', - help: ` + help: i18n.translate('xpack.lens.formula.ceilFunction.markdown', { + defaultMessage: ` Ceiling of value, rounds up. Example: Round up price to the next dollar -${'`ceil(sum(price))`'} +\`ceil(sum(price))\` `, + }), }, clamp: { positionalArguments: [ @@ -238,8 +251,8 @@ ${'`ceil(sum(price))`'} type: getTypeI18n('number'), }, ], - // signature: 'clamp(value: number, minimum: number, maximum: number)', - help: ` + help: i18n.translate('xpack.lens.formula.clampFunction.markdown', { + defaultMessage: ` Limits the value from a minimum to maximum. Example: Make sure to catch outliers @@ -251,6 +264,7 @@ clamp( ) \`\`\` `, + }), }, cube: { positionalArguments: [ @@ -259,12 +273,14 @@ clamp( type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cubeFunction.markdown', { + defaultMessage: ` Calculates the cube of a number. Example: Calculate volume from side length -${'`cube(last_value(length))`'} +\`cube(last_value(length))\` `, + }), }, exp: { positionalArguments: [ @@ -273,13 +289,15 @@ ${'`cube(last_value(length))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.expFunction.markdown', { + defaultMessage: ` Raises *e* to the nth power. Example: Calculate the natural exponential function -${'`exp(last_value(duration))`'} +\`exp(last_value(duration))\` `, + }), }, fix: { positionalArguments: [ @@ -288,12 +306,14 @@ ${'`exp(last_value(duration))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.fixFunction.markdown', { + defaultMessage: ` For positive values, takes the floor. For negative values, takes the ceiling. Example: Rounding towards zero -${'`fix(sum(profit))`'} +\`fix(sum(profit))\` `, + }), }, floor: { positionalArguments: [ @@ -302,12 +322,14 @@ ${'`fix(sum(profit))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.floorFunction.markdown', { + defaultMessage: ` Round down to nearest integer value Example: Round down a price -${'`floor(sum(price))`'} +\`floor(sum(price))\` `, + }), }, log: { positionalArguments: [ @@ -322,7 +344,8 @@ ${'`floor(sum(price))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.logFunction.markdown', { + defaultMessage: ` Logarithm with optional base. The natural base *e* is used as default. Example: Calculate number of bits required to store values @@ -331,17 +354,8 @@ log(sum(bytes)) log(sum(bytes), 2) \`\`\` `, + }), }, - // TODO: check if this is valid for Tinymath - // log10: { - // positionalArguments: [ - // { name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }), type: getTypeI18n('number') }, - // ], - // help: ` - // Base 10 logarithm. - // Example: ${'`log10(sum(bytes))`'} - // `, - // }, mod: { positionalArguments: [ { @@ -353,12 +367,14 @@ log(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.modFunction.markdown', { + defaultMessage: ` Remainder after dividing the function by a number Example: Calculate last three digits of a value -${'`mod(sum(price), 1000)`'} +\`mod(sum(price), 1000)\` `, + }), }, pow: { positionalArguments: [ @@ -371,12 +387,14 @@ ${'`mod(sum(price), 1000)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.powFunction.markdown', { + defaultMessage: ` Raises the value to a certain power. The second argument is required Example: Calculate volume based on side length -${'`pow(last_value(length), 3)`'} +\`pow(last_value(length), 3)\` `, + }), }, round: { positionalArguments: [ @@ -391,7 +409,8 @@ ${'`pow(last_value(length), 3)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.roundFunction.markdown', { + defaultMessage: ` Rounds to a specific number of decimal places, default of 0 Examples: Round to the cent @@ -400,6 +419,7 @@ round(sum(bytes)) round(sum(bytes), 2) \`\`\` `, + }), }, sqrt: { positionalArguments: [ @@ -408,12 +428,14 @@ round(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.sqrtFunction.markdown', { + defaultMessage: ` Square root of a positive value only Example: Calculate side length based on area -${'`sqrt(last_value(area))`'} +\`sqrt(last_value(area))\` `, + }), }, square: { positionalArguments: [ @@ -422,12 +444,14 @@ ${'`sqrt(last_value(area))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.squareFunction.markdown', { + defaultMessage: ` Raise the value to the 2nd power Example: Calculate area based on side length -${'`square(last_value(length))`'} +\`square(last_value(length))\` `, + }), }, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx index 0e9eeda51f786..37fd0a20865f7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx @@ -5,22 +5,99 @@ * 2.0. */ -import React, { FC, Fragment, useState, useEffect } from 'react'; - +import React, { FC, Fragment, useState, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useTimefilter } from '../../contexts/kibana'; import { NavigationMenu } from '../../components/navigation_menu'; import { HelpMenu } from '../../components/help_menu'; -import { useMlKibana } from '../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator } from '../../contexts/kibana'; + +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { isFullLicense } from '../../license'; +import { mlNodesAvailable, getMlNodeCount } from '../../ml_nodes_check/check_ml_nodes'; +import { checkPermission } from '../../capabilities/check_capabilities'; +import type { ResultLink, FileDataVisualizerSpec } from '../../../../../data_visualizer/public'; + +interface GetUrlParams { + indexPatternId: string; + globalState: any; +} export const FileDataVisualizerPage: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, dataVisualizer }, + services: { + docLinks, + dataVisualizer, + data: { + indexPatterns: { get: getIndexPattern }, + }, + }, } = useMlKibana(); - const [FileDataVisualizer, setFileDataVisualizer] = useState | null>(null); + const mlUrlGenerator = useMlUrlGenerator(); + getMlNodeCount(); + + const [FileDataVisualizer, setFileDataVisualizer] = useState(null); + + const links: ResultLink[] = useMemo( + () => [ + { + id: 'create_ml_job', + title: i18n.translate('xpack.ml.fileDatavisualizer.actionsPanel.anomalyDetectionTitle', { + defaultMessage: 'Create new ML job', + }), + description: '', + icon: 'machineLearningApp', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async ({ indexPatternId }) => { + try { + const { timeFieldName } = await getIndexPattern(indexPatternId); + return ( + isFullLicense() && + timeFieldName !== undefined && + checkPermission('canCreateJob') && + mlNodesAvailable() + ); + } catch (error) { + return false; + } + }, + }, + { + id: 'open_in_data_viz', + title: i18n.translate('xpack.ml.fileDatavisualizer.actionsPanel.dataframeTitle', { + defaultMessage: 'Open in Data Visualizer', + }), + description: '', + icon: 'dataVisualizer', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async () => true, + }, + ], + [] + ); useEffect(() => { if (dataVisualizer !== undefined) { + getMlNodeCount(); const { getFileDataVisualizerComponent } = dataVisualizer; getFileDataVisualizerComponent().then(setFileDataVisualizer); } @@ -29,7 +106,7 @@ export const FileDataVisualizerPage: FC = () => { return ( - {FileDataVisualizer} + {FileDataVisualizer !== null && } ); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx index af803d32e5139..60b6c90370b61 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx @@ -5,20 +5,40 @@ * 2.0. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; -import { useMlKibana, useTimefilter } from '../../contexts/kibana'; +import React, { FC, Fragment, useEffect, useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana, useTimefilter, useMlUrlGenerator } from '../../contexts/kibana'; import { NavigationMenu } from '../../components/navigation_menu'; import { HelpMenu } from '../../components/help_menu'; -import type { IndexDataVisualizerViewProps } from '../../../../../data_visualizer/public'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { isFullLicense } from '../../license'; +import { mlNodesAvailable, getMlNodeCount } from '../../ml_nodes_check/check_ml_nodes'; +import { checkPermission } from '../../capabilities/check_capabilities'; + +import type { ResultLink, IndexDataVisualizerSpec } from '../../../../../data_visualizer/public'; + +interface GetUrlParams { + indexPatternId: string; + globalState: any; +} + export const IndexDataVisualizerPage: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, dataVisualizer }, + services: { + docLinks, + dataVisualizer, + data: { + indexPatterns: { get: getIndexPattern }, + }, + }, } = useMlKibana(); - const [ - IndexDataVisualizer, - setIndexDataVisualizer, - ] = useState | null>(null); + const mlUrlGenerator = useMlUrlGenerator(); + getMlNodeCount(); + + const [IndexDataVisualizer, setIndexDataVisualizer] = useState( + null + ); useEffect(() => { if (dataVisualizer !== undefined) { @@ -26,10 +46,84 @@ export const IndexDataVisualizerPage: FC = () => { getIndexDataVisualizerComponent().then(setIndexDataVisualizer); } }, []); + + const links: ResultLink[] = useMemo( + () => [ + { + id: 'create_ml_ad_job', + title: i18n.translate('xpack.ml.indexDatavisualizer.actionsPanel.anomalyDetectionTitle', { + defaultMessage: 'Advanced anomaly detection', + }), + description: i18n.translate( + 'xpack.ml.indexDatavisualizer.actionsPanel.anomalyDetectionDescription', + { + defaultMessage: + 'Create a job with the full range of options for more advanced use cases.', + } + ), + icon: 'createAdvancedJob', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async ({ indexPatternId }) => { + try { + const { timeFieldName } = await getIndexPattern(indexPatternId); + return ( + isFullLicense() && + timeFieldName !== undefined && + checkPermission('canCreateJob') && + mlNodesAvailable() + ); + } catch (error) { + return false; + } + }, + dataTestSubj: 'dataVisualizerCreateAdvancedJobCard', + }, + { + id: 'create_ml_dfa_job', + title: i18n.translate('xpack.ml.indexDatavisualizer.actionsPanel.dataframeTitle', { + defaultMessage: 'Data frame analytics', + }), + description: i18n.translate( + 'xpack.ml.indexDatavisualizer.actionsPanel.dataframeDescription', + { + defaultMessage: 'Create outlier detection, regression, or classification analytics.', + } + ), + icon: 'classificationJob', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async () => { + return ( + isFullLicense() && checkPermission('canCreateDataFrameAnalytics') && mlNodesAvailable() + ); + }, + dataTestSubj: 'dataVisualizerCreateDataFrameAnalyticsCard', + }, + ], + [] + ); + return IndexDataVisualizer ? ( - {IndexDataVisualizer} + {IndexDataVisualizer !== null && } ) : ( 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 d959328218a18..82f8a90fafb7d 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -40,7 +40,6 @@ import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { mlEscape } from '../util/string_utils'; import { FormattedTooltip, MlTooltipComponent } 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'; @@ -62,6 +61,9 @@ declare global { } } +function getFormattedSeverityScore(score: number): string { + return String(parseInt(String(score), 10)); +} /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -122,7 +124,7 @@ const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { defaultMessage: 'Max anomaly score', }), - value: cell.formattedValue, + value: cell.formattedValue === '0' ? ' < 1' : cell.formattedValue, color: cell.color, // @ts-ignore seriesIdentifier: { @@ -408,73 +410,75 @@ export const SwimlaneContainer: FC = ({ grow={false} > <> -
- {showSwimlane && !isLoading && ( - - - - - - )} +
+
+ {showSwimlane && !isLoading && ( + + - {isLoading && ( - - + + )} + + {isLoading && ( + + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} + )} +
{swimlaneType === SWIMLANE_TYPE.OVERALL && showSwimlane && diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap index c9316dcd45ef7..793bf3a4f8ef9 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap @@ -26,6 +26,7 @@ exports[`NewCalendar Renders new calendar form 1`] = ` isGlobalCalendar={false} isNewCalendarIdValid={true} jobIds={Array []} + loading={true} onCalendarIdChange={[Function]} onCreate={[Function]} onCreateGroupOption={[Function]} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap index 49caddfd29f82..410017795c6cf 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -192,6 +192,7 @@ exports[`CalendarForm Renders calendar form 1`] = ` canDeleteCalendar={true} eventsList={Array []} onDeleteClick={[MockFunction]} + saving={false} showImportModal={[MockFunction]} showNewEventModal={[MockFunction]} showSearchBar={true} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js index da131e2e998fa..1792f5324396a 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js @@ -69,6 +69,7 @@ export const CalendarForm = ({ showImportModal, onJobSelection, saving, + loading, selectedGroupOptions, selectedJobOptions, showNewEventModal, @@ -83,7 +84,11 @@ export const CalendarForm = ({ const helpText = isNewCalendarIdValid === true && !isEdit ? msg : undefined; const error = isNewCalendarIdValid === false && !isEdit ? [msg] : undefined; const saveButtonDisabled = - canCreateCalendar === false || saving || !isNewCalendarIdValid || calendarId === ''; + canCreateCalendar === false || + saving || + !isNewCalendarIdValid || + calendarId === '' || + loading === true; const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); return ( @@ -116,7 +121,7 @@ export const CalendarForm = ({ name="calendarId" value={calendarId} onChange={onCalendarIdChange} - disabled={isEdit === true || saving === true} + disabled={isEdit === true || saving === true || loading === true} data-test-subj="mlCalendarIdInput" /> @@ -133,7 +138,7 @@ export const CalendarForm = ({ name="description" value={description} onChange={onDescriptionChange} - disabled={isEdit === true || saving === true} + disabled={isEdit === true || saving === true || loading === true} data-test-subj="mlCalendarDescriptionInput" /> @@ -152,7 +157,7 @@ export const CalendarForm = ({ } checked={isGlobalCalendar} onChange={onGlobalCalendarChange} - disabled={saving === true || canCreateCalendar === false} + disabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarApplyToAllJobsSwitch" /> @@ -172,7 +177,7 @@ export const CalendarForm = ({ options={jobIds} selectedOptions={selectedJobOptions} onChange={onJobSelection} - isDisabled={saving === true || canCreateCalendar === false} + isDisabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarJobSelection" /> @@ -190,7 +195,7 @@ export const CalendarForm = ({ options={groupIds} selectedOptions={selectedGroupOptions} onChange={onGroupSelection} - isDisabled={saving === true || canCreateCalendar === false} + isDisabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarJobGroupSelection" /> @@ -215,6 +220,8 @@ export const CalendarForm = ({ onDeleteClick={onEventDelete} showImportModal={showImportModal} showNewEventModal={showNewEventModal} + loading={loading} + saving={saving} showSearchBar /> @@ -272,6 +279,7 @@ CalendarForm.propTypes = { showImportModal: PropTypes.func.isRequired, onJobSelection: PropTypes.func.isRequired, saving: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, selectedGroupOptions: PropTypes.array.isRequired, selectedJobOptions: PropTypes.array.isRequired, showNewEventModal: PropTypes.func.isRequired, diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js index 4f5364136f923..ad5de1f92dae5 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js @@ -15,14 +15,14 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; -function DeleteButton({ onClick, canDeleteCalendar, testSubj }) { +function DeleteButton({ onClick, testSubj, disabled }) { return ( { const sorting = { sort: { @@ -93,7 +95,7 @@ export const EventsTable = ({ render: (event) => ( { onDeleteClick(event.event_id); }} @@ -105,7 +107,7 @@ export const EventsTable = ({ const search = { toolsRight: [ , { expect(wrapper).toMatchSnapshot(); }); - test('Import modal shown on Import Events button click', () => { + test('Import modal button is disabled', () => { const wrapper = mountWithIntl(); const importButton = wrapper.find('[data-test-subj="mlCalendarImportEventsButton"]'); const button = importButton.find('EuiButton'); - button.simulate('click'); - - expect(wrapper.state('isImportModalVisible')).toBe(true); + expect(button.prop('isDisabled')).toBe(true); }); - test('New event modal shown on New event button click', () => { + test('New event modal button is disabled', () => { const wrapper = mountWithIntl(); const importButton = wrapper.find('[data-test-subj="mlCalendarNewEventButton"]'); const button = importButton.find('EuiButton'); button.simulate('click'); - expect(wrapper.state('isNewEventModalVisible')).toBe(true); + expect(button.prop('isDisabled')).toBe(true); }); test('isDuplicateId returns true if form calendar id already exists in calendars', () => { diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index d13140f0be16c..6bd96e012548d 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -18,6 +18,7 @@ "data", "features", "ruleRegistry", + "timelines", "triggersActionsUi" ], "ui": true, diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c7faa28b04685..53b5300e556c5 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -31,7 +31,7 @@ import { } from '@kbn/rule-data-utils/target/technical_field_names'; import moment from 'moment-timezone'; import React, { useMemo } from 'react'; -import type { TopAlertResponse } from '../'; +import type { TopAlert, TopAlertResponse } from '../'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; import { asDuration } from '../../../../common/utils/formatters'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; @@ -39,6 +39,7 @@ import { decorateResponse } from '../decorate_response'; import { SeverityBadge } from '../severity_badge'; type AlertsFlyoutProps = { + alert?: TopAlert; alerts?: TopAlertResponse[]; isInApp?: boolean; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; @@ -46,6 +47,7 @@ type AlertsFlyoutProps = { } & EuiFlyoutProps; export function AlertsFlyout({ + alert, alerts, isInApp = false, observabilityRuleTypeRegistry, @@ -59,9 +61,12 @@ export function AlertsFlyout({ const decoratedAlerts = useMemo(() => { return decorateResponse(alerts ?? [], observabilityRuleTypeRegistry); }, [alerts, observabilityRuleTypeRegistry]); - const alert = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); - if (!alert) { + let alertData = alert; + if (!alertData) { + alertData = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); + } + if (!alertData) { return null; } @@ -70,45 +75,45 @@ export function AlertsFlyout({ title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', { defaultMessage: 'Status', }), - description: alert.active ? 'Active' : 'Recovered', + description: alertData.active ? 'Active' : 'Recovered', }, { title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', { defaultMessage: 'Severity', }), - description: , + description: , }, { title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', { defaultMessage: 'Triggered', }), description: ( - {moment(alert.start).format(dateFormat)} + {moment(alertData.start).format(dateFormat)} ), }, { title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', { defaultMessage: 'Duration', }), - description: asDuration(alert.fields[ALERT_DURATION], { extended: true }), + description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }), }, { title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', { defaultMessage: 'Expected value', }), - description: alert.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', + description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', { defaultMessage: 'Actual value', }), - description: alert.fields[ALERT_EVALUATION_VALUE] ?? '-', + description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', { defaultMessage: 'Rule type', }), - description: alert.fields[RULE_CATEGORY] ?? '-', + description: alertData.fields[RULE_CATEGORY] ?? '-', }, ]; @@ -116,10 +121,10 @@ export function AlertsFlyout({ -

{alert.fields[RULE_NAME]}

+

{alertData.fields[RULE_NAME]}

- {alert.reason} + {alertData.reason}
@@ -129,11 +134,11 @@ export function AlertsFlyout({ listItems={overviewListItems} /> - {alert.link && !isInApp && ( + {alertData.link && !isInApp && ( - + View in app diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index c0a08fa7faac7..b2d44f9a598dd 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -7,17 +7,17 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo, useState } from 'react'; -import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; +import { IIndexPattern, SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { callObservabilityApi } from '../../services/call_observability_api'; export function AlertsSearchBar({ + dynamicIndexPattern, rangeFrom, rangeTo, onQueryChange, query, }: { + dynamicIndexPattern: IIndexPattern[]; rangeFrom?: string; rangeTo?: string; query?: string; @@ -31,16 +31,9 @@ export function AlertsSearchBar({ }, []); const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery'); - const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', - }); - }, []); - return ( void) => void; +} + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', { + defaultMessage: 'Status', + }), + id: ALERT_STATUS, + initialWidth: 79, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.triggeredColumnDescription', { + defaultMessage: 'Triggered', + }), + id: ALERT_START, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.durationColumnDescription', { + defaultMessage: 'Duration', + }), + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.severityColumnDescription', { + defaultMessage: 'Severity', + }), + id: ALERT_SEVERITY_LEVEL, + initialWidth: 102, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonColumnDescription', { + defaultMessage: 'Reason', + }), + linkField: '*', + id: RULE_NAME, + initialWidth: 400, + }, +]; + +const NO_ROW_RENDER: RowRenderer[] = []; + +const trailingControlColumns: never[] = []; + +export function AlertsTableTGrid(props: AlertsTableTGridProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { prepend } = core.http.basePath; + const { indexName, rangeFrom, rangeTo, kuery, status, setRefetch } = props; + const [flyoutAlert, setFlyoutAlert] = useState(undefined); + const handleFlyoutClose = () => setFlyoutAlert(undefined); + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + + const leadingControlColumns = [ + { + id: 'expand', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + setFlyoutAlert(alert)} + /> + ); + }, + }, + { + id: 'view_in_app', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + + ); + }, + }, + ]; + + return ( + <> + {flyoutAlert && ( + + + + )} + {timelines.getTGrid<'standalone'>({ + type: 'standalone', + columns, + deletedEventIds: [], + end: rangeTo, + filters: [], + indexNames: [indexName], + itemsPerPage: 10, + itemsPerPageOptions: [10, 25, 50], + loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { + defaultMessage: 'loading alerts', + }), + footerText: i18n.translate('xpack.observability.alertsTable.footerTextLabel', { + defaultMessage: 'alerts', + }), + query: { + query: `${ALERT_STATUS}: ${status}${kuery !== '' ? ` and ${kuery}` : ''}`, + language: 'kuery', + }, + renderCellValue: getRenderCellValue({ rangeFrom, rangeTo, setFlyoutAlert }), + rowRenderers: NO_ROW_RENDER, + start: rangeFrom, + setRefetch, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + }, + ], + leadingControlColumns, + trailingControlColumns, + unit: (totalAlerts: number) => + i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', { + values: { totalAlerts }, + defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}', + }), + })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx new file mode 100644 index 0000000000000..38919857e86c1 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RULE_ID, RULE_NAME } from '@kbn/rule-data-utils/target/technical_field_names'; +import React, { useState } from 'react'; +import { format, parse } from 'url'; + +import { parseTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; +import type { ActionProps } from '../../../../timelines/common'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +export function RowCellActionsRender({ data }: ActionProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { prepend } = core.http.basePath; + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const parsedFields = parseTechnicalFields(dataFieldEs); + const formatter = observabilityRuleTypeRegistry.getFormatter(parsedFields[RULE_ID]!); + const formatted = { + link: undefined, + reason: parsedFields[RULE_NAME]!, + ...(formatter?.({ fields: parsedFields, formatters: { asDuration, asPercent } }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + const link = parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }) + : undefined; + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + > + Actions +
+ + + + + + + {i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', { + defaultMessage: 'View in app', + })} + + + +
+
+
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 6f696a70665ce..fed9ee0be3a4a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -7,21 +7,20 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertStatus } from '../../../common/typings'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; -import { callObservabilityApi } from '../../services/call_observability_api'; import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; -import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertsTable } from './alerts_table'; +import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -41,6 +40,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { core, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); + const refetch = useRef<() => void>(); const { query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; @@ -59,37 +59,52 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); - const { data: alerts } = useFetcher( - ({ signal }) => { - const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); + + const dynamicIndexPattern = useMemo( + () => (dynamicIndexPatternResp ? [dynamicIndexPatternResp] : []), + [dynamicIndexPatternResp] + ); + + const setStatusFilter = useCallback( + (value: AlertStatus) => { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }, + [history] + ); - if (!start || !end) { - return; + const onQueryChange = useCallback( + ({ dateRange, query }) => { + if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { + return refetch.current && refetch.current(); } - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/top', - params: { - query: { - start, - end, - kuery, - status, - }, - }, + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), }); }, - [kuery, rangeFrom, rangeTo, status] + [history, rangeFrom, rangeTo, kuery] ); - function setStatusFilter(value: AlertStatus) { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('status', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - } + const setRefetch = useCallback((ref) => { + refetch.current = ref; + }, []); return ( { - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - }} + onQueryChange={onQueryChange} /> @@ -162,7 +167,14 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
- + 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + /> diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx new file mode 100644 index 0000000000000..1cd86631197c4 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconTip, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { CellValueElementProps, TimelineNonEcsData } from '../../../../timelines/common'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; +import { asDuration } from '../../../common/utils/formatters'; +import { SeverityBadge } from './severity_badge'; +import { TopAlert } from '.'; +import { decorateResponse } from './decorate_response'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const getRenderCellValue = ({ + rangeTo, + rangeFrom, + setFlyoutAlert, +}: { + rangeTo: string; + rangeFrom: string; + setFlyoutAlert: (data: TopAlert) => void; +}) => { + return ({ columnId, data, linkValues }: CellValueElementProps) => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]); + + switch (columnId) { + case ALERT_STATUS: + return value !== 'closed' ? ( + + ) : ( + + ); + case ALERT_START: + return ; + case ALERT_DURATION: + return asDuration(Number(value), { extended: true }); + case ALERT_SEVERITY_LEVEL: + return ; + case RULE_NAME: + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + + return ( + setFlyoutAlert && setFlyoutAlert(alert)}>{alert.reason} + ); + default: + return <>{value}; + } + }; +}; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index b6ed0a0a3d17f..8aa184bca913f 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../cases/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../rule_registry/tsconfig.json" }, + { "path": "../timelines/tsconfig.json"}, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index f20c6c2d52fdd..c95c837c4959f 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -91,6 +91,12 @@ export const API_BASE_GENERATE = `${API_BASE_URL}/generate`; export const API_LIST_URL = `${API_BASE_URL}/jobs`; export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`; +export const API_GET_ILM_POLICY_STATUS = `${API_BASE_URL}/ilm_policy_status`; +export const API_CREATE_ILM_POLICY_URL = `${API_BASE_URL}/ilm_policy`; +export const API_MIGRATE_ILM_POLICY_URL = `${API_BASE_URL}/deprecations/migrate_ilm_policy`; + +export const ILM_POLICY_NAME = 'kibana-reporting'; + // hacky endpoint: download CSV without queueing a report export const API_BASE_URL_V1 = '/api/reporting/v1'; // export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv_searchsource`; diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 8205b4f13a320..6efaf42a5ad14 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -164,3 +164,9 @@ export type DownloadReportFn = (jobId: JobId) => DownloadLink; type ManagementLink = string; export type ManagementLinkFn = () => ManagementLink; + +export type IlmPolicyMigrationStatus = 'policy-not-found' | 'indices-not-managed-by-policy' | 'ok'; + +export interface IlmPolicyStatusResponse { + status: IlmPolicyMigrationStatus; +} diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index ddba61e9a0b8d..6a8f3a3e4e5ec 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -6,6 +6,7 @@ "configPath": ["xpack", "reporting"], "requiredPlugins": [ "data", + "esUiShared", "home", "management", "licensing", diff --git a/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx b/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx new file mode 100644 index 0000000000000..78b2e77d09aee --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React, { createContext, useContext } from 'react'; + +import { IlmPolicyStatusResponse } from '../../common/types'; + +import { useCheckIlmPolicyStatus } from './reporting_api_client'; + +type UseCheckIlmPolicyStatus = ReturnType; + +interface ContextValue { + status: undefined | IlmPolicyStatusResponse['status']; + isLoading: UseCheckIlmPolicyStatus['isLoading']; + recheckStatus: UseCheckIlmPolicyStatus['resendRequest']; +} + +const IlmPolicyStatusContext = createContext(undefined); + +export const IlmPolicyStatusContextProvider: FunctionComponent = ({ children }) => { + const { isLoading, data, resendRequest: recheckStatus } = useCheckIlmPolicyStatus(); + + return ( + + {children} + + ); +}; + +export type UseIlmPolicyStatusReturn = ReturnType; + +export const useIlmPolicyStatus = (): ContextValue => { + const ctx = useContext(IlmPolicyStatusContext); + if (!ctx) { + throw new Error('"useIlmPolicyStatus" can only be used inside of "IlmPolicyStatusContext"'); + } + return ctx; +}; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx new file mode 100644 index 0000000000000..37857943774d4 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from 'src/core/public'; +import type { FunctionComponent } from 'react'; +import React, { createContext, useContext } from 'react'; + +import type { ReportingAPIClient } from './reporting_api_client'; + +interface ContextValue { + http: HttpSetup; + apiClient: ReportingAPIClient; +} + +const InternalApiClientContext = createContext(undefined); + +export const InternalApiClientClientProvider: FunctionComponent<{ + http: HttpSetup; + apiClient: ReportingAPIClient; +}> = ({ http, apiClient, children }) => { + return ( + + {children} + + ); +}; + +export const useInternalApiClient = (): ContextValue => { + const ctx = useContext(InternalApiClientContext); + if (!ctx) { + throw new Error('"useInternalApiClient" can only be used inside of "InternalApiClientContext"'); + } + return ctx; +}; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts new file mode 100644 index 0000000000000..afd8222fd3831 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRequest, UseRequestResponse } from '../../shared_imports'; +import { IlmPolicyStatusResponse } from '../../../common/types'; + +import { API_GET_ILM_POLICY_STATUS } from '../../../common/constants'; + +import { useInternalApiClient } from './context'; + +export const useCheckIlmPolicyStatus = (): UseRequestResponse => { + const { http } = useInternalApiClient(); + return useRequest(http, { path: API_GET_ILM_POLICY_STATUS, method: 'get' }); +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts similarity index 52% rename from x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts rename to x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts index b875e08c2a230..b32d675a1d209 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { useCallback } from 'react'; -import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad'; +export * from './reporting_api_client'; -export const useDownloadWorkpad = () => - useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []); +export * from './hooks'; + +export { InternalApiClientClientProvider, useInternalApiClient } from './context'; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts similarity index 93% rename from x-pack/plugins/reporting/public/lib/reporting_api_client.ts rename to x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 4ce9e8760f21f..64caac0e27bdd 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -12,8 +12,9 @@ import { API_BASE_GENERATE, API_BASE_URL, API_LIST_URL, + API_MIGRATE_ILM_POLICY_URL, REPORTING_MANAGEMENT_HOME, -} from '../../common/constants'; +} from '../../../common/constants'; import { DownloadReportFn, JobId, @@ -21,8 +22,8 @@ import { ReportApiJSON, ReportDocument, ReportSource, -} from '../../common/types'; -import { add } from '../notifier/job_completion_notifications'; +} from '../../../common/types'; +import { add } from '../../notifier/job_completion_notifications'; export interface JobQueueEntry { _id: string; @@ -167,4 +168,8 @@ export class ReportingAPIClient { this.http.post(`${API_BASE_URL}/diagnose/screenshot`, { asSystemRequest: true, }); + + public migrateReportingIndicesIlmPolicy = (): Promise => { + return this.http.put(`${API_MIGRATE_ILM_POLICY_URL}`); + }; } diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 93df3c8d99935..9ce249aa32a1d 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -1,82 +1,116 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ReportListing Report job listing with some items 1`] = ` -Array [ - -
-
- +
+
+ +
+
- +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + + + + + + + + + -
-
-
- -
- -
- - - - -
+ +
- - - - - - - - - - - - - - + + + - - - + + + - - - + + + - - - - - - - - - - - - - - - -
+ + - -
+ + -
+ - -
- -
-
- + Report + -
-
- - - - - - Report - - - - - - + + + + + - - + - - - - Created at - - - - - - + Created at + + + + + + + + - - + - - - - Status - - - - - - + Status + + + + + + + + - - - - - - Actions - - - - - -
+ -
- Loading reports + Actions -
-
-
-
-
- , -
-
- -
+ + + + + + + + + - -
+ - -
- - -
- -
- -
- - -
- - -
- -
- -
- - - - - - - - - - - - - + + - - - + canvas workpad + + + + + + + + + - - - + Created at + +
+
+
+ 2020-04-14 @ 05:01 PM +
+ + elastic + +
+
+ + + -
- - - + - - - - - - - - + + + + + + + + - - - - - -
- -
-
- - - - + Report + +
+
+
+ My Canvas Workpad +
+ +
- - Report - - - - - -
- - - - - - Created at - - - - - - - - - - - - Status - - - - - - - - - - + Status + +
+
+ + Pending - waiting for job to be processed + +
+
+ + + +
+
+ + +
- - Actions - - - - - - - + + + + + + + + + +
+
+ + + +
+ +
+
+ + +
+ +
+
+ + +
+
+
+ Report +
+
- + My Canvas Workpad +
+ - Loading reports +
+ + + canvas workpad + + +
+
+
+ +
+
+ Created at +
+
+
+
+ 2020-04-14 @ 05:01 PM +
+ + elastic
-
-
+
+ + + + +
+ Status +
+
+
+ + 2020-04-14 @ 05:01 PM + , + } + } + > + Processing (attempt 1 of 1) at + + 2020-04-14 @ 05:01 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 04:19 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 04:19 PM + , + } + } + > + Completed at + + 2020-04-14 @ 04:19 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 01:20 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 01:21 PM + , + } + } + > + Completed with warnings at + + 2020-04-14 @ 01:21 PM + + + +
+ + + + Errors occurred: see job info for details. + + + +
+
+
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 01:19 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 01:19 PM + , + } + } + > + Completed at + + 2020-04-14 @ 01:19 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 01:19 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 01:19 PM + , + } + } + > + Completed at + + 2020-04-14 @ 01:19 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 01:17 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 01:18 PM + , + } + } + > + Completed at + + 2020-04-14 @ 01:18 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ My Canvas Workpad +
+ +
+ + + canvas workpad + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-14 @ 01:12 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-14 @ 01:13 PM + , + } + } + > + Completed at + + 2020-04-14 @ 01:13 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + +
+ Report +
+
+
+
+ count +
+ +
+ + + visualization + + +
+
+
+
+ +
+ + +
+ Created at +
+
+
+
+ 2020-04-09 @ 03:09 PM +
+ + elastic + +
+
+ +
+ + +
+ Status +
+
+
+ + 2020-04-09 @ 03:10 PM + , + } + } + > + Completed at + + 2020-04-09 @ 03:10 PM + + +
+
+ +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+ + + + + + +
+ + } + onPageChange={[Function]} + onPageSizeChange={[Function]} + pagination={ + Object { + "hidePerPageOptions": true, + "pageIndex": 0, + "pageSize": 10, + "totalItemCount": 18, + } + } + > +
+ +
+ + + +
+ +
+ + +
+ + + +
+
+
+ +
-
, -] + +
`; diff --git a/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx b/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx new file mode 100644 index 0000000000000..3945ec5be9fa7 --- /dev/null +++ b/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import type { ApplicationStart } from 'src/core/public'; + +import { ILM_POLICY_NAME } from '../../common/constants'; +import { LocatorPublic, SerializableState } from '../shared_imports'; + +interface Props { + navigateToUrl: ApplicationStart['navigateToUrl']; + locator: LocatorPublic; +} + +const i18nTexts = { + buttonLabel: i18n.translate('xpack.reporting.listing.reports.ilmPolicyLinkText', { + defaultMessage: 'Edit ILM policy', + }), +}; + +export const IlmPolicyLink: FunctionComponent = ({ locator, navigateToUrl }) => { + return ( + { + locator + .getUrl({ + page: 'policy_edit', + policyName: ILM_POLICY_NAME, + }) + .then((url) => { + navigateToUrl(url); + }); + }} + > + {i18nTexts.buttonLabel} + + ); +}; diff --git a/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx new file mode 100644 index 0000000000000..5bb3ac524e130 --- /dev/null +++ b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import { EuiCallOut, EuiButton, EuiCode } from '@elastic/eui'; + +import type { NotificationsSetup } from 'src/core/public'; + +import { ILM_POLICY_NAME } from '../../../common/constants'; + +import { useInternalApiClient } from '../../lib/reporting_api_client'; + +const i18nTexts = { + title: i18n.translate('xpack.reporting.listing.ilmPolicyCallout.migrationNeededTitle', { + defaultMessage: 'Migrate reporting indices', + }), + description: ( + {ILM_POLICY_NAME}, + }} + /> + ), + buttonLabel: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesButtonLabel', + { + defaultMessage: 'Migrate indices', + } + ), + migrateErrorTitle: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesErrorTitle', + { + defaultMessage: 'Could not migrate reporting indices', + } + ), + migrateSuccessTitle: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesSuccessTitle', + { + defaultMessage: 'Successfully migrated reporting indices', + } + ), +}; + +interface Props { + toasts: NotificationsSetup['toasts']; + onMigrationDone: () => void; +} + +export const IlmPolicyMigrationNeededCallOut: FunctionComponent = ({ + toasts, + onMigrationDone, +}) => { + const [isMigratingIndices, setIsMigratingIndices] = useState(false); + + const { apiClient } = useInternalApiClient(); + + const migrateReportingIndices = async () => { + try { + setIsMigratingIndices(true); + await apiClient.migrateReportingIndicesIlmPolicy(); + onMigrationDone(); + toasts.addSuccess({ title: i18nTexts.migrateSuccessTitle }); + } catch (e) { + toasts.addError(e, { + title: i18nTexts.migrateErrorTitle, + toastMessage: e.body?.message, + }); + } finally { + setIsMigratingIndices(false); + } + }; + + return ( + +

{i18nTexts.description}

+ + {i18nTexts.buttonLabel} + +
+ ); +}; diff --git a/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx new file mode 100644 index 0000000000000..892cbcdde5ede --- /dev/null +++ b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { EuiSpacer, EuiFlexItem } from '@elastic/eui'; + +import { NotificationsSetup } from 'src/core/public'; + +import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; + +import { IlmPolicyMigrationNeededCallOut } from './ilm_policy_migration_needed_callout'; + +interface Props { + toasts: NotificationsSetup['toasts']; +} + +export const MigrateIlmPolicyCallOut: FunctionComponent = ({ toasts }) => { + const { isLoading, recheckStatus, status } = useIlmPolicyStatus(); + + if (isLoading || !status || status === 'ok') { + return null; + } + + return ( + <> + + + + + + ); +}; diff --git a/x-pack/plugins/reporting/public/management/mount_management_section.tsx b/x-pack/plugins/reporting/public/management/mount_management_section.tsx index eb1057a9bdfc7..8d147628c6662 100644 --- a/x-pack/plugins/reporting/public/management/mount_management_section.tsx +++ b/x-pack/plugins/reporting/public/management/mount_management_section.tsx @@ -10,10 +10,11 @@ import * as React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Observable } from 'rxjs'; import { CoreSetup, CoreStart } from 'src/core/public'; -import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; import { ILicense } from '../../../licensing/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { ClientConfigType } from '../plugin'; +import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports'; import { ReportListing } from './report_listing'; export async function mountManagementSection( @@ -22,17 +23,23 @@ export async function mountManagementSection( license$: Observable, pollConfig: ClientConfigType['poll'], apiClient: ReportingAPIClient, + urlService: SharePluginSetup['url'], params: ManagementAppMountParams ) { render( - + + + + + , params.element ); diff --git a/x-pack/plugins/reporting/public/management/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx index efc1a7dfe3b20..0b278cbaa0449 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.test.tsx @@ -7,9 +7,22 @@ import React from 'react'; import { Observable } from 'rxjs'; -import { mountWithIntl } from '@kbn/test/jest'; -import { ILicense } from '../../../licensing/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { UnwrapPromise } from '@kbn/utility-types'; + +import { act } from 'react-dom/test-utils'; + +import { registerTestBed } from '@kbn/test/jest'; + +import type { SharePluginSetup, LocatorPublic } from '../../../../../src/plugins/share/public'; +import type { NotificationsSetup } from '../../../../../src/core/public'; +import { httpServiceMock, notificationServiceMock } from '../../../../../src/core/public/mocks'; + +import type { ILicense } from '../../../licensing/public'; + +import { IlmPolicyMigrationStatus } from '../../common/types'; + +import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -17,7 +30,7 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { }; }); -import { ReportListing } from './report_listing'; +import { ReportListing, Props } from './report_listing'; const reportingAPIClient = { list: () => @@ -33,6 +46,7 @@ const reportingAPIClient = { { _id: 'k8t4ylcb07mi9d006214ifyg', _index: '.reporting-2020.04.05', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-09T19:10:10.049Z', created_at: '2020-04-09T19:09:52.139Z', created_by: 'elastic', jobtype: 'PNG', kibana_id: 'f2e59b4e-f79b-4a48-8a7d-6d50a3c1d914', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'png', objectType: 'visualization', }, output: { content_type: 'image/png', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-09T19:09:52.137Z', layout: { dimensions: { height: 1575, width: 1423, }, id: 'png', }, objectType: 'visualization', relativeUrl: "/s/hsyjklk/app/visualize#/edit/94d1fe40-7a94-11ea-b373-0749f92ad295?_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!((enabled:!t,id:'1',params:(),schema:metric,type:count)),params:(addLegend:!f,addTooltip:!t,metric:(colorSchema:'Green%20to%20Red',colorsRange:!((from:0,to:10000)),invertColors:!f,labels:(show:!t),metricColorMode:None,percentageMode:!f,style:(bgColor:!f,bgFill:%23000,fontSize:60,labelColor:!f,subText:''),useRanges:!f),type:metric),title:count,type:metric))&_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15y,to:now))&indexPattern=d81752b0-7434-11ea-be36-1f978cda44d4&type=metric", title: 'count', }, priority: 10, process_expiration: '2020-04-09T19:14:54.570Z', started_at: '2020-04-09T19:09:54.570Z', status: 'completed', timeout: 300000, }, sort: [1586459392139], }, ]), // prettier-ignore total: () => Promise.resolve(18), + migrateReportingIndicesIlmPolicy: jest.fn(), } as any; const validCheck = { @@ -48,10 +62,6 @@ const license$ = { }, } as Observable; -const toasts = { - addDanger: jest.fn(), -} as any; - const mockPollConfig = { jobCompletionNotifier: { interval: 5000, @@ -64,22 +74,87 @@ const mockPollConfig = { }; describe('ReportListing', () => { - it('Report job listing with some items', () => { - const wrapper = mountWithIntl( - ; + let ilmLocator: undefined | LocatorPublic; + let urlService: SharePluginSetup['url']; + let testBed: UnwrapPromise>; + let toasts: NotificationsSetup['toasts']; + + const createTestBed = registerTestBed( + (props?: Partial) => ( + - ); - wrapper.update(); - const input = wrapper.find('[data-test-subj="reportJobListing"]'); - expect(input).toMatchSnapshot(); + http={httpService} + > + + + + + ), + { memoryRouter: { wrapComponent: false } } + ); + + const setup = async (props?: Partial) => { + const tb = await createTestBed(props); + const { find, exists, component } = tb; + + return { + ...tb, + actions: { + findListTable: () => find('reportJobListing'), + hasIlmMigrationBanner: () => exists('migrateReportingIndicesPolicyCallOut'), + hasIlmPolicyLink: () => exists('ilmPolicyLink'), + migrateIndices: async () => { + await act(async () => { + find('migrateReportingIndicesButton').simulate('click'); + }); + component.update(); + }, + }, + }; + }; + + const runSetup = async (props?: Partial) => { + await act(async () => { + testBed = await setup(props); + }); + testBed.component.update(); + }; + + beforeEach(async () => { + toasts = notificationServiceMock.createSetupContract().toasts; + httpService = httpServiceMock.createSetupContract(); + ilmLocator = ({ + getUrl: jest.fn(), + } as unknown) as LocatorPublic; + + urlService = ({ + locators: { + get: () => ilmLocator, + }, + } as unknown) as SharePluginSetup['url']; + await runSetup(); + }); + + afterEach(() => { + jest.clearAllMocks(); }); - it('subscribes to license changes, and unsubscribes on dismount', () => { + it('Report job listing with some items', () => { + const { actions } = testBed; + const table = actions.findListTable(); + expect(table).toMatchSnapshot(); + }); + + it('subscribes to license changes, and unsubscribes on dismount', async () => { const unsubscribeMock = jest.fn(); const subMock = { subscribe: jest.fn().mockReturnValue({ @@ -87,19 +162,103 @@ describe('ReportListing', () => { }), } as any; - const wrapper = mountWithIntl( - } - pollConfig={mockPollConfig} - redirect={jest.fn()} - toasts={toasts} - /> - ); - wrapper.update(); + await runSetup({ license$: subMock }); + expect(subMock.subscribe).toHaveBeenCalled(); expect(unsubscribeMock).not.toHaveBeenCalled(); - wrapper.unmount(); + testBed.component.unmount(); expect(unsubscribeMock).toHaveBeenCalled(); }); + + describe('ILM policy', () => { + beforeEach(async () => { + httpService = httpServiceMock.createSetupContract(); + ilmLocator = ({ + getUrl: jest.fn(), + } as unknown) as LocatorPublic; + + urlService = ({ + locators: { + get: () => ilmLocator, + }, + } as unknown) as SharePluginSetup['url']; + + await runSetup(); + }); + + it('shows the migrate banner when migration status is not "OK"', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmMigrationBanner()).toBe(true); + }); + + it('does not show the migrate banner when migration status is "OK"', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmMigrationBanner()).toBe(false); + }); + + it('hides the ILM policy link if there is no ILM policy', async () => { + const status: IlmPolicyMigrationStatus = 'policy-not-found'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(false); + }); + + it('hides the ILM policy link if there is no ILM policy locator', async () => { + ilmLocator = undefined; + const status: IlmPolicyMigrationStatus = 'ok'; // should never happen, but need to test that when the locator is missing we don't render the link + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(false); + }); + + it('always shows the ILM policy link if there is an ILM policy', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(true); + + const status2: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValue({ status: status2 }); + await runSetup(); + expect(actions.hasIlmPolicyLink()).toBe(true); + }); + + it('hides the banner after migrating indices', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + const status2: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValueOnce({ status }); + httpService.get.mockResolvedValueOnce({ status: status2 }); + await runSetup(); + const { actions } = testBed; + + expect(actions.hasIlmMigrationBanner()).toBe(true); + await actions.migrateIndices(); + expect(actions.hasIlmMigrationBanner()).toBe(false); + expect(actions.hasIlmPolicyLink()).toBe(true); + expect(toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + + it('informs users when migrations failed', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValueOnce({ status }); + reportingAPIClient.migrateReportingIndicesIlmPolicy.mockRejectedValueOnce(new Error('oops!')); + await runSetup(); + const { actions } = testBed; + + expect(actions.hasIlmMigrationBanner()).toBe(true); + await actions.migrateIndices(); + expect(toasts.addError).toHaveBeenCalledTimes(1); + expect(actions.hasIlmMigrationBanner()).toBe(true); + expect(actions.hasIlmPolicyLink()).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 0b6ece4d8bd02..749e42de526d3 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -13,6 +13,7 @@ import { EuiSpacer, EuiText, EuiTextColor, + EuiLoadingSpinner, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; @@ -26,10 +27,18 @@ import { JOB_STATUSES as JobStatuses } from '../../common/constants'; import { Poller } from '../../common/poller'; import { durationToNumber } from '../../common/schema_utils'; import { checkLicense } from '../lib/license_check'; -import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; +import { + JobQueueEntry, + ReportingAPIClient, + useInternalApiClient, +} from '../lib/reporting_api_client'; +import { useIlmPolicyStatus, UseIlmPolicyStatusReturn } from '../lib/ilm_policy_status_context'; +import type { SharePluginSetup } from '../shared_imports'; import { ClientConfigType } from '../plugin'; import { ReportDeleteButton, ReportDownloadButton, ReportErrorButton, ReportInfoButton } from './'; import { ReportDiagnostic } from './report_diagnostic'; +import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; +import { IlmPolicyLink } from './ilm_policy_link'; export interface Job { id: string; @@ -55,7 +64,10 @@ export interface Props { license$: LicensingPluginSetup['license$']; pollConfig: ClientConfigType['poll']; redirect: ApplicationStart['navigateToApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; toasts: ToastsSetup; + urlService: SharePluginSetup['url']; + ilmPolicyContextValue: UseIlmPolicyStatusReturn; } interface State { @@ -132,6 +144,10 @@ class ReportListingUi extends Component { } public render() { + const { ilmPolicyContextValue, urlService, navigateToUrl } = this.props; + const ilmLocator = urlService.locators.get('ILM_LOCATOR_ID'); + const hasIlmPolicy = ilmPolicyContextValue.status !== 'policy-not-found'; + const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); return ( <> { } /> + + {this.renderTable()} - + + + {ilmPolicyContextValue.isLoading ? ( + + ) : ( + showIlmPolicyLink && ( + + ) + )} + @@ -531,4 +558,18 @@ class ReportListingUi extends Component { } } -export const ReportListing = injectI18n(ReportListingUi); +const PrivateReportListing = injectI18n(ReportListingUi); + +export const ReportListing = ( + props: Omit +) => { + const ilmPolicyStatusValue = useIlmPolicyStatus(); + const { apiClient } = useInternalApiClient(); + return ( + + ); +}; diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index a2881af902072..fcbc4662c6e59 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -15,7 +15,6 @@ import { Plugin, PluginInitializerContext, } from 'src/core/public'; -import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../src/plugins/embeddable/public'; import { FeatureCatalogueCategory, @@ -23,7 +22,6 @@ import { HomePublicPluginStart, } from '../../../../src/plugins/home/public'; import { ManagementSetup, ManagementStart } from '../../../../src/plugins/management/public'; -import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; import { constants, getDefaultLayoutSelectors } from '../common'; import { durationToNumber } from '../common/schema_utils'; @@ -37,6 +35,13 @@ import { getSharedComponents } from './shared'; import { ReportingCsvShareProvider } from './share_context_menu/register_csv_reporting'; import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; +import type { + SharePluginSetup, + SharePluginStart, + UiActionsSetup, + UiActionsStart, +} from './shared_imports'; + export interface ClientConfigType { poll: { jobsRefresh: { interval: number; intervalErrorMultiplier: number } }; roles: { enabled: boolean }; @@ -159,6 +164,7 @@ export class ReportingPublicPlugin license$, this.config.poll, apiClient, + share.url, params ); }, diff --git a/x-pack/plugins/reporting/public/shared_imports.ts b/x-pack/plugins/reporting/public/shared_imports.ts new file mode 100644 index 0000000000000..010da46c07401 --- /dev/null +++ b/x-pack/plugins/reporting/public/shared_imports.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + SharePluginSetup, + SharePluginStart, + LocatorPublic, +} from '../../../../src/plugins/share/public'; + +export { useRequest, UseRequestResponse } from '../../../../src/plugins/es_ui_shared/public'; + +export type { SerializableState } from 'src/plugins/kibana_utils/common'; + +export type { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; + +export type { ManagementAppMountParams } from 'src/plugins/management/public'; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts b/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts new file mode 100644 index 0000000000000..dc20f92f38c94 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + IndicesIndexStatePrefixedSettings, + IndicesIndexSettings, +} from '@elastic/elasticsearch/api/types'; +import { ILM_POLICY_NAME } from '../../../common/constants'; +import { IlmPolicyMigrationStatus } from '../../../common/types'; +import { IlmPolicyManager } from '../../lib/store/ilm_policy_manager'; +import type { DeprecationsDependencies } from './types'; + +export const checkIlmMigrationStatus = async ({ + reportingCore, + elasticsearchClient, +}: DeprecationsDependencies): Promise => { + const ilmPolicyManager = IlmPolicyManager.create({ client: elasticsearchClient }); + if (!(await ilmPolicyManager.doesIlmPolicyExist())) { + return 'policy-not-found'; + } + + const store = await reportingCore.getStore(); + const indexPattern = store.getReportingIndexPattern(); + + const { body: reportingIndicesSettings } = await elasticsearchClient.indices.getSettings({ + index: indexPattern, + }); + + const hasUnmanagedIndices = Object.values(reportingIndicesSettings).some((settings) => { + return ( + (settings?.settings as IndicesIndexStatePrefixedSettings)?.index?.lifecycle?.name !== + ILM_POLICY_NAME && + (settings?.settings as IndicesIndexSettings)?.['index.lifecycle']?.name !== ILM_POLICY_NAME + ); + }); + + return hasUnmanagedIndices ? 'indices-not-managed-by-policy' : 'ok'; +}; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/index.ts b/x-pack/plugins/reporting/server/lib/deprecations/index.ts new file mode 100644 index 0000000000000..95594940e07e2 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { checkIlmMigrationStatus } from './check_ilm_migration_status'; + +export const deprecations = { + checkIlmMigrationStatus, +}; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/types.ts b/x-pack/plugins/reporting/server/lib/deprecations/types.ts new file mode 100644 index 0000000000000..c6e9e3b7ad920 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from 'src/core/server'; +import type { ReportingCore } from '../../core'; + +export interface DeprecationsDependencies { + reportingCore: ReportingCore; + elasticsearchClient: ElasticsearchClient; +} diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index e66f72f88f8ea..b2a2a1edcd6a5 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -10,5 +10,5 @@ export { cryptoFactory } from './crypto'; export { ExportTypesRegistry, getExportTypesRegistry } from './export_types_registry'; export { LevelLogger } from './level_logger'; export { statuses } from './statuses'; -export { ReportingStore } from './store'; +export { ReportingStore, IlmPolicyManager } from './store'; export { startTrace } from './trace'; diff --git a/x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts similarity index 83% rename from x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts rename to x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts index 90636e3c523a3..bea2ba21c0846 100644 --- a/x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IlmPutLifecycleRequest } from '@elastic/elasticsearch/api/types'; +import type { IlmPutLifecycleRequest } from '@elastic/elasticsearch/api/types'; export const reportingIlmPolicy: IlmPutLifecycleRequest['body'] = { policy: { diff --git a/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts new file mode 100644 index 0000000000000..ca0a74cae8726 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from 'src/core/server'; +import { ILM_POLICY_NAME } from '../../../../common/constants'; + +import { reportingIlmPolicy } from './constants'; + +/** + * Responsible for detecting and provisioning the reporting ILM policy. + * + * Uses the provided {@link ElasticsearchClient} to scope request privileges. + */ +export class IlmPolicyManager { + constructor(private readonly client: ElasticsearchClient) {} + + public static create(opts: { client: ElasticsearchClient }) { + return new IlmPolicyManager(opts.client); + } + + public async doesIlmPolicyExist(): Promise { + try { + await this.client.ilm.getLifecycle({ policy: ILM_POLICY_NAME }); + return true; + } catch (e) { + if (e.statusCode === 404) { + return false; + } + throw e; + } + } + + /** + * Create the Reporting ILM policy + */ + public async createIlmPolicy(): Promise { + await this.client.ilm.putLifecycle({ + policy: ILM_POLICY_NAME, + body: reportingIlmPolicy, + }); + } +} diff --git a/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts new file mode 100644 index 0000000000000..045a9ecb59997 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { reportingIlmPolicy } from './constants'; +export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts index 6b979325921a6..888918abbc344 100644 --- a/x-pack/plugins/reporting/server/lib/store/index.ts +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -8,3 +8,4 @@ export { ReportDocument } from '../../../common/types'; export { Report } from './report'; export { ReportingStore } from './store'; +export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 17c067a255b38..7a7dd20e1b25c 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -10,12 +10,15 @@ import { ElasticsearchClient } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; import { JobStatus } from '../../../common/types'; + +import { ILM_POLICY_NAME } from '../../../common/constants'; + import { ReportTaskParams } from '../tasks'; + +import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; -import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; - -import { reportingIlmPolicy } from './report_ilm_policy'; +import { IlmPolicyManager } from './ilm_policy_manager'; /* * When an instance of Kibana claims a report job, this information tells us about that instance @@ -92,6 +95,7 @@ export class ReportingStore { private readonly indexPrefix: string; // config setting of index prefix in system index name private readonly indexInterval: string; // config setting of index prefix: how often to poll for pending work private client?: ElasticsearchClient; + private ilmPolicyManager?: IlmPolicyManager; constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { const config = reportingCore.getConfig(); @@ -109,6 +113,15 @@ export class ReportingStore { return this.client; } + private async getIlmPolicyManager() { + if (!this.ilmPolicyManager) { + const client = await this.getClient(); + this.ilmPolicyManager = IlmPolicyManager.create({ client }); + } + + return this.ilmPolicyManager; + } + private async createIndex(indexName: string) { const client = await this.getClient(); const { body: exists } = await client.indices.exists({ index: indexName }); @@ -125,7 +138,7 @@ export class ReportingStore { number_of_shards: 1, auto_expand_replicas: '0-1', lifecycle: { - name: this.ilmPolicyName, + name: ILM_POLICY_NAME, }, }, mappings: { @@ -181,37 +194,19 @@ export class ReportingStore { return client.indices.refresh({ index }); } - private readonly ilmPolicyName = 'kibana-reporting'; - - private async doesIlmPolicyExist(): Promise { - const client = await this.getClient(); - try { - await client.ilm.getLifecycle({ policy: this.ilmPolicyName }); - return true; - } catch (e) { - if (e.statusCode === 404) { - return false; - } - throw e; - } - } - /** * Function to be called during plugin start phase. This ensures the environment is correctly * configured for storage of reports. */ public async start() { - const client = await this.getClient(); + const ilmPolicyManager = await this.getIlmPolicyManager(); try { - if (await this.doesIlmPolicyExist()) { - this.logger.debug(`Found ILM policy ${this.ilmPolicyName}; skipping creation.`); + if (await ilmPolicyManager.doesIlmPolicyExist()) { + this.logger.debug(`Found ILM policy ${ILM_POLICY_NAME}; skipping creation.`); return; } - this.logger.info(`Creating ILM policy for managing reporting indices: ${this.ilmPolicyName}`); - await client.ilm.putLifecycle({ - policy: this.ilmPolicyName, - body: reportingIlmPolicy, - }); + this.logger.info(`Creating ILM policy for managing reporting indices: ${ILM_POLICY_NAME}`); + await ilmPolicyManager.createIlmPolicy(); } catch (e) { this.logger.error('Error in start phase'); this.logger.error(e.body.error); @@ -446,4 +441,8 @@ export class ReportingStore { return body.hits?.hits[0] as ReportRecordTimeout; } + + public getReportingIndexPattern(): string { + return `${this.indexPrefix}-*`; + } } diff --git a/x-pack/plugins/reporting/server/routes/deprecations.ts b/x-pack/plugins/reporting/server/routes/deprecations.ts new file mode 100644 index 0000000000000..7a38faf60f6bb --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/deprecations.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { errors } from '@elastic/elasticsearch'; +import { + API_MIGRATE_ILM_POLICY_URL, + API_GET_ILM_POLICY_STATUS, + ILM_POLICY_NAME, +} from '../../common/constants'; +import { IlmPolicyStatusResponse } from '../../common/types'; +import { deprecations } from '../lib/deprecations'; +import { ReportingCore } from '../core'; +import { IlmPolicyManager, LevelLogger as Logger } from '../lib'; + +export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Logger) => { + const { router } = reporting.getPluginSetupDeps(); + + router.get( + { + path: API_GET_ILM_POLICY_STATUS, + validate: false, + }, + async ( + { + core: { + elasticsearch: { client: scopedClient }, + }, + }, + req, + res + ) => { + const checkIlmMigrationStatus = () => { + return deprecations.checkIlmMigrationStatus({ + reportingCore: reporting, + // We want to make the current status visible to all reporting users + elasticsearchClient: scopedClient.asInternalUser, + }); + }; + + try { + const response: IlmPolicyStatusResponse = { + status: await checkIlmMigrationStatus(), + }; + return res.ok({ body: response }); + } catch (e) { + return res.customError({ statusCode: e?.statusCode ?? 500, body: { message: e.message } }); + } + } + ); + + router.put( + { path: API_MIGRATE_ILM_POLICY_URL, validate: false }, + async ({ core: { elasticsearch } }, req, res) => { + const store = await reporting.getStore(); + const { + client: { asCurrentUser: client }, + } = elasticsearch; + + const scopedIlmPolicyManager = IlmPolicyManager.create({ + client, + }); + + // First we ensure that the reporting ILM policy exists in the cluster + try { + // We don't want to overwrite an existing reporting policy because it may contain alterations made by users + if (!(await scopedIlmPolicyManager.doesIlmPolicyExist())) { + await scopedIlmPolicyManager.createIlmPolicy(); + } + } catch (e) { + return res.customError({ statusCode: e?.statusCode ?? 500, body: { message: e.message } }); + } + + const indexPattern = store.getReportingIndexPattern(); + + // Second we migrate all of the existing indices to be managed by the reporting ILM policy + try { + await client.indices.putSettings({ + index: indexPattern, + body: { + 'index.lifecycle': { + name: ILM_POLICY_NAME, + }, + }, + }); + return res.ok(); + } catch (err) { + logger.error(err); + + if (err instanceof errors.ResponseError) { + // If there were no reporting indices to update, that's OK because then there is nothing to migrate + if (err.statusCode === 404) { + return res.ok(); + } + return res.customError({ + statusCode: err.statusCode ?? 500, + body: { + message: err.message, + name: err.name, + }, + }); + } + + throw err; + } + } + ); +}; diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index e061bd4f7d66c..a462da3849083 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -6,15 +6,17 @@ */ import { LevelLogger as Logger } from '../lib'; +import { registerDeprecationsRoutes } from './deprecations'; +import { registerDiagnosticRoutes } from './diagnostic'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; import { ReportingCore } from '../core'; -import { registerDiagnosticRoutes } from './diagnostic'; export function registerRoutes(reporting: ReportingCore, logger: Logger) { + registerDeprecationsRoutes(reporting, logger); + registerDiagnosticRoutes(reporting, logger); registerJobGenerationRoutes(reporting, logger); registerJobInfoRoutes(reporting); - registerDiagnosticRoutes(reporting, logger); } export interface ReportingRequestPre { diff --git a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 039424d34bfa1..fe3504c84115b 100644 --- a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -6,22 +6,56 @@ */ import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; +import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import { FieldMap } from './types'; +const NumberFromString = new t.Type( + 'NumberFromString', + (u): u is number => typeof u === 'number', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + const d = Number(s); + return isNaN(d) ? t.failure(u, c) : t.success(d); + }), + (a) => a +); + +const BooleanFromString = new t.Type( + 'BooleanFromString', + (u): u is boolean => typeof u === 'boolean', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + switch (s.toLowerCase().trim()) { + case '1': + case 'true': + case 'yes': + return t.success(true); + case '0': + case 'false': + case 'no': + case null: + return t.success(false); + default: + return t.failure(u, c); + } + }), + (a) => a +); + const esFieldTypeMap = { keyword: t.string, text: t.string, date: t.string, - boolean: t.boolean, - byte: t.number, - long: t.number, - integer: t.number, - short: t.number, - double: t.number, - float: t.number, - scaled_float: t.number, - unsigned_long: t.number, + boolean: t.union([t.number, BooleanFromString]), + byte: t.union([t.number, NumberFromString]), + long: t.union([t.number, NumberFromString]), + integer: t.union([t.number, NumberFromString]), + short: t.union([t.number, NumberFromString]), + double: t.union([t.number, NumberFromString]), + float: t.union([t.number, NumberFromString]), + scaled_float: t.union([t.number, NumberFromString]), + unsigned_long: t.union([t.number, NumberFromString]), flattened: t.record(t.string, t.array(t.string)), }; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 3fb32856a1ef1..abfcb4014a79f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -78,13 +78,6 @@ export enum SecurityPageName { eventFilters = 'event_filters', } -export enum SecurityPageGroupName { - detect = 'detect', - explore = 'explore', - investigate = 'investigate', - manage = 'manage', -} - export const TIMELINES_PATH = '/timelines'; export const CASES_PATH = '/cases'; export const OVERVIEW_PATH = '/overview'; diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index f4cddfe4d8da9..1b486ca3a5fcd 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -115,8 +115,42 @@ cd x-pack/plugins/security_solution CYPRESS_BASE_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_USERNAME= CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run:firefox ``` +#### CCS Custom Target + Headless + +This test execution requires two clusters configured for CCS. See [Search across clusters](https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cross-cluster-search.html) for instructions on how to prepare such setup. + +The instructions below assume: +* Search cluster is on server1 +* Remote cluster is on server2 +* Remote cluster is accessible from the search cluster with name `remote` +* Security and TLS are enabled + +```shell +# bootstrap Kibana from the project root +yarn kbn bootstrap + +# launch the Cypress test runner with overridden environment variables +cd x-pack/plugins/security_solution +CYPRESS_ELASTICSEARCH_USERNAME="user" \ +CYPRESS_ELASTICSEARCH_PASSWORD="pass" \ +CYPRESS_BASE_URL="https://user:pass@server1:5601" \ +CYPRESS_ELASTICSEARCH_URL="https://user:pass@server1:9200" \ +CYPRESS_CCS_KIBANA_URL="https://user:pass@server2:5601" \ +CYPRESS_CCS_ELASTICSEARCH_URL="https://user:pass@server2:9200" \ +CYPRESS_CCS_REMOTE_NAME="remote" \ +yarn cypress:run:ccs +``` + +Similar sequence, just ending with `yarn cypress:open:ccs`, can be used for interactive test running via Cypress UI. + +Appending `--browser firefox` to the `yarn cypress:run:ccs` command above will run the tests on Firefox instead of Chrome. + ## Folder Structure +### ccs_integration/ + +Contains the specs that are executed in a Cross Cluster Search configuration, typically during integration tests. + ### integration/ Cypress convention. Contains the specs that are going to be executed. @@ -174,7 +208,7 @@ Represents all the URLs used during the tests execution. The data the tests need: - Is generated on the fly using our application APIs (preferred way) -- Is ingested on the ELS instance using the `es_archive` utility +- Is ingested on the ELS instance using the `es_archiver` utility By default, when running the tests in Jenkins mode, a base set of data is ingested on the ELS instance: an empty kibana index and a set of auditbeat data (the `empty_kibana` and `auditbeat` archives, respectively). This is usually enough to cover most of the scenarios that we are testing. @@ -200,6 +234,52 @@ node ../../../scripts/es_archiver save custom_rules ".kibana",".siem-signal*" - Note that the command will create the folder if it does not exist. +### Using an archive from within the Cypress tests + +Task [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts) provides helpers such as `esArchiverLoad` and `esArchiverUnload` by means of `es_archiver`'s CLI. + +Because of `cy.exec`, used to invoke `es_archiver`, it's necessary to override its environment with `NODE_TLS_REJECT_UNAUTHORIZED=1`. It indeed would inject `NODE_TLS_REJECT_UNAUTHORIZED=0` and make `es_archive` otherwise abort with the following warning if used over https: + +> Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification. + +### CCS + +Tests running in CCS configuration need to care about two aspects: + +1. data (eg. to trigger alerts) is generated/loaded on the remote cluster +2. queries (eg. detection rules) refer to remote indices + +Incorrect handling of the above points might result in false positives, in that the remote cluster is not involved but the test passes anyway. + +#### Remote data loading + +Helpers `esArchiverCCSLoad` and `esArchiverCCSUnload` are provided by [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts): + +```javascript +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; +``` + +They will use the `CYPRESS_CCS_*_URL` environment variables for accessing the remote cluster. Complex tests involving local and remote data can interleave them with `esArchiverLoad` and `esArchiverUnload` as needed. + +#### Remote indices queries + +Queries accessing remote indices follow the usual `:` notation but should not hard-code the remote name in the test itself. + +For such reason the environemnt variable `CYPRESS_CCS_REMOTE_NAME` is defined and, in the case of detection rules, used as shown below: + +```javascript +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + ... +}; + +``` + +Similar approach should be used in defining all index patterns, rules, and queries to be applied on remote data. + ## Development Best Practices ### Clean up the state diff --git a/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts new file mode 100644 index 0000000000000..f87399a666904 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; + +import { + expandFirstAlert, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../../tasks/alerts'; +import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; + +import { unmappedCCSRule } from '../../objects/rule'; + +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Alert details with unmapped fields', () => { + beforeEach(() => { + cleanKibana(); + esArchiverCCSLoad('unmapped_fields'); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(unmappedCCSRule); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + expandFirstAlert(); + }); + + afterEach(() => { + esArchiverCCSUnload('unmapped_fields'); + }); + + it('Displays the unmapped field on the JSON view', () => { + const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + cy.wrap(elements) + .eq(length - expectedUnmappedField.line) + .should('have.text', expectedUnmappedField.text); + }); + }); + + it('Displays the unmapped field on the table', () => { + const expectedUnmmappedField = { + row: 55, + field: 'unmapped', + text: 'This is the unmapped field', + }; + + openTable(); + + cy.get(TABLE_ROWS) + .eq(expectedUnmmappedField.row) + .within(() => { + cy.get(CELL_TEXT).eq(0).should('have.text', expectedUnmmappedField.field); + cy.get(CELL_TEXT).eq(1).should('have.text', expectedUnmmappedField.text); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts new file mode 100644 index 0000000000000..fdc4bce677f74 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { newRule } from '../../objects/rule'; +import { RULES_MONIROTING_TABLE, RULE_NAME } from '../../screens/alerts_detection_rules'; +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana, reload } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Rules talbes links', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + goToManageAlertsDetectionRules(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(newRule, 'rule1'); + + reload(); + }); + + it('should render correct link for rule name - rules', () => { + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); + + it('should render correct link for rule name - rule monitoring', () => { + cy.get(RULES_MONIROTING_TABLE).first().click(); + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts index 28240bd12e4be..4a729ab5044ba 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts @@ -6,17 +6,20 @@ */ import { INSPECT_HOSTS_BUTTONS_IN_SECURITY, INSPECT_MODAL } from '../../screens/inspect'; +import { HOST_OVERVIEW } from '../../screens/hosts/main'; import { cleanKibana } from '../../tasks/common'; -import { closesModal, openStatsAndTables } from '../../tasks/inspect'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { clickInspectButton, closesModal, openStatsAndTables } from '../../tasks/inspect'; +import { loginAndWaitForHostDetailsPage, loginAndWaitForPage } from '../../tasks/login'; import { HOSTS_URL } from '../../urls/navigation'; describe('Inspect', () => { + before(() => { + cleanKibana(); + }); context('Hosts stats and tables', () => { before(() => { - cleanKibana(); loginAndWaitForPage(HOSTS_URL); }); afterEach(() => { @@ -30,4 +33,18 @@ describe('Inspect', () => { }) ); }); + + context('Hosts details', () => { + before(() => { + loginAndWaitForHostDetailsPage(); + }); + afterEach(() => { + closesModal(); + }); + + it(`inspects the host details`, () => { + clickInspectButton(HOST_OVERVIEW); + cy.get(INSPECT_MODAL).should('be.visible'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts index 3b1df67bec29c..cdccb6d75d18c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts @@ -71,7 +71,7 @@ describe('Display not found page', () => { cy.get(NOT_FOUND).should('exist'); }); - it('navigates to the trusted applications page with incorrect link', () => { + it('navigates to the event filters page with incorrect link', () => { loginAndWaitForPage(`${EVENT_FILTERS_URL}/randomUrl`); cy.get(NOT_FOUND).should('exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 9a8626f2a0d7d..3383ef4996ead 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -16,6 +16,8 @@ export const totalNumberOfPrebuiltRulesInEsArchive = 127; export const totalNumberOfPrebuiltRulesInEsArchiveCustomRule = 145; +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + interface MitreAttackTechnique { name: string; subtechniques: string[]; @@ -198,6 +200,24 @@ export const unmappedRule: CustomRule = { maxSignals: 100, }; +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + name: 'Rule with unmapped fields', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + runsEvery, + lookBack, + timeline, + maxSignals: 100, +}; + export const existingRule: CustomRule = { customQuery: 'host.name: *', name: 'Rule 1', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index ba071184d98eb..0bf0e5a09e328 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -72,6 +72,8 @@ export const RULES_TABLE = '[data-test-subj="rules-table"]'; export const RULES_ROW = '.euiTableRow'; +export const RULES_MONIROTING_TABLE = '[data-test-subj="allRulesTableTab-monitoring"]'; + export const SEVENTH_RULE = 6; export const SEVERITY = '[data-test-subj="severity"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts index f8b20393ba110..95381b06f44e9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts @@ -16,3 +16,5 @@ export const EVENTS_TAB = '[data-test-subj="navigation-events"]'; export const KQL_SEARCH_BAR = '[data-test-subj="queryInput"]'; export const UNCOMMON_PROCESSES_TAB = '[data-test-subj="navigation-uncommonProcesses"]'; + +export const HOST_OVERVIEW = `[data-test-subj="host-overview"]`; diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index a0ee5bda82b01..83ec1536baf0f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -11,24 +11,47 @@ const ES_ARCHIVE_DIR = '../../test/security_solution_cypress/es_archives'; const CONFIG_PATH = '../../test/functional/config.js'; const ES_URL = Cypress.env('ELASTICSEARCH_URL'); const KIBANA_URL = Cypress.config().baseUrl; +const CCS_ES_URL = Cypress.env('CCS_ELASTICSEARCH_URL'); +const CCS_KIBANA_URL = Cypress.env('CCS_KIBANA_URL'); + +// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +const NODE_TLS_REJECT_UNAUTHORIZED = '1'; export const esArchiverLoad = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"` + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverUnload = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"` + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverResetKibana = () => { cy.exec( `node ../../../scripts/es_archiver empty-kibana-index --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, - { failOnNonZeroExit: false } + { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } + ); +}; + +export const esArchiverCCSLoad = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; + +export const esArchiverCCSUnload = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/inspect.ts b/x-pack/plugins/security_solution/cypress/tasks/inspect.ts index d056e012bb7f8..112bf9a208b39 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/inspect.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/inspect.ts @@ -11,6 +11,10 @@ export const closesModal = () => { cy.get('[data-test-subj="modal-inspect-close"]').click(); }; +export const clickInspectButton = (container: string) => { + cy.get(`${container} ${INSPECT_BUTTON_ICON}`).trigger('click', { force: true }); +}; + export const openStatsAndTables = (table: InspectButtonMetadata) => { if (table.tabId) { cy.get(table.tabId).click({ force: true }); @@ -21,6 +25,6 @@ export const openStatsAndTables = (table: InspectButtonMetadata) => { force: true, }); } else { - cy.get(`${table.id} ${INSPECT_BUTTON_ICON}`).trigger('click', { force: true }); + clickInspectButton(table.id); } }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index be447993273fb..243bfd113bfd2 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -10,6 +10,7 @@ import Url, { UrlObject } from 'url'; import { ROLES } from '../../common/test'; import { TIMELINE_FLYOUT_BODY } from '../screens/timeline'; +import { hostDetailsUrl } from '../urls/navigation'; /** * Credentials in the `kibana.dev.yml` config file will be used to authenticate @@ -312,6 +313,11 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { cy.get(TIMELINE_FLYOUT_BODY).should('be.visible'); }; +export const loginAndWaitForHostDetailsPage = () => { + loginAndWaitForPage(hostDetailsUrl('suricata-iowa')); + cy.get('[data-test-subj="loading-spinner"]', { timeout: 12000 }).should('not.exist'); +}; + export const waitForPageWithoutDateRange = (url: string, role?: ROLES) => { cy.visit(role ? getUrlWithRoute(role, url) : url); cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index 304db7e93e2cb..a9fad865f506c 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -23,6 +23,8 @@ export const SECURITY_DETECTIONS_RULES_CREATION_URL = '/app/security/detections/ export const EXCEPTIONS_URL = 'app/security/exceptions'; export const HOSTS_URL = '/app/security/hosts/allHosts'; +export const hostDetailsUrl = (hostName: string) => + `/app/security/hosts/${hostName}/authentications`; export const HOSTS_PAGE_TAB_URLS = { allHosts: '/app/security/hosts/allHosts', anomalies: '/app/security/hosts/anomalies', diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 104c6120ecb39..5362454d3b46b 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -9,10 +9,12 @@ "build-beat-doc": "node scripts/beat_docs/build.js && node ../../../scripts/eslint ./server/utils/beat_schema/fields.ts --fix", "cypress": "../../../node_modules/.bin/cypress", "cypress:open": "yarn cypress open --config-file ./cypress/cypress.json", + "cypress:open:ccs": "yarn cypress:open --config integrationFolder=./cypress/ccs_integration", "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:run": "yarn cypress:run:reporter --browser chrome --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", + "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "junit:merge": "../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json && ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results && mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/", diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index affed45908fdd..e1c14f2a86380 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -26,7 +26,7 @@ import { NETWORK, TIMELINES, CASE, - ADMINISTRATION, + MANAGE, } from '../translations'; import { OVERVIEW_PATH, @@ -116,12 +116,12 @@ export const topDeepLinks: AppDeepLink[] = [ }, { id: SecurityPageName.administration, - title: ADMINISTRATION, + title: MANAGE, path: ENDPOINTS_PATH, navLinkStatus: AppNavLinkStatus.hidden, keywords: [ - i18n.translate('xpack.securitySolution.search.administration', { - defaultMessage: 'Administration', + i18n.translate('xpack.securitySolution.search.manage', { + defaultMessage: 'Manage', }), ], }, @@ -179,28 +179,28 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.authentications', { defaultMessage: 'Authentications', }), - path: '/authentications', + path: `${HOSTS_PATH}/authentications`, }, { id: 'uncommonProcesses', title: i18n.translate('xpack.securitySolution.search.hosts.uncommonProcesses', { defaultMessage: 'Uncommon Processes', }), - path: '/uncommonProcesses', + path: `${HOSTS_PATH}/uncommonProcesses`, }, { id: 'events', title: i18n.translate('xpack.securitySolution.search.hosts.events', { defaultMessage: 'Events', }), - path: '/events', + path: `${HOSTS_PATH}/events`, }, { id: 'externalAlerts', title: i18n.translate('xpack.securitySolution.search.hosts.externalAlerts', { defaultMessage: 'External Alerts', }), - path: '/alerts', + path: `${HOSTS_PATH}/alerts`, }, ], premium: [ @@ -209,7 +209,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.anomalies', { defaultMessage: 'Anomalies', }), - path: '/anomalies', + path: `${HOSTS_PATH}/anomalies`, }, ], }, @@ -220,28 +220,28 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.network.dns', { defaultMessage: 'DNS', }), - path: '/dns', + path: `${NETWORK_PATH}/dns`, }, { id: 'http', title: i18n.translate('xpack.securitySolution.search.network.http', { defaultMessage: 'HTTP', }), - path: '/http', + path: `${NETWORK_PATH}/http`, }, { id: 'tls', title: i18n.translate('xpack.securitySolution.search.network.tls', { defaultMessage: 'TLS', }), - path: '/tls', + path: `${NETWORK_PATH}/tls`, }, { id: 'externalAlertsNetwork', title: i18n.translate('xpack.securitySolution.search.network.externalAlerts', { defaultMessage: 'External Alerts', }), - path: '/external-alerts', + path: `${NETWORK_PATH}/external-alerts`, }, ], premium: [ @@ -250,7 +250,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.anomalies', { defaultMessage: 'Anomalies', }), - path: '/anomalies', + path: `${NETWORK_PATH}/anomalies`, }, ], }, @@ -261,7 +261,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.timeline.templates', { defaultMessage: 'Templates', }), - path: '/template', + path: `${TIMELINES_PATH}/template`, }, ], }, @@ -272,7 +272,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.cases.create', { defaultMessage: 'Create New Case', }), - path: '/create', + path: `${CASES_PATH}/create`, }, ], premium: [ @@ -281,7 +281,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.cases.configure', { defaultMessage: 'Configure Cases', }), - path: '/configure', + path: `${CASES_PATH}/configure`, }, ], }, @@ -377,12 +377,13 @@ export function updateGlobalNavigation({ const deepLinks = getDeepLinks(undefined, capabilities); const updatedDeepLinks = deepLinks.map((link) => { switch (link.id) { - case 'case': + case SecurityPageName.case: return { ...link, navLinkStatus: capabilities.siem.read_cases ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: capabilities.siem.read_cases === true, }; default: return link; @@ -390,6 +391,7 @@ export function updateGlobalNavigation({ }); updater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent showing main nav link deepLinks: updatedDeepLinks, })); } diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 271eea47840dc..d6f8516d43a72 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -6,8 +6,11 @@ */ import * as i18n from '../translations'; -import { SecurityPageName, SecurityPageGroupName } from '../types'; -import { SiemNavTab, NavTabGroups } from '../../common/components/navigation/types'; +import { + SecurityNav, + SecurityNavGroup, + SecurityNavGroupKey, +} from '../../common/components/navigation/types'; import { APP_OVERVIEW_PATH, APP_RULES_PATH, @@ -21,9 +24,10 @@ import { APP_ENDPOINTS_PATH, APP_TRUSTED_APPS_PATH, APP_EVENT_FILTERS_PATH, + SecurityPageName, } from '../../../common/constants'; -export const navTabs: SiemNavTab = { +export const navTabs: SecurityNav = { [SecurityPageName.overview]: { id: SecurityPageName.overview, name: i18n.OVERVIEW, @@ -36,21 +40,21 @@ export const navTabs: SiemNavTab = { name: i18n.ALERTS, href: APP_ALERTS_PATH, disabled: false, - urlKey: SecurityPageName.alerts, + urlKey: 'alerts', }, [SecurityPageName.rules]: { id: SecurityPageName.rules, name: i18n.RULES, href: APP_RULES_PATH, disabled: false, - urlKey: SecurityPageName.rules, + urlKey: 'rules', }, [SecurityPageName.exceptions]: { id: SecurityPageName.exceptions, name: i18n.EXCEPTIONS, href: APP_EXCEPTIONS_PATH, disabled: false, - urlKey: SecurityPageName.exceptions, + urlKey: 'exceptions', }, [SecurityPageName.hosts]: { id: SecurityPageName.hosts, @@ -85,46 +89,46 @@ export const navTabs: SiemNavTab = { name: i18n.ADMINISTRATION, href: APP_MANAGEMENT_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.endpoints]: { id: SecurityPageName.endpoints, name: i18n.ENDPOINTS, href: APP_ENDPOINTS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.trustedApps]: { id: SecurityPageName.trustedApps, name: i18n.TRUSTED_APPLICATIONS, href: APP_TRUSTED_APPS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.eventFilters]: { id: SecurityPageName.eventFilters, name: i18n.EVENT_FILTERS, href: APP_EVENT_FILTERS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, }; -export const navTabGroups: NavTabGroups = { - [SecurityPageGroupName.detect]: { - id: SecurityPageGroupName.detect, +export const securityNavGroup: SecurityNavGroup = { + [SecurityNavGroupKey.detect]: { + id: SecurityNavGroupKey.detect, name: i18n.DETECT, }, - [SecurityPageGroupName.explore]: { - id: SecurityPageGroupName.explore, + [SecurityNavGroupKey.explore]: { + id: SecurityNavGroupKey.explore, name: i18n.EXPLORE, }, - [SecurityPageGroupName.investigate]: { - id: SecurityPageGroupName.investigate, + [SecurityNavGroupKey.investigate]: { + id: SecurityNavGroupKey.investigate, name: i18n.INVESTIGATE, }, - [SecurityPageGroupName.manage]: { - id: SecurityPageGroupName.manage, + [SecurityNavGroupKey.manage]: { + id: SecurityNavGroupKey.manage, name: i18n.MANAGE, }, }; diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index d16c35a832e6b..e7f825691c58d 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -14,12 +14,14 @@ import { SecuritySolutionAppWrapper } from '../../common/components/page'; import { HelpMenu } from '../../common/components/help_menu'; import { UseUrlState } from '../../common/components/url_state'; import { navTabs } from './home_navigations'; -import { useInitSourcerer, useSourcererScope } from '../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { + useInitSourcerer, + useSourcererScope, + getScopeFromPath, +} from '../../common/containers/sourcerer'; import { useUpgradeSecurityPackages } from '../../common/hooks/use_upgrade_security_packages'; import { GlobalHeader } from './global_header'; import { SecuritySolutionTemplateWrapper } from './template_wrapper'; -import { isDetectionsPath } from '../../helpers'; interface HomePageProps { children: React.ReactNode; @@ -34,13 +36,9 @@ const HomePageComponent: React.FC = ({ }) => { const { pathname } = useLocation(); - useInitSourcerer( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + useInitSourcerer(getScopeFromPath(pathname)); - const { browserFields, indexPattern } = useSourcererScope( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + const { browserFields, indexPattern } = useSourcererScope(getScopeFromPath(pathname)); // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx index a2f1ed8c115d6..eb606cd8ff583 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx @@ -12,12 +12,10 @@ import { useLocation } from 'react-router-dom'; import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; import { AppLeaveHandler } from '../../../../../../../../src/core/public'; import { useShowTimeline } from '../../../../common/utils/timeline/use_show_timeline'; -import { useSourcererScope } from '../../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { useSourcererScope, getScopeFromPath } from '../../../../common/containers/sourcerer'; import { TimelineId } from '../../../../../common/types/timeline'; import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning'; import { Flyout } from '../../../../timelines/components/flyout'; -import { isDetectionsPath } from '../../../../../public/helpers'; export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar'; @@ -27,9 +25,7 @@ export const SecuritySolutionBottomBar = React.memo( const [showTimeline] = useShowTimeline(); - const { indicesExist } = useSourcererScope( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + const { indicesExist } = useSourcererScope(getScopeFromPath(pathname)); return indicesExist && showTimeline ? ( <> diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index 62a61828830be..8056c4092091c 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -36,7 +36,7 @@ import { Immutable } from '../../common/endpoint/types'; import { AppAction } from '../common/store/actions'; import { TimelineState } from '../timelines/store/timeline/types'; import { SecurityPageName } from '../../common/constants'; -export { SecurityPageName, SecurityPageGroupName } from '../../common/constants'; +export { SecurityPageName } from '../../common/constants'; export interface SecuritySubPluginStore { initialState: Record; diff --git a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx index 13a549b6babc9..c5ed3454f1ca5 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx @@ -19,7 +19,6 @@ import { CaseHeaderPage } from '../components/case_header_page'; import { WhitePageWrapper, SectionWrapper } from '../components/wrappers'; import * as i18n from './translations'; import { APP_ID } from '../../../common/constants'; -import { SiemNavTabKey } from '../../common/components/navigation/types'; const ConfigureCasesPageComponent: React.FC = () => { const { @@ -31,9 +30,9 @@ const ConfigureCasesPageComponent: React.FC = () => { const backOptions = useMemo( () => ({ - href: getCaseUrl(search), + path: getCaseUrl(search), text: i18n.BACK_TO_ALL, - pageId: SecurityPageName.case as SiemNavTabKey, + pageId: SecurityPageName.case, }), [search] ); diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index e46e5c2074f05..4a59fe3fdcabd 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -18,7 +18,6 @@ import { CaseHeaderPage } from '../components/case_header_page'; import { Create } from '../components/create'; import * as i18n from './translations'; import { APP_ID } from '../../../common/constants'; -import { SiemNavTabKey } from '../../common/components/navigation/types'; export const CreateCasePage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); @@ -29,9 +28,9 @@ export const CreateCasePage = React.memo(() => { const backOptions = useMemo( () => ({ - href: getCaseUrl(search), + path: getCaseUrl(search), text: i18n.BACK_TO_ALL, - pageId: SecurityPageName.case as SiemNavTabKey, + pageId: SecurityPageName.case, }), [search] ); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx index a5e0c90402df4..ebd25eef87cb7 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx @@ -9,8 +9,6 @@ import React, { memo, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { AppLocation } from '../../../../common/endpoint/types'; -import { timelineActions } from '../../../timelines/store/timeline'; -import { TimelineId } from '../../../../../timelines/common'; /** * This component should be used above all routes, but below the Provider. @@ -20,10 +18,6 @@ export const RouteCapture = memo(({ children }) => { const location: AppLocation = useLocation(); const dispatch = useDispatch(); - useEffect(() => { - dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); - }, [dispatch, location.pathname]); - useEffect(() => { dispatch({ type: 'userChangedUrl', payload: location }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 90a4e67d76b99..ccba97f6a7942 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -179,7 +179,7 @@ describe('EventsViewer', () => { mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); }); - test('it renders the "Showing..." subtitle with the expected event count', () => { + test('it renders the "Showing..." subtitle with the expected event count by default', () => { const wrapper = mount( @@ -190,6 +190,19 @@ describe('EventsViewer', () => { ); }); + test('should not render the "Showing..." subtitle with the expected event count if showTotalCount is set to false ', () => { + const disableSubTitle = { + ...eventsViewerDefaultProps, + showTotalCount: false, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual(''); + }); + test('it renders the Fields Browser as a settings gear', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index c2f170c58043d..b8b6b9766bdde 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -135,6 +135,7 @@ interface Props { rowRenderers: RowRenderer[]; start: string; sort: Sort[]; + showTotalCount?: boolean; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -163,6 +164,7 @@ const EventsViewerComponent: React.FC = ({ rowRenderers, start, sort, + showTotalCount = true, utilityBar, graphEventId, }) => { @@ -253,8 +255,12 @@ const EventsViewerComponent: React.FC = ({ const subtitle = useMemo( () => - `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, - [totalCountMinusDeleted, unit] + showTotalCount + ? `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( + totalCountMinusDeleted + )}` + : null, + [showTotalCount, totalCountMinusDeleted, unit] ); const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 32aa716d4bce3..bfc14a0f0c680 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -10,6 +10,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; +import { isEmpty } from 'lodash/fp'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -40,6 +41,7 @@ export interface OwnProps { id: TimelineId; scopeId: SourcererScopeName; start: string; + showTotalCount?: boolean; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; onRuleChange?: () => void; @@ -176,6 +178,7 @@ const StatefulEventsViewerComponent: React.FC = ({ rowRenderers={rowRenderers} start={start} sort={sort} + showTotalCount={isEmpty(graphEventId) ? true : false} utilityBar={utilityBar} graphEventId={graphEventId} /> diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx index 2c42353daee75..47b0871229864 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx @@ -41,7 +41,7 @@ describe('HeaderPage', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index dc8e19249b6be..dea19e1366875 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -12,7 +12,7 @@ import { EuiPageHeaderSection, EuiSpacer, } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { LinkIcon, LinkIconProps } from '../link_icon'; @@ -24,8 +24,6 @@ import { SecurityPageName } from '../../../app/types'; import { Sourcerer } from '../sourcerer'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useKibana } from '../../lib/kibana'; -import { SiemNavTabKey } from '../navigation/types'; - interface HeaderProps { border?: boolean; isLoading?: boolean; @@ -65,10 +63,10 @@ const HeaderSection = styled(EuiPageHeaderSection)` HeaderSection.displayName = 'HeaderSection'; interface BackOptions { + pageId: SecurityPageName; text: LinkIconProps['children']; - href?: LinkIconProps['href']; + path?: string; dataTestSubj?: string; - pageId: SiemNavTabKey; } export interface HeaderPageProps extends HeaderProps { @@ -85,6 +83,29 @@ export interface HeaderPageProps extends HeaderProps { titleNode?: React.ReactElement; } +const HeaderLinkBack: React.FC<{ backOptions: BackOptions }> = React.memo(({ backOptions }) => { + const { navigateToUrl } = useKibana().services.application; + const { formatUrl } = useFormatUrl(backOptions.pageId); + + const backUrl = formatUrl(backOptions.path ?? ''); + return ( + + { + ev.preventDefault(); + navigateToUrl(backUrl); + }} + href={backUrl} + iconType="arrowLeft" + > + {backOptions.text} + + + ); +}); +HeaderLinkBack.displayName = 'HeaderLinkBack'; + const HeaderPageComponent: React.FC = ({ backOptions, backComponent, @@ -99,62 +120,36 @@ const HeaderPageComponent: React.FC = ({ title, titleNode, ...rest -}) => { - const { navigateToUrl } = useKibana().services.application; +}) => ( + <> + + + {backOptions && } + {!backOptions && backComponent && <>{backComponent}} - const { formatUrl } = useFormatUrl(backOptions?.pageId ?? SecurityPageName.overview); - const backUrl = formatUrl(backOptions?.href ?? ''); - const goTo = useCallback( - (ev) => { - ev.preventDefault(); - if (backOptions) { - navigateToUrl(backUrl); - } - }, - [backOptions, navigateToUrl, backUrl] - ); - return ( - <> - - - {backOptions && ( - - - {backOptions.text} - - - )} - - {!backOptions && backComponent && <>{backComponent}} - {titleNode || ( - - )} + {titleNode || ( + <Title + draggableArguments={draggableArguments} + title={title} + badgeOptions={badgeOptions} + /> + )} - {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} - {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} - {border && isLoading && <EuiProgress size="xs" color="accent" />} - </HeaderSection> + {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} + {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} + {border && isLoading && <EuiProgress size="xs" color="accent" />} + </HeaderSection> - {children && ( - <EuiPageHeaderSection data-test-subj="header-page-supplements"> - {children} - </EuiPageHeaderSection> - )} - {!hideSourcerer && <Sourcerer scope={SourcererScopeName.default} />} - </EuiPageHeader> - {/* Manually add a 'padding-bottom' to header */} - <EuiSpacer size="l" /> - </> - ); -}; + {children && ( + <EuiPageHeaderSection data-test-subj="header-page-supplements"> + {children} + </EuiPageHeaderSection> + )} + {!hideSourcerer && <Sourcerer scope={SourcererScopeName.default} />} + </EuiPageHeader> + {/* Manually add a 'padding-bottom' to header */} + <EuiSpacer size="l" /> + </> +); export const HeaderPage = React.memo(HeaderPageComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index b7defcc8c2af9..6681ee2cb7e8f 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -9,9 +9,9 @@ import { isEmpty } from 'lodash/fp'; import { useCallback } from 'react'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { navTabs } from '../../../app/home/home_navigations'; -import { APP_ID } from '../../../../common/constants'; -import { useKibana } from '../../lib/kibana'; -import { SiemNavTabKey } from '../navigation/types'; +import { useAppUrl } from '../../lib/kibana/hooks'; +import { SecurityNavKey } from '../navigation/types'; +import { SecurityPageName } from '../../../app/types'; export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; @@ -33,9 +33,11 @@ interface FormatUrlOptions { export type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string; -export const useFormatUrl = (page: SiemNavTabKey) => { - const { getUrlForApp } = useKibana().services.application; - const search = useGetUrlSearch(navTabs[page]); +export const useFormatUrl = (page: SecurityPageName) => { + const { getAppUrl } = useAppUrl(); + const tab = page in navTabs ? navTabs[page as SecurityNavKey] : undefined; + const search = useGetUrlSearch(tab); + const formatUrl = useCallback<FormatUrl>( (path: string, { absolute = false, skipSearch = false } = {}) => { const pathArr = path.split('?'); @@ -48,9 +50,9 @@ export const useFormatUrl = (page: SiemNavTabKey) => { ? '' : `?${pathArr[1]}` }`; - return getUrlForApp(APP_ID, { deepLinkId: page, path: formattedPath, absolute }); + return getAppUrl({ deepLinkId: page, path: formattedPath, absolute }); }, - [getUrlForApp, page, search] + [getAppUrl, page, search] ); return { formatUrl, search }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 1b1b3c9af4bfc..e147e8b7fc958 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -6,7 +6,7 @@ */ import { UrlStateType } from '../url_state/constants'; -import { SecurityPageName, SecurityPageGroupName } from '../../../app/types'; +import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; @@ -27,15 +27,14 @@ export interface NavGroupTab { id: string; name: string; } +export enum SecurityNavGroupKey { + detect = 'detect', + explore = 'explore', + investigate = 'investigate', + manage = 'manage', +} -export type SecurityNavTabGroupKey = - | SecurityPageGroupName.detect - | SecurityPageGroupName.explore - | SecurityPageGroupName.investigate - | SecurityPageGroupName.manage; - -export type NavTabGroups = Record<SecurityNavTabGroupKey, NavGroupTab>; - +export type SecurityNavGroup = Record<SecurityNavGroupKey, NavGroupTab>; export interface NavTab { id: string; name: string; @@ -44,8 +43,7 @@ export interface NavTab { urlKey?: UrlStateType; pageId?: SecurityPageName; } - -export type SiemNavTabKey = +export type SecurityNavKey = | SecurityPageName.overview | SecurityPageName.hosts | SecurityPageName.network @@ -59,7 +57,7 @@ export type SiemNavTabKey = | SecurityPageName.trustedApps | SecurityPageName.eventFilters; -export type SiemNavTab = Record<SiemNavTabKey, NavTab>; +export type SecurityNav = Record<SecurityNavKey, NavTab>; export type GetUrlForApp = ( appId: string, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx index 011378db1b31c..258dad531837a 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx @@ -12,9 +12,9 @@ import { makeMapStateToProps } from '../url_state/helpers'; import { getSearch } from './helpers'; import { SearchNavTab } from './types'; -export const useGetUrlSearch = (tab: SearchNavTab) => { +export const useGetUrlSearch = (tab?: SearchNavTab) => { const mapState = makeMapStateToProps(); const { urlState } = useDeepEqualSelector(mapState); - const urlSearch = useMemo(() => getSearch(tab, urlState), [tab, urlState]); + const urlSearch = useMemo(() => (tab ? getSearch(tab, urlState) : ''), [tab, urlState]); return urlSearch; }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 47ffb20c21d5a..e3549aa6ec047 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -7,7 +7,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; -import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { useKibana } from '../../../lib/kibana/kibana_react'; +import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { SecurityPageName } from '../../../../app/types'; import { useSecuritySolutionNavigation } from '.'; import { CONSTANTS } from '../../url_state/constants'; @@ -16,6 +17,7 @@ import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { UrlInputsModel } from '../../../store/inputs/model'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; +jest.mock('../../../lib/kibana/kibana_react'); jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_selector'); jest.mock('../../../utils/route/use_route_spy'); @@ -94,7 +96,7 @@ describe('useSecuritySolutionNavigation', () => { "icon": "logoSecurity", "items": Array [ Object { - "id": "securitySolution", + "id": "main", "items": Array [ Object { "data-href": "securitySolution/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index e04ec7727a08f..fffe59fceff41 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -7,11 +7,11 @@ import React, { useCallback, useMemo } from 'react'; import { EuiSideNavItemType } from '@elastic/eui/src/components/side_nav/side_nav_types'; -import { navTabGroups } from '../../../../app/home/home_navigations'; -import { APP_ID } from '../../../../../common/constants'; +import { securityNavGroup } from '../../../../app/home/home_navigations'; import { getSearch } from '../helpers'; import { PrimaryNavigationItemsProps } from './types'; -import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { useGetUserCasesPermissions } from '../../../lib/kibana'; +import { useNavigation } from '../../../lib/kibana/hooks'; import { NavTab } from '../types'; export const usePrimaryNavigationItems = ({ @@ -19,7 +19,7 @@ export const usePrimaryNavigationItems = ({ selectedTabId, ...urlStateProps }: PrimaryNavigationItemsProps): Array<EuiSideNavItemType<{}>> => { - const { navigateToApp, getUrlForApp } = useKibana().services.application; + const { navigateTo, getAppUrl } = useNavigation(); const getSideNav = useCallback( (tab: NavTab) => { @@ -29,10 +29,10 @@ export const usePrimaryNavigationItems = ({ const handleClick = (ev: React.MouseEvent) => { ev.preventDefault(); - navigateToApp(APP_ID, { deepLinkId: id, path: urlSearch }); + navigateTo({ deepLinkId: id, path: urlSearch }); }; - const appHref = getUrlForApp(APP_ID, { deepLinkId: id, path: urlSearch }); + const appHref = getAppUrl({ deepLinkId: id, path: urlSearch }); return { 'data-href': appHref, @@ -45,7 +45,7 @@ export const usePrimaryNavigationItems = ({ onClick: handleClick, }; }, - [getUrlForApp, navigateToApp, selectedTabId, urlStateProps] + [getAppUrl, navigateTo, selectedTabId, urlStateProps] ); const navItemsToDisplay = usePrimaryNavigationItemsToDisplay(navTabs); @@ -63,27 +63,30 @@ export const usePrimaryNavigationItems = ({ function usePrimaryNavigationItemsToDisplay(navTabs: Record<string, NavTab>) { const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - return [ - { - id: APP_ID, - name: '', - items: [navTabs.overview], - }, - { - ...navTabGroups.detect, - items: [navTabs.alerts, navTabs.rules, navTabs.exceptions], - }, - { - ...navTabGroups.explore, - items: [navTabs.hosts, navTabs.network], - }, - { - ...navTabGroups.investigate, - items: hasCasesReadPermissions ? [navTabs.timelines, navTabs.case] : [navTabs.timelines], - }, - { - ...navTabGroups.manage, - items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], - }, - ]; + return useMemo( + () => [ + { + id: 'main', + name: '', + items: [navTabs.overview], + }, + { + ...securityNavGroup.detect, + items: [navTabs.alerts, navTabs.rules, navTabs.exceptions], + }, + { + ...securityNavGroup.explore, + items: [navTabs.hosts, navTabs.network], + }, + { + ...securityNavGroup.investigate, + items: hasCasesReadPermissions ? [navTabs.timelines, navTabs.case] : [navTabs.timelines], + }, + { + ...securityNavGroup.manage, + items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], + }, + ], + [navTabs, hasCasesReadPermissions] + ); } diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx index b40799895e8a2..18b99adca3a55 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx @@ -59,6 +59,14 @@ jest.mock('../../lib/kibana', () => ({ }, })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx index e178aba188d11..3175656f12071 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx @@ -31,6 +31,14 @@ jest.mock('../../lib/kibana', () => ({ }), })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer - lodash.throttle mocked to test update url', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 487463dfd9d7d..87e17ba7691cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -9,6 +9,7 @@ import { difference, isEmpty } from 'lodash/fp'; import { useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { useKibana } from '../../lib/kibana'; import { CONSTANTS, UrlStateType } from './constants'; import { @@ -31,6 +32,8 @@ import { UrlState, } from './types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../../timelines/common'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef<PreviousLocationUrlState>(value); @@ -71,6 +74,7 @@ export const useUrlStateHooks = ({ const [isInitializing, setIsInitializing] = useState(true); const { filterManager, savedQueries } = useKibana().services.data.query; const prevProps = usePrevious({ pathName, pageName, urlState }); + const dispatch = useDispatch(); const handleInitialize = (type: UrlStateType, needUpdate?: boolean) => { let mySearch = search; @@ -222,9 +226,10 @@ export const useUrlStateHooks = ({ }); } else if (pathName !== prevProps.pathName) { handleInitialize(type, isDetectionsPages(pageName)); + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitializing, history, pathName, pageName, prevProps, urlState]); + }, [isInitializing, history, pathName, pageName, prevProps, urlState, dispatch]); useEffect(() => { document.title = `${getTitle(pageName, detailName, navTabs)} - Kibana`; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index 702a532949428..ae2e509a7d94e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; import { Provider } from 'react-redux'; -import { useInitSourcerer } from '.'; +import { getScopeFromPath, useInitSourcerer } from '.'; import { mockPatterns } from './mocks'; // import { SourcererScopeName } from '../../store/sourcerer/model'; import { RouteSpyState } from '../../utils/route/types'; @@ -180,3 +180,18 @@ describe('Sourcerer Hooks', () => { }); }); }); + +describe('getScopeFromPath', () => { + it('should return default scope', async () => { + expect(getScopeFromPath('/')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/exceptions')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/rules')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/rules/create')).toBe(SourcererScopeName.default); + }); + + it('should return detections scope', async () => { + expect(getScopeFromPath('/alerts')).toBe(SourcererScopeName.detections); + expect(getScopeFromPath('/rules/id/foo')).toBe(SourcererScopeName.detections); + expect(getScopeFromPath('/rules/id/foo/edit')).toBe(SourcererScopeName.detections); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index 8cc075de324a2..002c40fc9d428 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -8,11 +8,13 @@ import { useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; +import { matchPath } from 'react-router-dom'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIndexFields } from '../source'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; +import { ALERTS_PATH, RULES_PATH } from '../../../../common/constants'; import { TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../hooks/use_selector'; @@ -125,3 +127,14 @@ export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName const SourcererScope = useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); return SourcererScope; }; + +export const getScopeFromPath = ( + pathname: string +): SourcererScopeName.default | SourcererScopeName.detections => { + return matchPath(pathname, { + path: [ALERTS_PATH, `${RULES_PATH}/id/:id`], + strict: false, + }) == null + ? SourcererScopeName.default + : SourcererScopeName.detections; +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 1b05c6a857263..b9bbf7afd3626 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -15,6 +15,7 @@ import { set } from '@elastic/safer-lodash-set'; import { APP_ID, DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; +import { NavigateToAppOptions } from '../../../../../../../src/core/public'; import { StartServices } from '../../../types'; import { useUiSetting, useKibana } from './kibana_react'; @@ -169,8 +170,15 @@ export const useAppUrl = () => { const { getUrlForApp } = useKibana().services.application; const getAppUrl = useCallback( - ({ appId = APP_ID, ...options }: { appId?: string; deepLinkId?: string; path?: string }) => - getUrlForApp(appId, options), + ({ + appId = APP_ID, + ...options + }: { + appId?: string; + deepLinkId?: string; + path?: string; + absolute?: boolean; + }) => getUrlForApp(appId, options), [getUrlForApp] ); return { getAppUrl }; @@ -191,9 +199,7 @@ export const useNavigateTo = () => { }: { url?: string; appId?: string; - deepLinkId?: string; - path?: string; - }) => { + } & NavigateToAppOptions) => { if (url) { navigateToUrl(url); } else { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx index 31d1ce6d41153..70101021bc4f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx @@ -27,8 +27,8 @@ jest.mock('react-router-dom', () => { }); const mockNavigateToApp = jest.fn(); -jest.mock('../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../common/lib/kibana/kibana_react'); return { ...original, @@ -43,6 +43,13 @@ jest.mock('../../../common/lib/kibana', () => { }, }, }), + }; +}); + +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + return { + ...original, useUiSetting$: jest.fn().mockReturnValue([]), useGetUserSavedObjectPermissions: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx index 880817af856f8..0595fd96d1377 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx @@ -27,7 +27,20 @@ jest.mock('react-router-dom', () => { }); jest.mock('../../../../common/components/link_to'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../../common/lib/kibana/kibana_react'); + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); jest.mock('../../../containers/detection_engine/rules/api', () => ({ getPrePackagedRulesStatus: jest.fn().mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx index 5688b4065ab76..90568e28793a3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx @@ -16,8 +16,7 @@ import { SecurityPageName } from '../../../../app/types'; import { useFormatUrl } from '../../../../common/components/link_to'; import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; import { useUserData } from '../../user_info'; -import { APP_ID } from '../../../../../common/constants'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useNavigateTo } from '../../../../common/lib/kibana/hooks'; const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ @@ -40,14 +39,14 @@ const PrePackagedRulesPromptComponent: React.FC<PrePackagedRulesPromptProps> = ( createPrePackagedRules(); }, [createPrePackagedRules]); const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const { navigateToApp } = useKibana().services.application; + const { navigateTo } = useNavigateTo(); const goToCreateRule = useCallback( (ev) => { ev.preventDefault(); - navigateToApp(APP_ID, { deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); + navigateTo({ deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); }, - [navigateToApp] + [navigateTo] ); const [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 8d0492267258f..c6145a70ec8d2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -313,7 +313,7 @@ export const getColumns = ({ }; export const getMonitoringColumns = ( - history: H.History, + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise<void>, formatUrl: FormatUrl ): RulesStatusesColumns[] => { const cols: RulesStatusesColumns[] = [ @@ -326,7 +326,10 @@ export const getMonitoringColumns = ( data-test-subj="ruleName" onClick={(ev: { preventDefault: () => void }) => { ev.preventDefault(); - history.push(getRuleDetailsUrl(item.id)); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(item.id), + }); }} href={formatUrl(getRuleDetailsUrl(item.id))} > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 77ca5be0c0ac1..22281fa2c8687 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -300,8 +300,8 @@ export const RulesTables = React.memo<RulesTableProps>( reFetchRules, ]); - const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ - history, + const monitoringColumns = useMemo(() => getMonitoringColumns(navigateToApp, formatUrl), [ + navigateToApp, formatUrl, ]); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 3d488f1f08c98..1f2bda768d19c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -305,7 +305,7 @@ const CreateRulePageComponent: React.FC = () => { <MaxWidthEuiFlexItem> <DetectionEngineHeaderPage backOptions={{ - href: getRulesUrl(), + path: getRulesUrl(), text: i18n.BACK_TO_RULES, pageId: SecurityPageName.rules, }} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index a7a9b31d1f408..66f62ad3ebeab 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -578,7 +578,7 @@ const RuleDetailsPageComponent = () => { <Display show={!globalFullScreen}> <DetectionEngineHeaderPage backOptions={{ - href: getRulesUrl(), + path: getRulesUrl(), text: i18n.BACK_TO_RULES, pageId: SecurityPageName.rules, dataTestSubj: 'ruleDetailsBackToAllRules', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 4786d7f2eae78..caec85f537d2b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -355,7 +355,7 @@ const EditRulePageComponent: FC = () => { <MaxWidthEuiFlexItem> <DetectionEngineHeaderPage backOptions={{ - href: getRuleDetailsUrl(ruleId ?? ''), + path: getRuleDetailsUrl(ruleId ?? ''), text: `${i18n.BACK_TO} ${rule?.name ?? ''}`, pageId: SecurityPageName.rules, dataTestSubj: 'ruleEditBackToRuleDetails', diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 6e371bbf610e1..7c34e6f30b910 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -97,7 +97,7 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta ); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); - const [loading, { hostDetails: hostOverview, id, refetch }] = useHostDetails({ + const [loading, { inspect, hostDetails: hostOverview, id, refetch }] = useHostDetails({ endDate: to, startDate: from, hostName: detailName, @@ -169,6 +169,7 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta }} setQuery={setQuery} refetch={refetch} + inspect={inspect} /> )} </AnomalyTableProvider> diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 973dbc41925da..86bd8b5f47b0b 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -65,6 +65,7 @@ const EventsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> = ({ }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 6cf5e989fb645..a123f06f62f96 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -304,7 +304,7 @@ describe('endpoint list middleware', () => { }); }); - describe('handle Endpoint Pending Actions state actions', () => { + describe.skip('handle Endpoint Pending Actions state actions', () => { let mockedApis: ReturnType<typeof endpointPageHttpMock>; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 2f8ced9d2a771..e34e9cf5a83f3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -166,6 +166,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -286,6 +288,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -331,6 +335,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -537,6 +543,8 @@ const endpointsTotal = async (http: HttpStart): Promise<number> => { }) ).total; } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(`error while trying to check for total endpoints`); logError(error); } @@ -547,6 +555,8 @@ const doEndpointsExist = async (http: HttpStart): Promise<boolean> => { try { return (await endpointsTotal(http)) > 0; } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(`error while trying to check if endpoints exist`); logError(error); } @@ -613,6 +623,8 @@ async function getEndpointPackageInfo( payload: createLoadedResourceState(packageInfo), }); } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); dispatch({ @@ -663,6 +675,8 @@ const loadEndpointsPendingActions = async ({ payload: createLoadedResourceState(agentIdToPendingActions), }); } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(error); } }; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx index d448b7644cc24..9ad2549c85642 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx @@ -29,14 +29,14 @@ export const EventFiltersListEmptyState = memo<{ <h2> <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.title" - defaultMessage="Add your first Endpoint Event Filter" + defaultMessage="Add your first event filter" /> </h2> } body={ <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.message" - defaultMessage="There are currently no Endpoint Event Filters on your endpoint." + defaultMessage="There are currently no event filters on your endpoint." /> } actions={ @@ -48,7 +48,7 @@ export const EventFiltersListEmptyState = memo<{ > <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.addButton" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add Event Filter" /> </EuiButton> } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx index 6a106b1488677..d5a1c6624923b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx @@ -69,9 +69,8 @@ describe('Event filter flyout', () => { it('should renders correctly', () => { const component = render(); - expect(component.getAllByText('Add Endpoint Event Filter')).not.toBeNull(); + expect(component.getAllByText('Add event filter')).not.toBeNull(); expect(component.getByText('Cancel')).not.toBeNull(); - expect(component.getByText('Endpoint Security')).not.toBeNull(); }); it('should dispatch action to init form store on mount', async () => { @@ -183,9 +182,8 @@ describe('Event filter flyout', () => { it('should renders correctly when id and edit type', () => { const component = render({ id: 'fakeId', type: 'edit' }); - expect(component.getAllByText('Update Endpoint Event Filter')).not.toBeNull(); + expect(component.getAllByText('Update event filter')).not.toBeNull(); expect(component.getByText('Cancel')).not.toBeNull(); - expect(component.getByText('Endpoint Security')).not.toBeNull(); }); it('should dispatch action to init form store on mount with id', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index 1217488a75ea6..9f81d25520524 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -94,12 +94,12 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo( {id ? ( <FormattedMessage id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update" - defaultMessage="Update Endpoint Event Filter" + defaultMessage="Update event filter" /> ) : ( <FormattedMessage id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add event filter" /> )} </EuiButton> @@ -112,23 +112,19 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo( <EuiFlyoutHeader hasBorder> <EuiTitle size="m"> <h2> - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.title" - defaultMessage="Endpoint Security" - /> + {id ? ( + <FormattedMessage + id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update" + defaultMessage="Update event filter" + /> + ) : ( + <FormattedMessage + id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create" + defaultMessage="Add event filter" + /> + )} </h2> </EuiTitle> - {id ? ( - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update" - defaultMessage="Update Endpoint Event Filter" - /> - ) : ( - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create" - defaultMessage="Add Endpoint Event Filter" - /> - )} </EuiFlyoutHeader> <EuiFlyoutBody> diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index 121808b62f570..db5c42241a0cc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -206,7 +206,7 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo( return !isIndexPatternLoading && exception ? ( <EuiForm component="div"> <EuiText size="s">{FORM_DESCRIPTION}</EuiText> - <EuiSpacer size="s" /> + <EuiSpacer size="m" /> {nameInputMemo} <EuiSpacer size="m" /> {allowSelectOs ? ( diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 1f3b721fd51e3..2d608bdc6e157 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -211,7 +211,7 @@ export const EventFiltersListPage = memo(() => { > <FormattedMessage id="xpack.securitySolution.eventFilters.list.pageAddButton" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add Event Filter" /> </EuiButton> ) diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index fa644d1cbcdac..75723e0d3af84 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -203,7 +203,10 @@ export const HostOverview = React.memo<HostSummaryProps>( return ( <> <InspectButtonContainer> - <OverviewWrapper direction={isInDetailsSidePanel ? 'column' : 'row'}> + <OverviewWrapper + direction={isInDetailsSidePanel ? 'column' : 'row'} + data-test-subj="host-overview" + > {!isInDetailsSidePanel && ( <InspectButton queryId={id} title={i18n.INSPECT_TITLE} inspectIndex={0} /> )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 1f520a1847053..96a59383b1a4e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -21,6 +21,7 @@ import { EuiIconTip, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiRangeProps, } from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; @@ -32,6 +33,13 @@ import { StyledDescriptionList } from './panels/styles'; import { CubeForProcess } from './panels/cube_for_process'; import { GeneratedText } from './generated_text'; +// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position +const StyledEuiRange = styled(EuiRange)<EuiRangeProps>` + & .euiRangeTrack:after { + left: -65px; + transform: rotate(90deg); + } +`; interface StyledGraphControlProps { $backgroundColor: string; $iconColor: string; @@ -275,7 +283,7 @@ export const GraphControls = React.memo( > <EuiIcon type="plusInCircle" /> </button> - <EuiRange + <StyledEuiRange className="zoom-slider" data-test-subj="resolver:graph-controls:zoom-slider" min={0} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index f0c21b6bc1565..eed44afae1695 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -221,7 +221,7 @@ const TimelineNameComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => { [timelineType] ); - const content = useMemo(() => (title.length ? title : placeholder), [title, placeholder]); + const content = useMemo(() => title || placeholder, [title, placeholder]); return ( <EuiText> @@ -239,10 +239,8 @@ const TimelineDescriptionComponent: React.FC<FlyoutHeaderProps> = ({ timelineId ); return ( <EuiText size="s" data-test-subj="timeline-description"> - {description.length ? ( - <LineClamp key={description.length} lineClampHeight={4.5}> - {description} - </LineClamp> + {description ? ( + <LineClamp lineClampHeight={4.5}>{description}</LineClamp> ) : ( commonI18n.DESCRIPTION )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap index 84611e0b7f02c..2cd3d333798d4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap @@ -121,14 +121,17 @@ exports[`Expandable Host Component ExpandableHostDetails: rendering it should re className="c0" > <OverviewWrapper + data-test-subj="host-overview" direction="column" > <EuiFlexGroup className="c1" + data-test-subj="host-overview" direction="column" > <div className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionColumn euiFlexGroup--responsive c1" + data-test-subj="host-overview" > <OverviewDescriptionList descriptionList={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index ac6f6e52db1e2..b71cbb4c082ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,7 +130,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -164,7 +164,13 @@ export const EventsCountComponent = ({ > <EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" /> </PopoverRowItems> - <EuiToolTip content={`${totalCount} ${footerText}`}> + <EuiToolTip + content={ + <> + {totalCount} {footerText} + </> + } + > <ServerSideEventCount> <EuiBadge color="hollow" data-test-subj="server-side-event-count"> {totalCount} diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index b242c0ec2a4a7..8bb4e6cb45853 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -26,13 +26,15 @@ type TGridComponent = TGridProps & { store?: Store; storage: Storage; data?: DataPublicPluginStart; + setStore: (store: Store) => void; }; export const TGrid = (props: TGridComponent) => { - const { store, storage, ...tGridProps } = props; + const { store, storage, setStore, ...tGridProps } = props; let tGridStore = store; if (!tGridStore && props.type === 'standalone') { tGridStore = createStore(initialTGridState, storage); + setStore(tGridStore); } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx index 59cc18767af21..652cb6a5dae33 100644 --- a/x-pack/plugins/timelines/public/components/loading/index.tsx +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -17,7 +17,7 @@ SpinnerFlexItem.displayName = 'SpinnerFlexItem'; export interface LoadingPanelProps { dataTestSubj?: string; - text: string; + text: string | React.ReactNode; height: number | string; showBorder?: boolean; width: number | string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts index fc566da8c58a2..6c793e132b7e3 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -23,17 +23,18 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 23e94b92eaf3d..c164d0026fdf8 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -135,7 +135,7 @@ const TgridActionTdCell = ({ rowIndex, hasRowRenderers, onRuleChange, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, showNotes = false, tabType, @@ -267,7 +267,7 @@ export const DataDrivenColumns = React.memo<DataDrivenColumnProps>( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index dca3b84eb84b7..2db1bde08bd0c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -58,7 +58,7 @@ export const EventColumnView = React.memo<Props>( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, @@ -82,7 +82,6 @@ export const EventColumnView = React.memo<Props>( .join(' '), [columnHeaders, data] ); - const leadingActionCells = useMemo( () => leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx index 2978759b6d148..b7fb0b40c0345 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -110,7 +110,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -144,7 +144,7 @@ export const EventsCountComponent = ({ > <EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" /> </PopoverRowItems> - <EuiToolTip content={`${totalCount} ${footerText}`}> + <EuiToolTip content={`${totalCount} ${footerText?.toString()}`}> <ServerSideEventCount> <EuiBadge color="hollow" data-test-subj="server-side-event-count"> {totalCount} @@ -305,7 +305,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={`${loadingText}...`} + text={loadingText} width="100%" /> </LoadingPanelContainer> diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75aae2ed55c4b..c267a0e57dd2c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -40,6 +40,7 @@ import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; import * as i18n from './translations'; +import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -103,7 +104,9 @@ export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; deletedEventIds: Readonly<string[]>; end: string; + loadingText: React.ReactNode; filters: Filter[]; + footerText: React.ReactNode; headerFilterGroup?: React.ReactNode; height?: number; indexNames: string[]; @@ -113,6 +116,7 @@ export interface TGridStandaloneProps { onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setRefetch: (ref: () => void) => void; start: string; sort: SortColumnTimeline[]; utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; @@ -120,13 +124,17 @@ export interface TGridStandaloneProps { leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; data?: DataPublicPluginStart; + unit: (total: number) => React.ReactNode; } +const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ columns, deletedEventIds, end, + loadingText, filters, + footerText, headerFilterGroup, indexNames, itemsPerPage, @@ -135,6 +143,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ query, renderCellValue, rowRenderers, + setRefetch, start, sort, utilityBar, @@ -142,6 +151,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ leadingControlColumns, trailingControlColumns, data, + unit = basicUnit, }) => { const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -155,7 +165,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ queryFields, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); useEffect(() => { dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading })); }, [dispatch, isQueryLoading]); @@ -216,6 +225,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ skip: !canQueryTimeline, data, }); + setRefetch(refetch); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), @@ -268,71 +278,81 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ showCheckboxes: false, }) ); + dispatch( + tGridActions.initializeTGridSettings({ + footerText, + id: STANDALONE_ID, + loadingText, + unit, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - <StyledEuiPanel data-test-subj="events-viewer-panel" $isFullScreen={false}> - {canQueryTimeline ? ( - <> - <HeaderSection - id={!resolverIsShowing(graphEventId) ? STANDALONE_ID : undefined} - inspect={inspect} - loading={loading} - height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} - subtitle={utilityBar ? undefined : subtitle} - title={justTitle} - // title={globalFullScreen ? titleWithExitFullScreen : justTitle} - > - {HeaderSectionContent} - </HeaderSection> - {utilityBar && !resolverIsShowing(graphEventId) && ( - <UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar> - )} - <EventsContainerLoading - data-timeline-id={STANDALONE_ID} - data-test-subj={`events-container-loading-${loading}`} - > - <FullWidthFlexGroup $visible={!graphEventId} gutterSize="none"> - <ScrollableFlexItem grow={1}> - <StatefulBody - activePage={pageInfo.activePage} - browserFields={EMPTY_BROWSER_FIELDS} - data={nonDeletedEvents} - id={STANDALONE_ID} - isEventViewer={true} - onRuleChange={onRuleChange} - renderCellValue={renderCellValue} - rowRenderers={rowRenderers} - sort={sort} - tabType={TimelineTabs.query} - totalPages={calculateTotalPages({ - itemsCount: totalCountMinusDeleted, - itemsPerPage: itemsPerPageStore, - })} - leadingControlColumns={leadingControlColumns} - trailingControlColumns={trailingControlColumns} - /> - <Footer - activePage={pageInfo.activePage} - data-test-subj="events-viewer-footer" - updatedAt={updatedAt} - height={footerHeight} - id={STANDALONE_ID} - isLive={false} - isLoading={loading} - itemsCount={nonDeletedEvents.length} - itemsPerPage={itemsPerPageStore} - itemsPerPageOptions={itemsPerPageOptionsStore} - onChangePage={loadPage} - totalCount={totalCountMinusDeleted} - /> - </ScrollableFlexItem> - </FullWidthFlexGroup> - </EventsContainerLoading> - </> - ) : null} - </StyledEuiPanel> + <InspectButtonContainer> + <StyledEuiPanel data-test-subj="events-viewer-panel" $isFullScreen={false}> + {canQueryTimeline ? ( + <> + <HeaderSection + id={!resolverIsShowing(graphEventId) ? STANDALONE_ID : undefined} + inspect={inspect} + loading={loading} + height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} + subtitle={utilityBar ? undefined : subtitle} + title={justTitle} + // title={globalFullScreen ? titleWithExitFullScreen : justTitle} + > + {HeaderSectionContent} + </HeaderSection> + {utilityBar && !resolverIsShowing(graphEventId) && ( + <UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar> + )} + <EventsContainerLoading + data-timeline-id={STANDALONE_ID} + data-test-subj={`events-container-loading-${loading}`} + > + <FullWidthFlexGroup $visible={!graphEventId} gutterSize="none"> + <ScrollableFlexItem grow={1}> + <StatefulBody + activePage={pageInfo.activePage} + browserFields={EMPTY_BROWSER_FIELDS} + data={nonDeletedEvents} + id={STANDALONE_ID} + isEventViewer={true} + onRuleChange={onRuleChange} + renderCellValue={renderCellValue} + rowRenderers={rowRenderers} + sort={sort} + tabType={TimelineTabs.query} + totalPages={calculateTotalPages({ + itemsCount: totalCountMinusDeleted, + itemsPerPage: itemsPerPageStore, + })} + leadingControlColumns={leadingControlColumns} + trailingControlColumns={trailingControlColumns} + /> + <Footer + activePage={pageInfo.activePage} + data-test-subj="events-viewer-footer" + updatedAt={updatedAt} + height={footerHeight} + id={STANDALONE_ID} + isLive={false} + isLoading={loading} + itemsCount={nonDeletedEvents.length} + itemsPerPage={itemsPerPageStore} + itemsPerPageOptions={itemsPerPageOptionsStore} + onChangePage={loadPage} + totalCount={totalCountMinusDeleted} + /> + </ScrollableFlexItem> + </FullWidthFlexGroup> + </EventsContainerLoading> + </> + ) : null} + </StyledEuiPanel> + </InspectButtonContainer> ); }; diff --git a/x-pack/plugins/timelines/public/components/tgrid.tsx b/x-pack/plugins/timelines/public/components/tgrid.tsx index 9d74c9287236a..45e387b780a80 100644 --- a/x-pack/plugins/timelines/public/components/tgrid.tsx +++ b/x-pack/plugins/timelines/public/components/tgrid.tsx @@ -9,12 +9,12 @@ import React from 'react'; import type { TGridProps } from '../types'; import { TGridIntegrated, TGridIntegratedProps } from './t_grid/integrated'; -import { TGridStandalone } from './t_grid/standalone'; +import { TGridStandalone, TGridStandaloneProps } from './t_grid/standalone'; export const TGrid = (props: TGridProps) => { const { type, ...componentsProps } = props; if (type === 'standalone') { - return <TGridStandalone {...componentsProps} />; + return <TGridStandalone {...((componentsProps as unknown) as TGridStandaloneProps)} />; } else if (type === 'embedded') { return <TGridIntegrated {...((componentsProps as unknown) as TGridIntegratedProps)} />; } diff --git a/x-pack/plugins/timelines/public/methods/index.tsx b/x-pack/plugins/timelines/public/methods/index.tsx index cd98021c500c5..a11485f32b6a5 100644 --- a/x-pack/plugins/timelines/public/methods/index.tsx +++ b/x-pack/plugins/timelines/public/methods/index.tsx @@ -16,11 +16,21 @@ import { LastUpdatedAtProps, LoadingPanelProps } from '../components'; const TimelineLazy = lazy(() => import('../components')); export const getTGridLazy = ( props: TGridProps, - { store, storage, data }: { store?: Store; storage: Storage; data: DataPublicPluginStart } + { + store, + storage, + data, + setStore, + }: { + store?: Store; + storage: Storage; + data: DataPublicPluginStart; + setStore: (store: Store) => void; + } ) => { return ( <Suspense fallback={<EuiLoadingSpinner />}> - <TimelineLazy {...props} store={store} storage={storage} data={data} /> + <TimelineLazy {...props} store={store} storage={storage} data={data} setStore={setStore} /> </Suspense> ); }; diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index a6076d91eea1d..c9534d4312e78 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -39,6 +39,7 @@ export class TimelinesPlugin implements Plugin<void, TimelinesUIStart> { return getTGridLazy(props, { store: this._store, storage: this._storage, + setStore: this.setStore.bind(this), data, }); }, @@ -60,12 +61,15 @@ export class TimelinesPlugin implements Plugin<void, TimelinesUIStart> { getUseDraggableKeyboardWrapper: () => { return useDraggableKeyboardWrapper; }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setTGridEmbeddedStore: (store: any) => { - this._store = store; + setTGridEmbeddedStore: (store: Store) => { + this.setStore(store); }, }; } + private setStore(store: Store) { + this._store = store; + } + public stop() {} } diff --git a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts index e114f4516c79e..8bcf246dadb03 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts @@ -234,7 +234,6 @@ export const updateTimelineColumns = ({ timelineById, }: UpdateTimelineColumnsParams): TimelineById => { const timeline = timelineById[id]; - return { ...timelineById, [id]: { diff --git a/x-pack/plugins/timelines/public/store/t_grid/model.ts b/x-pack/plugins/timelines/public/store/t_grid/model.ts index 67b56540c8a42..ed788d1ee4677 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/model.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/model.ts @@ -26,12 +26,13 @@ export interface TGridModelSettings { /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; filterManager?: FilterManager; - footerText: string; - loadingText: string; + footerText?: string | React.ReactNode; + loadingText?: string | React.ReactNode; queryFields: string[]; selectAll: boolean; showCheckboxes?: boolean; title: string; + unit?: (n: number) => string | React.ReactNode; } export interface TGridModel extends TGridModelSettings { /** The columns displayed in the timeline */ diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 39594dcbff9ae..6667388fd3688 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -43,7 +43,11 @@ export function getDefaultAggregationConfig( case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); case PIVOT_SUPPORTED_AGGS.TOP_METRICS: - return getTopMetricsAggConfig(commonConfig); + return getTopMetricsAggConfig({ + ...commonConfig, + // top_metrics agg has different naming convention by default + aggName: PIVOT_SUPPORTED_AGGS.TOP_METRICS, + }); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts index 354a326f38659..56d17e7973e16 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts @@ -17,7 +17,6 @@ import { import { PivotAggsConfigTopMetrics } from './types'; import { TopMetricsAggForm } from './components/top_metrics_agg_form'; import { isPopulatedObject } from '../../../../../../../../common/shared_imports'; -import { PIVOT_SUPPORTED_AGGS } from '../../../../../../../../common/types/pivot_aggs'; /** * Gets initial basic configuration of the top_metrics aggregation. @@ -31,8 +30,6 @@ export function getTopMetricsAggConfig( isMultiField: true, field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', AggFormComponent: TopMetricsAggForm, - /** Default name */ - aggName: PIVOT_SUPPORTED_AGGS.TOP_METRICS, aggConfig: {}, getEsAggConfig() { // ensure the configuration has been completed diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 70b6d766fe786..df454b21ee725 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20811,7 +20811,6 @@ "xpack.securitySolution.rowRenderer.wasPreventedFromExecutingAMaliciousProcessDescription": "悪意のあるファイルの実行が防止されました", "xpack.securitySolution.rowRenderer.wasPreventedFromModifyingAMaliciousFileDescription": "悪意のあるファイルの修正が防止されました", "xpack.securitySolution.rowRenderer.wasPreventedFromRenamingAMaliciousFileDescription": "悪意のあるファイルの名前変更が防止されました", - "xpack.securitySolution.search.administration": "エンドポイント管理", "xpack.securitySolution.search.administration.trustedApps": "信頼できるアプリケーション", "xpack.securitySolution.search.cases": "ケース", "xpack.securitySolution.search.cases.configure": "ケースを構成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 02e17eaac3f57..447ba99945edc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -21143,7 +21143,6 @@ "xpack.securitySolution.rowRenderer.wasPreventedFromExecutingAMaliciousProcessDescription": "被阻止执行恶意进程", "xpack.securitySolution.rowRenderer.wasPreventedFromModifyingAMaliciousFileDescription": "被阻止修改恶意文件", "xpack.securitySolution.rowRenderer.wasPreventedFromRenamingAMaliciousFileDescription": "被阻止重命名恶意文件", - "xpack.securitySolution.search.administration": "终端管理", "xpack.securitySolution.search.administration.trustedApps": "受信任的应用程序", "xpack.securitySolution.search.cases": "案例", "xpack.securitySolution.search.cases.configure": "配置案例", diff --git a/x-pack/test/apm_api_integration/tests/services/top_services.ts b/x-pack/test/apm_api_integration/tests/services/top_services.ts index 37f7b09e8b7d2..9c687fc74acce 100644 --- a/x-pack/test/apm_api_integration/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/tests/services/top_services.ts @@ -286,13 +286,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(unfilteredServiceNames).to.eql(filteredServiceNames); - expect( - filteredResponse.body.items.every((item) => { - // make sure it did not query transaction data - return isEmpty(item.avgResponseTime); - }) - ).to.be(true); - expect(filteredResponse.body.items.every((item) => !!item.agentName)).to.be(true); }); } diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index 2c18051405964..427d8c21635c4 100644 --- a/x-pack/test/functional/apps/discover/async_scripted_fields.js +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const security = getService('security'); - describe('async search with scripted fields', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104362 + describe.skip('async search with scripted fields', function () { this.tags(['skipFirefox']); before(async function () { diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index 3eb66204df564..0b018b4428e1d 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -73,7 +73,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Generate CSV: new search', () => { + // FLAKY: https://github.com/elastic/kibana/issues/104372 + describe.skip('Generate CSV: new search', () => { beforeEach(async () => { await kibanaServer.importExport.load(ecommerceSOPath); await PageObjects.common.navigateToApp('discover'); diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index 650d67f05129c..de0dc459b6395 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -28,7 +28,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); } - describe('discover field visualize button', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104469 + describe.skip('discover field visualize button', () => { beforeEach(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/lens/basic'); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index 3867ed6f7dfea..dee5b5a5e31c0 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -273,6 +273,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('creates filebeat config'); await ml.dataVisualizerFileBased.selectCreateFilebeatConfig(); + + await ml.testExecution.logTestStep('closes filebeat config'); + await ml.dataVisualizerFileBased.closeCreateFilebeatConfig(); }); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index 93e3b67ca1565..54d7b6ac294d1 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -15,16 +15,21 @@ export default function ({ getService }: FtrProviderContext) { this.tags(['mlqa']); const indexPatternName = 'ft_farequote'; - // @TODO: Re-enable in follow up - // const advancedJobWizardDatafeedQuery = `{ - // "bool": { - // "must": [ - // { - // "match_all": {} - // } - // ] - // } - // }`; // Note query is not currently passed to the wizard + + const advancedJobWizardDatafeedQuery = JSON.stringify( + { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }, + null, + 2 + ); + // Note query is not currently passed to the wizard before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); @@ -48,20 +53,19 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer(indexPatternName); }); - // @TODO: Re-enable in follow up - // it('opens the advanced job wizard', async () => { - // await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); - // await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); - // - // // Note the search is not currently passed to the wizard, just the index. - // await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); - // await ml.dataVisualizerIndexBased.clickCreateAdvancedJobButton(); - // await ml.jobTypeSelection.assertAdvancedJobWizardOpen(); - // await ml.jobWizardAdvanced.assertDatafeedQueryEditorExists(); - // await ml.jobWizardAdvanced.assertDatafeedQueryEditorValue(advancedJobWizardDatafeedQuery); - // }); + it('opens the advanced job wizard', async () => { + await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); + + // Note the search is not currently passed to the wizard, just the index. + await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); + await ml.dataVisualizerIndexBased.clickCreateAdvancedJobButton(); + await ml.jobTypeSelection.assertAdvancedJobWizardOpen(); + await ml.jobWizardAdvanced.assertDatafeedQueryEditorExists(); + await ml.jobWizardAdvanced.assertDatafeedQueryEditorValue(advancedJobWizardDatafeedQuery); + }); }); describe('view in discover page action', function () { diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index 18f4f6a38a7b1..10b57de911a10 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -93,8 +93,8 @@ export default function ({ getService }: FtrProviderContext) { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // @TODO: Re-enable in follow up - // const ecExpectedModuleId = 'sample_data_ecommerce'; + + const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -365,11 +365,10 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); + await ml.testExecution.logTestStep('should display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); + await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index a53ed2fafe30c..920d82ed381c0 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -94,7 +94,6 @@ export default function ({ getService }: FtrProviderContext) { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -357,11 +356,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/ml/settings/calendar_creation.ts b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts index 242163d83c456..5cee16c10e215 100644 --- a/x-pack/test/functional/apps/ml/settings/calendar_creation.ts +++ b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts @@ -46,6 +46,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); await ml.settingsCalendar.navigateToCalendarCreationPage(); + await ml.settingsCalendar.waitForFormEnabled(); + await ml.testExecution.logTestStep('calendar creation sets calendar to apply to all jobs'); await ml.settingsCalendar.toggleApplyToAllJobsSwitch(true); await ml.settingsCalendar.assertJobSelectionNotExists(); @@ -79,6 +81,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); await ml.settingsCalendar.navigateToCalendarCreationPage(); + await ml.settingsCalendar.waitForFormEnabled(); + await ml.testExecution.logTestStep( 'calendar creation verifies the job selection and job group section are displayed' ); diff --git a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts index 291e5a8964553..783be207baf22 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts @@ -139,5 +139,10 @@ export function MachineLearningDataVisualizerFileBasedProvider( await testSubjects.click('fileDataVisFilebeatConfigLink'); await testSubjects.existOrFail('fileDataVisFilebeatConfigPanel'); }, + + async closeCreateFilebeatConfig() { + await testSubjects.click('fileBeatConfigFlyoutCloseButton'); + await testSubjects.missingOrFail('fileDataVisFilebeatConfigPanel'); + }, }; } diff --git a/x-pack/test/functional/services/ml/settings_calendar.ts b/x-pack/test/functional/services/ml/settings_calendar.ts index b2fc121f99936..8959e93623c1c 100644 --- a/x-pack/test/functional/services/ml/settings_calendar.ts +++ b/x-pack/test/functional/services/ml/settings_calendar.ts @@ -214,6 +214,11 @@ export function MachineLearningSettingsCalendarProvider( ); }, + async waitForFormEnabled() { + // @ts-expect-error null is acceptable for a disabled attribute that no longer exists. + await testSubjects.waitForAttributeToChange('mlCalendarIdInput', 'disabled', null); + }, + async assertCalendarRowExists(calendarId: string) { await testSubjects.existOrFail(this.calendarRowSelector(calendarId)); }, diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index aff1402f5567e..901744719144c 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -25,7 +25,6 @@ export default function ({ getService }: FtrProviderContext) { describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -134,11 +133,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index 2e5216d722518..0f271719a0d0f 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -25,8 +25,6 @@ export default function ({ getService }: FtrProviderContext) { describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // @TODO: Re-enable in follow up - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -135,11 +133,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx index 084b79d6a32b3..072944dd9d78e 100644 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx @@ -6,7 +6,7 @@ */ import { Router } from 'react-router-dom'; -import React from 'react'; +import React, { useCallback, useRef } from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; @@ -45,6 +45,11 @@ const AppRoot = React.memo( parameters: AppMountParameters; timelinesPluginSetup: TimelinesUIStart | null; }) => { + const refetch = useRef(); + + const setRefetch = useCallback((_refetch) => { + refetch.current = _refetch; + }, []); return ( <I18nProvider> <Router history={parameters.history}> @@ -56,10 +61,12 @@ const AppRoot = React.memo( columns: [], indexNames: [], deletedEventIds: [], + end: '', + footerText: 'Events', filters: [], itemsPerPage: 50, itemsPerPageOptions: [1, 2, 3], - end: '', + loadingText: 'Loading events', renderCellValue: () => <div data-test-subj="timeline-wrapper">test</div>, sort: [], leadingControlColumns: [], @@ -68,8 +75,10 @@ const AppRoot = React.memo( query: '', language: 'kuery', }, + setRefetch, start: '', rowRenderers: [], + unit: (n: number) => `${n}`, })) ?? null} </KibanaContextProvider> diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts new file mode 100644 index 0000000000000..a0f4a3f91fe32 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; +import { FtrProviderContext } from '../ftr_provider_context'; + +import { ILM_POLICY_NAME } from '../../../plugins/reporting/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const supertestNoAuth = getService('supertestWithoutAuth'); + const reportingAPI = getService('reportingAPI'); + + describe('ILM policy migration APIs', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.load('x-pack/test/functional/es_archives/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + }); + + afterEach(async () => { + await reportingAPI.deleteAllReports(); + await reportingAPI.migrateReportingIndices(); // ensure that the ILM policy exists + }); + + it('detects when no migration is needed', async () => { + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + + // try creating a report + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('detects when reporting indices should be migrated due to missing ILM policy', async () => { + await reportingAPI.makeAllReportingPoliciesUnmanaged(); + // TODO: Remove "any" when no longer through type issue "policy_id" missing + await es.ilm.deleteLifecycle({ policy: ILM_POLICY_NAME } as any); + + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('policy-not-found'); + // assert that migration fixes this + await reportingAPI.migrateReportingIndices(); + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('detects when reporting indices should be migrated due to unmanaged indices', async () => { + await reportingAPI.makeAllReportingPoliciesUnmanaged(); + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('indices-not-managed-by-policy'); + // assert that migration fixes this + await reportingAPI.migrateReportingIndices(); + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('does not override an existing ILM policy', async () => { + const customLifecycle = { + policy: { + phases: { + hot: { + min_age: '0ms', + actions: {}, + }, + delete: { + min_age: '0ms', + actions: { + delete: { + delete_searchable_snapshot: true, + }, + }, + }, + }, + }, + }; + + // customize the lifecycle policy + await es.ilm.putLifecycle({ + policy: ILM_POLICY_NAME, + body: customLifecycle, + }); + + await reportingAPI.migrateReportingIndices(); + + const { + body: { + [ILM_POLICY_NAME]: { policy }, + }, + } = await es.ilm.getLifecycle({ policy: ILM_POLICY_NAME }); + + expect(policy).to.eql(customLifecycle.policy); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index 15960e45d4a62..fed842427ab90 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Reporting API Integration Tests with Security disabled', function () { this.tags('ciGroup13'); loadTestFile(require.resolve('./job_apis')); + loadTestFile(require.resolve('./ilm_migration_apis')); }); } diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index e45af4bd140b0..eb32de9d0dc9c 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -164,6 +164,36 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer }); }; + const checkIlmMigrationStatus = async () => { + log.debug('ReportingAPI.checkIlmMigrationStatus'); + const { body } = await supertestWithoutAuth + .get('/api/reporting/ilm_policy_status') + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.status; + }; + + const migrateReportingIndices = async () => { + log.debug('ReportingAPI.migrateReportingIndices'); + await supertestWithoutAuth + .put('/api/reporting/deprecations/migrate_ilm_policy') + .set('kbn-xsrf', 'xxx') + .expect(200); + }; + + const makeAllReportingPoliciesUnmanaged = async () => { + log.debug('ReportingAPI.makeAllReportingPoliciesUnmanaged'); + const settings: any = { + 'index.lifecycle.name': null, + }; + await esSupertest + .put('/.reporting*/_settings') + .send({ + settings, + }) + .expect(200); + }; + return { initEcommerce, teardownEcommerce, @@ -182,5 +212,8 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer postJob, postJobJSON, deleteAllReports, + checkIlmMigrationStatus, + migrateReportingIndices, + makeAllReportingPoliciesUnmanaged, }; } diff --git a/x-pack/test/search_sessions_integration/config.ts b/x-pack/test/search_sessions_integration/config.ts index 1e2d648712098..9dc542038a48a 100644 --- a/x-pack/test/search_sessions_integration/config.ts +++ b/x-pack/test/search_sessions_integration/config.ts @@ -22,6 +22,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './tests/apps/dashboard/async_search'), + resolve(__dirname, './tests/apps/dashboard/session_sharing'), resolve(__dirname, './tests/apps/discover'), resolve(__dirname, './tests/apps/lens'), resolve(__dirname, './tests/apps/management/search_sessions'), diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts new file mode 100644 index 0000000000000..d06d5d5ebd6ab --- /dev/null +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common']); + + describe('Search session sharing', function () { + this.tags('ciGroup3'); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); + await PageObjects.common.navigateToApp('dashboard'); + }); + + loadTestFile(require.resolve('./lens')); + }); +} diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts new file mode 100644 index 0000000000000..b6dfc29bb1c6b --- /dev/null +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const find = getService('find'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['header', 'common', 'dashboard', 'timePicker', 'lens']); + + // Dashboard shares a search session with lens when navigating to and from by value lens to hit search cache + // https://github.com/elastic/kibana/issues/99310 + describe('Search session sharing with lens', () => { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/lens/basic'); + }); + + // NOTE: This test doesn't check if the cache was actually hit, but just checks if the same search session id is used + // so it doesn't give the 100% confidence that cache-hit improvement works https://github.com/elastic/kibana/issues/99310 + // but if it fails, we for sure know it doesn't work + it("should share search session with by value lens and don't share with by reference", async () => { + // Add a by ref lens panel to a new dashboard + const lensTitle = 'Artistpreviouslyknownaslens'; + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames(lensTitle); + await find.clickByButtonText(lensTitle); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.dashboard.waitForRenderComplete(); + + // Navigating to lens and back should create a new session + const byRefSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.dashboard.waitForRenderComplete(); + const newByRefSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + + expect(byRefSessionId).not.to.eql(newByRefSessionId); + + // Convert to by-value + const byRefPanel = await testSubjects.find('embeddablePanelHeading-' + lensTitle); + await dashboardPanelActions.unlinkFromLibary(byRefPanel); + await PageObjects.dashboard.waitForRenderComplete(); + const byValueSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + + // Navigating to lens and back should keep the session + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.dashboard.waitForRenderComplete(); + const newByValueSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + expect(byValueSessionId).to.eql(newByValueSessionId); + }); + }); +} diff --git a/x-pack/test/security_solution_cypress/ccs_config.ts b/x-pack/test/security_solution_cypress/ccs_config.ts new file mode 100644 index 0000000000000..8e679c071ac0f --- /dev/null +++ b/x-pack/test/security_solution_cypress/ccs_config.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +import { SecuritySolutionCypressCcsTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const securitySolutionCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...securitySolutionCypressConfig.getAll(), + + testRunner: SecuritySolutionCypressCcsTestRunner, + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index b219c491ddf77..0ac671f143d03 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -88,6 +88,30 @@ export async function SecuritySolutionCypressCliFirefoxTestRunner({ }); } +export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrProviderContext) { + const log = getService('log'); + + await withProcRunner(log, async (procs) => { + await procs.run('cypress', { + cmd: 'yarn', + args: ['cypress:run:ccs'], + cwd: resolve(__dirname, '../../plugins/security_solution'), + env: { + FORCE_COLOR: '1', + CYPRESS_BASE_URL: process.env.TEST_KIBANA_URL, + CYPRESS_ELASTICSEARCH_URL: process.env.TEST_ES_URL, + CYPRESS_ELASTICSEARCH_USERNAME: process.env.ELASTICSEARCH_USERNAME, + CYPRESS_ELASTICSEARCH_PASSWORD: process.env.ELASTICSEARCH_PASSWORD, + CYPRESS_CCS_KIBANA_URL: process.env.TEST_KIBANA_URLDATA, + CYPRESS_CCS_ELASTICSEARCH_URL: process.env.TEST_ES_URLDATA, + CYPRESS_CCS_REMOTE_NAME: process.env.TEST_CCS_REMOTE_NAME, + ...process.env, + }, + wait: true, + }); + }); +} + export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { const log = getService('log'); const config = getService('config');