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
{
+ /**
+ * 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 (