> {
- const url = Url.resolve(this.pickUrl(), options.path);
+ const url = this.resolveUrl(options.path);
const description = options.description || `${options.method} ${url}`;
let attempt = 0;
const maxAttempts = options.retries ?? DEFAULT_MAX_ATTEMPTS;
@@ -107,6 +113,9 @@ export class KbnClientRequester {
'kbn-xsrf': 'kbn-client',
},
httpsAgent: this.httpsAgent,
+ responseType: options.responseType,
+ // work around https://github.com/axios/axios/issues/2791
+ transformResponse: options.responseType === 'text' ? [(x) => x] : undefined,
paramsSerializer: (params) => Qs.stringify(params),
});
diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json
index 47a2fa19e7a8e..00c6f677cd223 100644
--- a/packages/kbn-ui-shared-deps/package.json
+++ b/packages/kbn-ui-shared-deps/package.json
@@ -14,7 +14,6 @@
"@kbn/monaco": "link:../kbn-monaco"
},
"devDependencies": {
- "@kbn/babel-preset": "link:../kbn-babel-preset",
"@kbn/dev-utils": "link:../kbn-dev-utils"
}
}
\ No newline at end of file
diff --git a/rfcs/images/url_service/new_architecture.png b/rfcs/images/url_service/new_architecture.png
new file mode 100644
index 0000000000000..9faa025d429bf
Binary files /dev/null and b/rfcs/images/url_service/new_architecture.png differ
diff --git a/rfcs/images/url_service/old_architecture.png b/rfcs/images/url_service/old_architecture.png
new file mode 100644
index 0000000000000..fdb1c13fabf34
Binary files /dev/null and b/rfcs/images/url_service/old_architecture.png differ
diff --git a/rfcs/text/0017_url_service.md b/rfcs/text/0017_url_service.md
new file mode 100644
index 0000000000000..87a8a92c090d6
--- /dev/null
+++ b/rfcs/text/0017_url_service.md
@@ -0,0 +1,600 @@
+- Start Date: 2021-03-26
+- RFC PR: (leave this empty)
+- Kibana Issue: (leave this empty)
+
+
+# Summary
+
+Currently in the Kibana `share` plugin we have two services that deal with URLs.
+
+One is *Short URL Service*: given a long internal Kibana URL it returns an ID.
+That ID can be used to "resolve" back to the long URL and redirect the user to
+that long URL page. (The Short URL Service is now used in Dashboard, Discover,
+Visualize apps, and have a few upcoming users, for example, when sharing panels
+by Slack or e-mail we will want to use short URLs.)
+
+```ts
+// It does not have a plugin API, you can only use it through an HTTP request.
+const shortUrl = await http.post('/api/shorten_url', {
+ url: '/some/long/kibana/url/.../very?long=true#q=(rison:approved)'
+});
+```
+
+The other is the *URL Generator Service*: it simply receives an object of
+parameters and returns back a deep link within Kibana. (You can use it, for
+example, to navigate to some specific query with specific filters for a
+specific index pattern in the Discover app. As of this writing, there are
+eight registered URL generators, which are used by ten plugins.)
+
+```ts
+// You first register a URL generator.
+const myGenerator = plugins.share.registerUrlGenerator(/* ... */);
+
+// You can fetch it from the registry (if you don't already have it).
+const myGenerator = plugins.share.getUrlGenerator(/* ... */);
+
+// Now you can use it to generate a deep link into Kibana.
+const deepLink: string = myGenerator.createUrl({ /* ... */ });
+```
+
+
+## Goals of the project
+
+The proposal is to unify both of these services (Short URL Service and URL
+Generator Service) into a single new *URL Service*. The new unified service
+will still provide all the functionality the above mentioned services provide
+and in addition will implement the following improvements:
+
+1. Standardize a way for apps to deep link and navigate into other Kibana apps,
+ with ability to use *location state* to specify the state of the app which is
+ not part of the URL.
+2. Combine Short URL Service with URL Generator Service to allow short URLs to
+ be constructed from URL generators, which will also allow us to automatically
+ migrate the short URLs if the parameters of the underlying URL generator
+ change and be able to store location state in every short URL.
+3. Make the short url service easier to use. (It was previously undocumented,
+ and no server side plugin APIs existed, which meant consumers had to use
+ REST APIs which is discouraged. Merging the two services will help achieve
+ this goal by simplifying the APIs.)
+4. Support short urls being deleted (previously not possible).
+5. Support short urls being migrated (previously not possible).
+
+See more detailed explanation and other small improvements in the "Motivation"
+section below.
+
+
+# Terminology
+
+In the proposed new service we introduce "locators". This is mostly a change
+in language, we are renaming "URL generators" to "locators". The old name would
+no longer make sense as we are not returning URLs from locators.
+
+
+# Basic example
+
+The URL Service will have a client (`UrlServiceClient`) which will have the same
+interface, both, on the server-side and the client-side. It will also have a
+documented public set of HTTP API endpoints for use by: (1) the client-side
+client; (2) external users, Elastic Cloud, and Support.
+
+The following code examples will work, both, on the server-side and the
+client-side, as the base `UrlServiceClient` interface will be similar in both
+environments.
+
+Below we consider four main examples of usage of the URL Service. All four
+examples are existing use cases we currently have in Kibana.
+
+
+## Navigating within Kibana using locators
+
+In this example let's consider a case where Discover app creates a locator,
+then another plugin uses that locator to navigate to a deep link within the
+Discover app.
+
+First, the Discover plugin creates its locator (usually one per app). It needs
+to do this on the client and server.
+
+
+```ts
+const locator = plugins.share.locators.create({
+ id: 'DISCOVER_DEEP_LINKS',
+ getLocation: ({
+ indexPattern,
+ highlightedField,
+ filters: [],
+ query: {},
+ fields: [],
+ activeDoc: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx',
+ }) => {
+ app: 'discover',
+ route: `/${indexPatten}#_a=(${risonEncode({filters, query, fields})})`,
+ state: {
+ highlightedField,
+ activeDoc,
+ },
+ },
+});
+```
+
+Now, the Discover plugin exports this locator from its plugin contract.
+
+```ts
+class DiscoverPlugin() {
+ start() {
+ return {
+ locator,
+ };
+ }
+}
+```
+
+Finally, if any other app now wants to navigate to a deep link within the
+Discover application, they use this exported locator.
+
+```ts
+plugins.discover.locator.navigate({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+Note, in this example the `highlightedField` parameter will not appear in the
+URL bar, it will be passed to the Discover app through [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState)
+mechanism (in Kibana case, using the [`history`](https://www.npmjs.com/package/history) package, which is used by `core.application.navigateToApp`).
+
+
+## Sending a deep link to Kibana
+
+We have use cases were a deep link to some Kibana app is sent out, for example,
+through e-mail or as a Slack message.
+
+In this example, lets consider some plugin gets hold of the Discover locator
+on the server-side.
+
+```ts
+const location = plugins.discover.locator.getRedirectPath({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+This would return the location of the client-side redirect endpoint. The redirect
+endpoint could look like this:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+This redirect client-side endpoint would find the Discover locator and and
+execute the `.navigate()` method on it.
+
+
+## Creating a short link
+
+In this example, lets create a short link using the Discover locator.
+
+```ts
+const shortUrl = await plugins.discover.locator.createShortUrl(
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ }
+ 'human-readable-slug',
+});
+```
+
+The above example creates a short link and persists it in a saved object. The
+short URL can have a human-readable slug, which uniquely identifies that short
+URL.
+
+```ts
+shortUrl.slug === 'human-readable-slug'
+```
+
+The short URL can be used to navigate to the Discover app. The redirect
+client-side endpoint currently looks like this:
+
+```
+/app/goto/human-readable-slug
+```
+
+This persisted short URL would effectively work the same as the full version:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+## External users navigating to a Kibana deep link
+
+Currently Elastic Cloud and Support have many links linking into Kibana. Most of
+them are deep links into Discover and Dashboard apps where, for example, index
+pattern is selected, or filters and time range are set.
+
+The external users could use the above mentioned client-side redirect endpoint
+to navigate to their desired deep location within Kibana, for example, to the
+Discover application:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+# Motivation
+
+Our motivation to improve the URL services comes from us intending to use them
+more, for example, for panel sharing to Slack or e-mail; and we believe that the
+current state of the URL services needs an upgrade.
+
+
+## Limitations of the Short URL Service
+
+We have identified the following limitations in the current implementation of
+the Short URL Service:
+
+1. There is no migration system. If an application exposes this functionality,
+ every possible URL that might be generated should be supported forever. A
+ migration could be written inside the app itself, on page load, but this is a
+ risky path for URLs with many possibilities.
+ 1. __Will do:__ Short URLs will be created using locators. We will use
+ migrations provided by the locators to migrate the stored parameters
+ in the short URL saved object.
+1. Short URLs store only the URL of the destination page. However, the
+ destination page might have other state which affects the display of the page
+ but is not present in the URL. Once the short URL is used to navigate to that
+ page, any state that is kept only in memory is lost.
+ 1. __Will do:__ The new implementation of the short URLs will also persist
+ the location state of the URL. That state would be provided to a
+ Kibana app once a user navigates to that app using a short URL.
+1. It exposes only HTTP endpoint API.
+ 1. __Will do:__ We will also expose a URL Service client through plugin
+ contract on the server and browser.
+1. It only has 3 HTTP endpoints, yet all three have different paths:
+ (1) `/short_url`, (2) `/shorten_url`; and (3) `/goto`.
+ 1. __Will do:__ We will normalize the HTTP endpoints. We will use HTTP
+ method "verbs" like POST, instead of verbs in the url like "shorten_url".
+1. There is not much documentation for developers.
+ 1. __Will do:__ The new service will have a much nicer API and docs.
+1. There is no way to delete short URLs once they are created.
+ 1. __Will do:__ The new service will provide CRUD API to manage short URLs,
+ including deletion.
+1. Short URL service uses MD5 algorithm to hash long URLs. Security team
+ requested to stop using that algorithm.
+ 1. __Will do:__ The new URL Service will not use MD5 algorithm.
+1. Short URLs are not automatically deleted when the target (say dashboard) is
+ deleted. (#10450)
+ 1. __Could do:__ The URL Service will not provide such feature. Though the
+ short URLs will keep track of saved object references used in the params
+ to generate a short URL. Maybe those saved references could somehow be
+ used in the future to provide such a facility.
+
+ Currently, there are two possible avenues for deleting a short URL when
+ the underlying dashboard is deleted:
+
+ 1. The Dashboard app could keep track of short URLs it generates for each
+ dashboard. Once a dashboard is deleted, the Dashboard app also
+ deletes all short URLs associated with that dashboard.
+ 1. Saved Objects Service could implement *cascading deletes*. Once a saved
+ object is deleted, the associated saved objects are also deleted
+ (#71453).
+1. Add additional metadata to each short URL.
+ 1. __Could do:__ Each short URL already keeps a counter of how often it was
+ resolved, we could also keep track of a timestamp when it was last
+ resolved, and have an ability for users to give a title to each short URL.
+1. Short URLs don't have a management UI.
+ 1. __Will NOT do:__ We will not create a dedicated UI for managing short
+ URLs. We could improve how short URLs saved objects are presented in saved
+ object management UI.
+1. Short URLs can't be created by read-only users (#18006).
+ 1. __Will NOT do:__ Currently short URLs are stored as saved objects of type
+ `url`, we would like to keep it that way and benefit from saved object
+ facilities like references, migrations, authorization etc.. The consensus
+ is that we will not allow anonymous users to create short URLs. We want to
+ continue using saved object for short URLs going forward and not
+ compromise on their security model.
+
+
+## Limitations of the URL Generator Service
+
+We have identified the following limitations in the current implementation of
+the URL Generator Service:
+
+1. URL generator generate only the URL of the destination. However there is
+ also the ability to use location state with `core.application.navigateToApp`
+ navigation method.
+ 1. __Will do:__ The new locators will also generate the location state, which
+ will be used in `.navigateToApp` method.
+1. URL generators are available only on the client-side. There is no way to use
+ them together with short URLs.
+ 1. __Will do:__ We will implement locators also on the server-side
+ (they will be available in both environments) and we will combine them
+ with the Short URL Service.
+1. URL generators are not exposed externally, thus Cloud and Support cannot use
+ them to generate deep links into Kibana.
+ 1. __Will do:__ We will expose HTTP endpoints on the server-side and the
+ "redirect" app on the client-side which external users will be able to use
+ to deep link into Kibana using locators.
+
+
+## Limitations of the architecture
+
+One major reason we want to "refresh" the Short URL Service and the URL
+Generator Service is their architecture.
+
+Currently, the Short URL Service is implemented on top of the `url` type saved
+object on the server-side. However, it only exposes the
+HTTP endpoints, it does not expose any API on the server for the server-side
+plugins to consume; on the client-side there is no plugin API either, developers
+need to manually execute HTTP requests.
+
+The URL Generator Service is only available on the client-side, there is no way
+to use it on the server-side, yet we already have use cases (for example ML
+team) where a server-side plugin wants to use a URL generator.
+
+![Current Short URL Service and URL Generator Service architecture](../images/url_service/old_architecture.png)
+
+The current architecture does not allow both services to be conveniently used,
+also as they are implemented in different locations, they are disjointed—
+we cannot create a short URL using an URL generator.
+
+
+# Detailed design
+
+In general we will try to provide as much as possible the same API on the
+server-side and the client-side.
+
+
+## High level architecture
+
+Below diagram shows the proposed architecture of the URL Service.
+
+![URL Service architecture](../images/url_service/new_architecture.png)
+
+
+## Plugin contracts
+
+The aim is to provide developers the same experience on the server and browser.
+
+Below are preliminary interfaces of the new URL Service. `IUrlService` will be
+a shared interface defined in `/common` folder shared across server and browser.
+This will allow us to provide users a common API interface on the server and
+browser, wherever they choose to use the URL Service:
+
+```ts
+/**
+ * Common URL Service client interface for the server-side and the client-side.
+ */
+interface IUrlService {
+ locators: ILocatorClient;
+ shortUrls: IShortUrlClient;
+}
+```
+
+
+### Locators
+
+The locator business logic will be contained in `ILocatorClient` client and will
+provide two main functionalities:
+
+1. It will provide a facility to create locators.
+1. It will also be a registry of locators, every newly created locator is
+ automatically added to the registry. The registry should never be used when
+ locator ID is known at the compile time, but is reserved only for use cases
+ when we only know ID of a locator at runtime.
+
+```ts
+interface ILocatorClient {
+ create(definition: LocatorDefinition
): Locator
;
+ get
(id: string): Locator
;
+}
+```
+
+The `LocatorDefinition` interface is a developer-friendly interface for creating
+new locators. Mainly two things will be required from each new locator:
+
+1. Implement the `getLocation()` method, which gives the locator specific `params`
+ object returns a Kibana location, see description of `KibanaLocation` below.
+2. Implement the `PersistableState` interface which we use in Kibana. This will
+ allow to migrate the locator `params`. Implementation of the `PersistableState`
+ interface will replace the `.isDeprecated` and `.migrate()` properties of URL
+ generators.
+
+
+```ts
+interface LocatorDefinition
extends PeristableState
{
+ id: string;
+ getLocation(params: P): KibanaLocation;
+}
+```
+
+Each constructed locator will have the following interface:
+
+```ts
+interface Locator
{
+ /** Creates a new short URL saved object using this locator. */
+ createShortUrl(params: P, slug?: string): Promise;
+ /** Returns a relative URL to the client-side redirect endpoint using this locator. */
+ getRedirectPath(params: P): string;
+ /** Navigate using core.application.navigateToApp() using this locator. */
+ navigate(params: P): void; // Only on browser.
+}
+```
+
+
+### Short URLs
+
+The short URL client `IShortUrlClient` which will be the same on the server and
+browser. However, the server and browser might add extra utility methods for
+convenience.
+
+```ts
+/**
+ * CRUD-like API for short URLs.
+ */
+interface IShortUrlClient {
+ /**
+ * Delete a short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ * @return Returns true if deletion was successful.
+ */
+ delete(slug: string): Promise;
+
+ /**
+ * Fetch short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ get(slug: string): Promise;
+
+ /**
+ * Same as `get()` but it also increments the "view" counter and the
+ * "last view" timestamp of this short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ resolve(slug: string): Promise;
+}
+```
+
+Note, that in this new service to create a short URL the developer will have to
+use a locator (instead of creating it directly from a long URL).
+
+```ts
+const shortUrl = await plugins.share.shortUrls.create(
+ plugins.discover.locator,
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ },
+ 'optional-human-readable-slug',
+);
+```
+
+These short URLs will be stored in saved objects of type `url` and will be
+automatically migrated using the locator. The long URL will NOT be stored in the
+saved object. The locator ID and locator params will be stored in the saved
+object, that will allow us to do the migrations for short URLs.
+
+
+### `KibanaLocation` interface
+
+The `KibanaLocation` interface is a simple interface to store a location in some
+Kibana application.
+
+```ts
+interface KibanaLocation {
+ app: string;
+ route: string;
+ state: object;
+}
+```
+
+It maps directly to a `.navigateToApp()` call.
+
+```ts
+let location: KibanaLocation;
+
+core.application.navigateToApp(location.app, {
+ route: location.route,
+ state: location.state,
+});
+```
+
+
+## HTTP endpoints
+
+
+### Short URL CRUD+ HTTP endpoints
+
+Below HTTP endpoints are designed to work specifically with short URLs:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __POST__ | `/api/short_url` | Endpoint for creating new short URLs. |
+| __GET__ | `/api/short_url/` | Endpoint for retrieving information about an existing short URL. |
+| __DELETE__ | `/api/short_url/` | Endpoint for deleting an existing short URL. |
+| __POST__ | `/api/short_url/` | Endpoint for updating information about an existing short URL. |
+| __POST__ | `/api/short_url//_resolve` | Similar to `GET /api/short_url/`, but also increments the short URL access count counter and the last access timestamp. |
+
+
+### The client-side navigate endpoint
+
+__NOTE.__ We are currently investigating if we really need this endpoint. The
+main user of it was expected to be Cloud and Support to deeply link into Kibana,
+but we are now reconsidering if we want to support this endpoint and possibly
+find a different solution.
+
+The `/app/goto/_redirect/?params=...¶msVersion=...` client-side
+endpoint will receive the locator ID and locator params, it will use those to
+find the locator and execute `locator.navigate(params)` method.
+
+The `paramsVersion` parameter will be used to specify the version of the
+`params` parameter. If the version is behind the latest version, then the migration
+facilities of the locator will be used to on-the-fly migrate the `params` to the
+latest version.
+
+
+### Legacy endpoints
+
+Below are the legacy HTTP endpoints implemented by the `share` plugin, with a
+plan of action for each endpoint:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __ANY__ | `/goto/` | Endpoint for redirecting short URLs, we will keep it to redirect short URLs. |
+| __GET__ | `/api/short_url/` | The new `GET /api/short_url/` endpoint will return a superset of the payload that the legacy endpoint now returns. |
+| __POST__ | `/api/shorten_url` | The legacy endpoints for creating short URLs. We will remove it or deprecate this endpoint and maintain it until 8.0 major release. |
+
+
+# Drawbacks
+
+Why should we *not* do this?
+
+- Implementation cost will be a few weeks, but the code complexity and quality
+ will improve.
+- There is a cost of migrating existing Kibana plugins to use the new API.
+
+
+# Alternatives
+
+We haven't considered other design alternatives.
+
+One alternative is still do the short URL improvements outlined above. But
+reconsider URL generators:
+
+- Do we need URL generators at all?
+ - Kibana URLs are not stable and have changed in our past experience. Hence,
+ the URL generators were created to make the URL generator parameters stable
+ unless a migration is available.
+- Do we want to put migration support in URL generators?
+ - Alternative would be for each app to support URLs forever or do the
+ migrations on the fly for old URLs.
+- Should Kibana URLs be stable and break only during major releases?
+- Should the Kibana application interface be extended such that some version of
+ URL generators is built in?
+
+The impact of not doing this change is essentially extending technical debt.
+
+
+# Adoption strategy
+
+Is this a breaking change? It is a breaking change in the sense that the API
+will change. However, all the existing use cases will be supported. When
+implementing this we will also adjust all Kibana code to use the new API. From
+the perspective of the developers when using the existing URL services nothing
+will change, they will simply need to review a PR which stops using the URL
+Generator Service and uses the combined URL Service instead, which will provide
+a superset of features.
+
+Alternatively, we can deprecate the URL Generator Service and maintain it for a
+few minor releases.
+
+
+# How we teach this
+
+For the existing short URL and URL generator functionality there is nothing to
+teach, as they will continue working with a largely similar API.
+
+Everything else in the new URL Service will have JSDoc comments and good
+documentation on our website.
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 8c1753c2cabab..18133ebec3353 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -1224,7 +1224,7 @@ export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts
delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType;
// Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts
- find: (options: SavedObjectsFindOptions_2) => Promise>;
+ find: (options: SavedObjectsFindOptions_2) => Promise>;
get: (type: string, id: string) => Promise>;
update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>;
}
@@ -1244,6 +1244,8 @@ export interface SavedObjectsCreateOptions {
// @public (undocumented)
export interface SavedObjectsFindOptions {
+ // @alpha
+ aggs?: Record;
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
// Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts
@@ -1284,7 +1286,9 @@ export interface SavedObjectsFindOptionsReference {
}
// @public
-export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+ // (undocumented)
+ aggregations?: A;
// (undocumented)
page: number;
// (undocumented)
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts
index 44466025de7e3..782ffa6897048 100644
--- a/src/core/public/saved_objects/saved_objects_client.ts
+++ b/src/core/public/saved_objects/saved_objects_client.ts
@@ -103,7 +103,9 @@ export interface SavedObjectsDeleteOptions {
*
* @public
*/
-export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+export interface SavedObjectsFindResponsePublic
+ extends SavedObjectsBatchResponse {
+ aggregations?: A;
total: number;
perPage: number;
page: number;
@@ -310,7 +312,7 @@ export class SavedObjectsClient {
* @property {object} [options.hasReference] - { type, id }
* @returns A find result with objects matching the specified search.
*/
- public find = (
+ public find = (
options: SavedObjectsFindOptions
): Promise> => {
const path = this.getPath(['_find']);
@@ -326,6 +328,7 @@ export class SavedObjectsClient {
sortField: 'sort_field',
type: 'type',
filter: 'filter',
+ aggs: 'aggs',
namespaces: 'namespaces',
preference: 'preference',
};
@@ -342,6 +345,12 @@ export class SavedObjectsClient {
query.has_reference = JSON.stringify(query.has_reference);
}
+ // `aggs` is a structured object. we need to stringify it before sending it, as `fetch`
+ // is not doing it implicitly.
+ if (query.aggs) {
+ query.aggs = JSON.stringify(query.aggs);
+ }
+
const request: ReturnType = this.savedObjectsFetch(path, {
method: 'GET',
query,
@@ -349,6 +358,7 @@ export class SavedObjectsClient {
return request.then((resp) => {
return renameKeys(
{
+ aggregations: 'aggregations',
saved_objects: 'savedObjects',
total: 'total',
per_page: 'perPage',
diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts
index 6ba23747cf374..d21039db30e5f 100644
--- a/src/core/server/saved_objects/routes/find.ts
+++ b/src/core/server/saved_objects/routes/find.ts
@@ -44,6 +44,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen
has_reference_operator: searchOperatorSchema,
fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
filter: schema.maybe(schema.string()),
+ aggs: schema.maybe(schema.string()),
namespaces: schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
),
@@ -59,6 +60,20 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsFind({ request: req }).catch(() => {});
+ // manually validation to avoid using JSON.parse twice
+ let aggs;
+ if (query.aggs) {
+ try {
+ aggs = JSON.parse(query.aggs);
+ } catch (e) {
+ return res.badRequest({
+ body: {
+ message: 'invalid aggs value',
+ },
+ });
+ }
+ }
+
const result = await context.core.savedObjects.client.find({
perPage: query.per_page,
page: query.page,
@@ -72,6 +87,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen
hasReferenceOperator: query.has_reference_operator,
fields: typeof query.fields === 'string' ? [query.fields] : query.fields,
filter: query.filter,
+ aggs,
namespaces,
});
diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts
new file mode 100644
index 0000000000000..1508cab69a048
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.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 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 { schema as s, ObjectType } from '@kbn/config-schema';
+
+/**
+ * Schemas for the Bucket aggregations.
+ *
+ * Currently supported:
+ * - filter
+ * - histogram
+ * - terms
+ *
+ * Not implemented:
+ * - adjacency_matrix
+ * - auto_date_histogram
+ * - children
+ * - composite
+ * - date_histogram
+ * - date_range
+ * - diversified_sampler
+ * - filters
+ * - geo_distance
+ * - geohash_grid
+ * - geotile_grid
+ * - global
+ * - ip_range
+ * - missing
+ * - multi_terms
+ * - nested
+ * - parent
+ * - range
+ * - rare_terms
+ * - reverse_nested
+ * - sampler
+ * - significant_terms
+ * - significant_text
+ * - variable_width_histogram
+ */
+export const bucketAggsSchemas: Record = {
+ filter: s.object({
+ term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])),
+ }),
+ histogram: s.object({
+ field: s.maybe(s.string()),
+ interval: s.maybe(s.number()),
+ min_doc_count: s.maybe(s.number()),
+ extended_bounds: s.maybe(
+ s.object({
+ min: s.number(),
+ max: s.number(),
+ })
+ ),
+ hard_bounds: s.maybe(
+ s.object({
+ min: s.number(),
+ max: s.number(),
+ })
+ ),
+ missing: s.maybe(s.number()),
+ keyed: s.maybe(s.boolean()),
+ order: s.maybe(
+ s.object({
+ _count: s.string(),
+ _key: s.string(),
+ })
+ ),
+ }),
+ terms: s.object({
+ field: s.maybe(s.string()),
+ collect_mode: s.maybe(s.string()),
+ exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
+ include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
+ execution_hint: s.maybe(s.string()),
+ missing: s.maybe(s.number()),
+ min_doc_count: s.maybe(s.number()),
+ size: s.maybe(s.number()),
+ show_term_doc_count_error: s.maybe(s.boolean()),
+ order: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])),
+ }),
+};
diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts
new file mode 100644
index 0000000000000..7967fad0185fb
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/index.ts
@@ -0,0 +1,15 @@
+/*
+ * 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 { bucketAggsSchemas } from './bucket_aggs';
+import { metricsAggsSchemas } from './metrics_aggs';
+
+export const aggregationSchemas = {
+ ...metricsAggsSchemas,
+ ...bucketAggsSchemas,
+};
diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts
new file mode 100644
index 0000000000000..c05ae67cd2164
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/metrics_aggs.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 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 { schema as s, ObjectType } from '@kbn/config-schema';
+
+/**
+ * Schemas for the metrics Aggregations
+ *
+ * Currently supported:
+ * - avg
+ * - cardinality
+ * - min
+ * - max
+ * - sum
+ * - top_hits
+ * - weighted_avg
+ *
+ * Not implemented:
+ * - boxplot
+ * - extended_stats
+ * - geo_bounds
+ * - geo_centroid
+ * - geo_line
+ * - matrix_stats
+ * - median_absolute_deviation
+ * - percentile_ranks
+ * - percentiles
+ * - rate
+ * - scripted_metric
+ * - stats
+ * - string_stats
+ * - t_test
+ * - value_count
+ */
+export const metricsAggsSchemas: Record = {
+ avg: s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])),
+ }),
+ cardinality: s.object({
+ field: s.maybe(s.string()),
+ precision_threshold: s.maybe(s.number()),
+ rehash: s.maybe(s.boolean()),
+ missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])),
+ }),
+ min: s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])),
+ format: s.maybe(s.string()),
+ }),
+ max: s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])),
+ format: s.maybe(s.string()),
+ }),
+ sum: s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.oneOf([s.string(), s.number(), s.boolean()])),
+ }),
+ top_hits: s.object({
+ explain: s.maybe(s.boolean()),
+ docvalue_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
+ stored_fields: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])),
+ from: s.maybe(s.number()),
+ size: s.maybe(s.number()),
+ sort: s.maybe(s.oneOf([s.literal('asc'), s.literal('desc')])),
+ seq_no_primary_term: s.maybe(s.boolean()),
+ version: s.maybe(s.boolean()),
+ track_scores: s.maybe(s.boolean()),
+ highlight: s.maybe(s.any()),
+ _source: s.maybe(s.oneOf([s.boolean(), s.string(), s.arrayOf(s.string())])),
+ }),
+ weighted_avg: s.object({
+ format: s.maybe(s.string()),
+ value_type: s.maybe(s.string()),
+ value: s.maybe(
+ s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.number()),
+ })
+ ),
+ weight: s.maybe(
+ s.object({
+ field: s.maybe(s.string()),
+ missing: s.maybe(s.number()),
+ })
+ ),
+ }),
+};
diff --git a/src/core/server/saved_objects/service/lib/aggregations/index.ts b/src/core/server/saved_objects/service/lib/aggregations/index.ts
new file mode 100644
index 0000000000000..f71d3e8daea9d
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/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 { validateAndConvertAggregations } from './validation';
diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts
new file mode 100644
index 0000000000000..8a7c1c3719eb0
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts
@@ -0,0 +1,431 @@
+/*
+ * 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 { estypes } from '@elastic/elasticsearch';
+import { validateAndConvertAggregations } from './validation';
+
+type AggsMap = Record;
+
+const mockMappings = {
+ properties: {
+ updated_at: {
+ type: 'date',
+ },
+ foo: {
+ properties: {
+ title: {
+ type: 'text',
+ },
+ description: {
+ type: 'text',
+ },
+ bytes: {
+ type: 'number',
+ },
+ },
+ },
+ bean: {
+ properties: {
+ canned: {
+ fields: {
+ text: {
+ type: 'text',
+ },
+ },
+ type: 'keyword',
+ },
+ },
+ },
+ alert: {
+ properties: {
+ actions: {
+ type: 'nested',
+ properties: {
+ group: {
+ type: 'keyword',
+ },
+ actionRef: {
+ type: 'keyword',
+ },
+ actionTypeId: {
+ type: 'keyword',
+ },
+ params: {
+ enabled: false,
+ type: 'object',
+ },
+ },
+ },
+ params: {
+ type: 'flattened',
+ },
+ },
+ },
+ },
+};
+
+describe('validateAndConvertAggregations', () => {
+ it('validates a simple aggregations', () => {
+ expect(
+ validateAndConvertAggregations(
+ ['foo'],
+ { aggName: { max: { field: 'foo.attributes.bytes' } } },
+ mockMappings
+ )
+ ).toEqual({
+ aggName: {
+ max: {
+ field: 'foo.bytes',
+ },
+ },
+ });
+ });
+
+ it('validates a nested field in simple aggregations', () => {
+ expect(
+ validateAndConvertAggregations(
+ ['alert'],
+ { aggName: { cardinality: { field: 'alert.attributes.actions.group' } } },
+ mockMappings
+ )
+ ).toEqual({
+ aggName: {
+ cardinality: {
+ field: 'alert.actions.group',
+ },
+ },
+ });
+ });
+
+ it('validates a nested aggregations', () => {
+ expect(
+ validateAndConvertAggregations(
+ ['alert'],
+ {
+ aggName: {
+ cardinality: {
+ field: 'alert.attributes.actions.group',
+ },
+ aggs: {
+ aggName: {
+ max: { field: 'alert.attributes.actions.group' },
+ },
+ },
+ },
+ },
+ mockMappings
+ )
+ ).toEqual({
+ aggName: {
+ cardinality: {
+ field: 'alert.actions.group',
+ },
+ aggs: {
+ aggName: {
+ max: {
+ field: 'alert.actions.group',
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it('validates a deeply nested aggregations', () => {
+ expect(
+ validateAndConvertAggregations(
+ ['alert'],
+ {
+ first: {
+ cardinality: {
+ field: 'alert.attributes.actions.group',
+ },
+ aggs: {
+ second: {
+ max: { field: 'alert.attributes.actions.group' },
+ aggs: {
+ third: {
+ min: {
+ field: 'alert.attributes.actions.actionTypeId',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ mockMappings
+ )
+ ).toEqual({
+ first: {
+ cardinality: {
+ field: 'alert.actions.group',
+ },
+ aggs: {
+ second: {
+ max: { field: 'alert.actions.group' },
+ aggs: {
+ third: {
+ min: {
+ field: 'alert.actions.actionTypeId',
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it('rewrites type attributes when valid', () => {
+ const aggregations: AggsMap = {
+ average: {
+ avg: {
+ field: 'alert.attributes.actions.group',
+ missing: 10,
+ },
+ },
+ };
+ expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({
+ average: {
+ avg: {
+ field: 'alert.actions.group',
+ missing: 10,
+ },
+ },
+ });
+ });
+
+ it('rewrites root attributes when valid', () => {
+ const aggregations: AggsMap = {
+ average: {
+ avg: {
+ field: 'alert.updated_at',
+ missing: 10,
+ },
+ },
+ };
+ expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({
+ average: {
+ avg: {
+ field: 'updated_at',
+ missing: 10,
+ },
+ },
+ });
+ });
+
+ it('throws an error when the `field` name is not using attributes path', () => {
+ const aggregations: AggsMap = {
+ average: {
+ avg: {
+ field: 'alert.actions.group',
+ missing: 10,
+ },
+ },
+ };
+ expect(() =>
+ validateAndConvertAggregations(['alert'], aggregations, mockMappings)
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[average.avg.field] Invalid attribute path: alert.actions.group"`
+ );
+ });
+
+ it('throws an error when the `field` name is referencing an invalid field', () => {
+ const aggregations: AggsMap = {
+ average: {
+ avg: {
+ field: 'alert.attributes.actions.non_existing',
+ missing: 10,
+ },
+ },
+ };
+ expect(() =>
+ validateAndConvertAggregations(['alert'], aggregations, mockMappings)
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[average.avg.field] Invalid attribute path: alert.attributes.actions.non_existing"`
+ );
+ });
+
+ it('throws an error when the attribute path is referencing an invalid root field', () => {
+ const aggregations: AggsMap = {
+ average: {
+ avg: {
+ field: 'alert.bad_root',
+ missing: 10,
+ },
+ },
+ };
+ expect(() =>
+ validateAndConvertAggregations(['alert'], aggregations, mockMappings)
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[average.avg.field] Invalid attribute path: alert.bad_root"`
+ );
+ });
+
+ it('rewrites the `field` name even when nested', () => {
+ const aggregations: AggsMap = {
+ average: {
+ weighted_avg: {
+ value: {
+ field: 'alert.attributes.actions.group',
+ missing: 10,
+ },
+ weight: {
+ field: 'alert.attributes.actions.actionRef',
+ },
+ },
+ },
+ };
+ expect(validateAndConvertAggregations(['alert'], aggregations, mockMappings)).toEqual({
+ average: {
+ weighted_avg: {
+ value: {
+ field: 'alert.actions.group',
+ missing: 10,
+ },
+ weight: {
+ field: 'alert.actions.actionRef',
+ },
+ },
+ },
+ });
+ });
+
+ it('rewrites the entries of a filter term record', () => {
+ const aggregations: AggsMap = {
+ myFilter: {
+ filter: {
+ term: {
+ 'foo.attributes.description': 'hello',
+ 'foo.attributes.bytes': 10,
+ },
+ },
+ },
+ };
+ expect(validateAndConvertAggregations(['foo'], aggregations, mockMappings)).toEqual({
+ myFilter: {
+ filter: {
+ term: { 'foo.description': 'hello', 'foo.bytes': 10 },
+ },
+ },
+ });
+ });
+
+ it('throws an error when referencing non-allowed types', () => {
+ const aggregations: AggsMap = {
+ myFilter: {
+ max: {
+ field: 'foo.attributes.bytes',
+ },
+ },
+ };
+
+ expect(() => {
+ validateAndConvertAggregations(['alert'], aggregations, mockMappings);
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"[myFilter.max.field] Invalid attribute path: foo.attributes.bytes"`
+ );
+ });
+
+ it('throws an error when an attributes is not respecting its schema definition', () => {
+ const aggregations: AggsMap = {
+ someAgg: {
+ terms: {
+ missing: 'expecting a number',
+ },
+ },
+ };
+
+ expect(() =>
+ validateAndConvertAggregations(['alert'], aggregations, mockMappings)
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[someAgg.terms.missing]: expected value of type [number] but got [string]"`
+ );
+ });
+
+ it('throws an error when trying to validate an unknown aggregation type', () => {
+ const aggregations: AggsMap = {
+ someAgg: {
+ auto_date_histogram: {
+ field: 'foo.attributes.bytes',
+ },
+ },
+ };
+
+ expect(() => {
+ validateAndConvertAggregations(['foo'], aggregations, mockMappings);
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"[someAgg.auto_date_histogram] auto_date_histogram aggregation is not valid (or not registered yet)"`
+ );
+ });
+
+ it('throws an error when a child aggregation is unknown', () => {
+ const aggregations: AggsMap = {
+ someAgg: {
+ max: {
+ field: 'foo.attributes.bytes',
+ },
+ aggs: {
+ unknownAgg: {
+ cumulative_cardinality: {
+ format: 'format',
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateAndConvertAggregations(['foo'], aggregations, mockMappings);
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"[someAgg.aggs.unknownAgg.cumulative_cardinality] cumulative_cardinality aggregation is not valid (or not registered yet)"`
+ );
+ });
+
+ it('throws an error when using a script attribute', () => {
+ const aggregations: AggsMap = {
+ someAgg: {
+ max: {
+ field: 'foo.attributes.bytes',
+ script: 'This is a bad script',
+ },
+ },
+ };
+
+ expect(() => {
+ validateAndConvertAggregations(['foo'], aggregations, mockMappings);
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"[someAgg.max.script]: definition for this key is missing"`
+ );
+ });
+
+ it('throws an error when using a script attribute in a nested aggregation', () => {
+ const aggregations: AggsMap = {
+ someAgg: {
+ min: {
+ field: 'foo.attributes.bytes',
+ },
+ aggs: {
+ nested: {
+ max: {
+ field: 'foo.attributes.bytes',
+ script: 'This is a bad script',
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => {
+ validateAndConvertAggregations(['foo'], aggregations, mockMappings);
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"[someAgg.aggs.nested.max.script]: definition for this key is missing"`
+ );
+ });
+});
diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts
new file mode 100644
index 0000000000000..a2fd392183132
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts
@@ -0,0 +1,229 @@
+/*
+ * 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 { estypes } from '@elastic/elasticsearch';
+import { ObjectType } from '@kbn/config-schema';
+import { isPlainObject } from 'lodash';
+
+import { IndexMapping } from '../../../mappings';
+import {
+ isObjectTypeAttribute,
+ rewriteObjectTypeAttribute,
+ isRootLevelAttribute,
+ rewriteRootLevelAttribute,
+} from './validation_utils';
+import { aggregationSchemas } from './aggs_types';
+
+const aggregationKeys = ['aggs', 'aggregations'];
+
+interface ValidationContext {
+ allowedTypes: string[];
+ indexMapping: IndexMapping;
+ currentPath: string[];
+}
+
+/**
+ * Validate an aggregation structure against the declared mappings and
+ * aggregation schemas, and rewrite the attribute fields using the KQL-like syntax
+ * - `{type}.attributes.{attribute}` to `{type}.{attribute}`
+ * - `{type}.{rootField}` to `{rootField}`
+ *
+ * throws on the first validation error if any is encountered.
+ */
+export const validateAndConvertAggregations = (
+ allowedTypes: string[],
+ aggs: Record,
+ indexMapping: IndexMapping
+): Record => {
+ return validateAggregations(aggs, {
+ allowedTypes,
+ indexMapping,
+ currentPath: [],
+ });
+};
+
+/**
+ * Validate a record of aggregation containers,
+ * Which can either be the root level aggregations (`SearchRequest.body.aggs`)
+ * Or a nested record of aggregation (`SearchRequest.body.aggs.myAggregation.aggs`)
+ */
+const validateAggregations = (
+ aggregations: Record,
+ context: ValidationContext
+) => {
+ return Object.entries(aggregations).reduce((memo, [aggrName, aggrContainer]) => {
+ memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName));
+ return memo;
+ }, {} as Record);
+};
+
+/**
+ * Validate an aggregation container, e.g an entry of `SearchRequest.body.aggs`, or
+ * from a nested aggregation record, including its potential nested aggregations.
+ */
+const validateAggregation = (
+ aggregation: estypes.AggregationContainer,
+ context: ValidationContext
+) => {
+ const container = validateAggregationContainer(aggregation, context);
+
+ if (aggregation.aggregations) {
+ container.aggregations = validateAggregations(
+ aggregation.aggregations,
+ childContext(context, 'aggregations')
+ );
+ }
+ if (aggregation.aggs) {
+ container.aggs = validateAggregations(aggregation.aggs, childContext(context, 'aggs'));
+ }
+
+ return container;
+};
+
+/**
+ * Validates root-level aggregation of given aggregation container
+ * (ignoring its nested aggregations)
+ */
+const validateAggregationContainer = (
+ container: estypes.AggregationContainer,
+ context: ValidationContext
+) => {
+ return Object.entries(container).reduce((memo, [aggName, aggregation]) => {
+ if (aggregationKeys.includes(aggName)) {
+ return memo;
+ }
+ return {
+ ...memo,
+ [aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)),
+ };
+ }, {} as estypes.AggregationContainer);
+};
+
+const validateAggregationType = (
+ aggregationType: string,
+ aggregation: Record,
+ context: ValidationContext
+) => {
+ const aggregationSchema = aggregationSchemas[aggregationType];
+ if (!aggregationSchema) {
+ throw new Error(
+ `[${context.currentPath.join(
+ '.'
+ )}] ${aggregationType} aggregation is not valid (or not registered yet)`
+ );
+ }
+
+ validateAggregationStructure(aggregationSchema, aggregation, context);
+ return validateAndRewriteFieldAttributes(aggregation, context);
+};
+
+/**
+ * Validate an aggregation structure against its declared schema.
+ */
+const validateAggregationStructure = (
+ schema: ObjectType,
+ aggObject: unknown,
+ context: ValidationContext
+) => {
+ return schema.validate(aggObject, {}, context.currentPath.join('.'));
+};
+
+/**
+ * List of fields that have an attribute path as value
+ *
+ * @example
+ * ```ts
+ * avg: {
+ * field: 'alert.attributes.actions.group',
+ * },
+ * ```
+ */
+const attributeFields = ['field'];
+/**
+ * List of fields that have a Record as value
+ *
+ * @example
+ * ```ts
+ * filter: {
+ * term: {
+ * 'alert.attributes.actions.group': 'value'
+ * },
+ * },
+ * ```
+ */
+const attributeMaps = ['term'];
+
+const validateAndRewriteFieldAttributes = (
+ aggregation: Record,
+ context: ValidationContext
+) => {
+ return recursiveRewrite(aggregation, context, []);
+};
+
+const recursiveRewrite = (
+ currentLevel: Record,
+ context: ValidationContext,
+ parents: string[]
+): Record => {
+ return Object.entries(currentLevel).reduce((memo, [key, value]) => {
+ const rewriteKey = isAttributeKey(parents);
+ const rewriteValue = isAttributeValue(key, value);
+
+ const nestedContext = childContext(context, key);
+ const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key;
+ const newValue = rewriteValue
+ ? validateAndRewriteAttributePath(value, nestedContext)
+ : isPlainObject(value)
+ ? recursiveRewrite(value, nestedContext, [...parents, key])
+ : value;
+
+ return {
+ ...memo,
+ [newKey]: newValue,
+ };
+ }, {});
+};
+
+const childContext = (context: ValidationContext, path: string): ValidationContext => {
+ return {
+ ...context,
+ currentPath: [...context.currentPath, path],
+ };
+};
+
+const lastParent = (parents: string[]) => {
+ if (parents.length) {
+ return parents[parents.length - 1];
+ }
+ return undefined;
+};
+
+const isAttributeKey = (parents: string[]) => {
+ const last = lastParent(parents);
+ if (last) {
+ return attributeMaps.includes(last);
+ }
+ return false;
+};
+
+const isAttributeValue = (fieldName: string, fieldValue: unknown): boolean => {
+ return attributeFields.includes(fieldName) && typeof fieldValue === 'string';
+};
+
+const validateAndRewriteAttributePath = (
+ attributePath: string,
+ { allowedTypes, indexMapping, currentPath }: ValidationContext
+) => {
+ if (isRootLevelAttribute(attributePath, indexMapping, allowedTypes)) {
+ return rewriteRootLevelAttribute(attributePath);
+ }
+ if (isObjectTypeAttribute(attributePath, indexMapping, allowedTypes)) {
+ return rewriteObjectTypeAttribute(attributePath);
+ }
+ throw new Error(`[${currentPath.join('.')}] Invalid attribute path: ${attributePath}`);
+};
diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts
new file mode 100644
index 0000000000000..25c3aea474ece
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.test.ts
@@ -0,0 +1,148 @@
+/*
+ * 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 { IndexMapping } from '../../../mappings';
+import {
+ isRootLevelAttribute,
+ rewriteRootLevelAttribute,
+ isObjectTypeAttribute,
+ rewriteObjectTypeAttribute,
+} from './validation_utils';
+
+const mockMappings: IndexMapping = {
+ properties: {
+ updated_at: {
+ type: 'date',
+ },
+ foo: {
+ properties: {
+ title: {
+ type: 'text',
+ },
+ description: {
+ type: 'text',
+ },
+ bytes: {
+ type: 'number',
+ },
+ },
+ },
+ bean: {
+ properties: {
+ canned: {
+ fields: {
+ text: {
+ type: 'text',
+ },
+ },
+ type: 'keyword',
+ },
+ },
+ },
+ alert: {
+ properties: {
+ actions: {
+ type: 'nested',
+ properties: {
+ group: {
+ type: 'keyword',
+ },
+ actionRef: {
+ type: 'keyword',
+ },
+ actionTypeId: {
+ type: 'keyword',
+ },
+ params: {
+ enabled: false,
+ type: 'object',
+ },
+ },
+ },
+ params: {
+ type: 'flattened',
+ },
+ },
+ },
+ },
+};
+
+describe('isRootLevelAttribute', () => {
+ it('returns true when referring to a path to a valid root level field', () => {
+ expect(isRootLevelAttribute('foo.updated_at', mockMappings, ['foo'])).toBe(true);
+ });
+ it('returns false when referring to a direct path to a valid root level field', () => {
+ expect(isRootLevelAttribute('updated_at', mockMappings, ['foo'])).toBe(false);
+ });
+ it('returns false when referring to a path to a unknown root level field', () => {
+ expect(isRootLevelAttribute('foo.not_present', mockMappings, ['foo'])).toBe(false);
+ });
+ it('returns false when referring to a path to an existing nested field', () => {
+ expect(isRootLevelAttribute('foo.properties.title', mockMappings, ['foo'])).toBe(false);
+ });
+ it('returns false when referring to a path to a valid root level field of an unknown type', () => {
+ expect(isRootLevelAttribute('bar.updated_at', mockMappings, ['foo'])).toBe(false);
+ });
+ it('returns false when referring to a path to a valid root level type field', () => {
+ expect(isRootLevelAttribute('foo.foo', mockMappings, ['foo'])).toBe(false);
+ });
+});
+
+describe('rewriteRootLevelAttribute', () => {
+ it('rewrites the attribute path to strip the type', () => {
+ expect(rewriteRootLevelAttribute('foo.references')).toEqual('references');
+ });
+ it('does not handle real root level path', () => {
+ expect(rewriteRootLevelAttribute('references')).not.toEqual('references');
+ });
+});
+
+describe('isObjectTypeAttribute', () => {
+ it('return true if attribute path is valid', () => {
+ expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['foo'])).toEqual(
+ true
+ );
+ });
+
+ it('return true for nested attributes', () => {
+ expect(isObjectTypeAttribute('bean.attributes.canned.text', mockMappings, ['bean'])).toEqual(
+ true
+ );
+ });
+
+ it('return false if attribute path points to an invalid type', () => {
+ expect(isObjectTypeAttribute('foo.attributes.description', mockMappings, ['bean'])).toEqual(
+ false
+ );
+ });
+
+ it('returns false if attribute path refers to a type', () => {
+ expect(isObjectTypeAttribute('bean', mockMappings, ['bean'])).toEqual(false);
+ });
+
+ it('Return error if key does not match SO attribute structure', () => {
+ expect(isObjectTypeAttribute('bean.canned.text', mockMappings, ['bean'])).toEqual(false);
+ });
+
+ it('Return false if key matches nested type attribute parent', () => {
+ expect(isObjectTypeAttribute('alert.actions', mockMappings, ['alert'])).toEqual(false);
+ });
+
+ it('returns false if path refers to a non-existent attribute', () => {
+ expect(isObjectTypeAttribute('bean.attributes.red', mockMappings, ['bean'])).toEqual(false);
+ });
+});
+
+describe('rewriteObjectTypeAttribute', () => {
+ it('rewrites the attribute path to strip the type', () => {
+ expect(rewriteObjectTypeAttribute('foo.attributes.prop')).toEqual('foo.prop');
+ });
+ it('returns invalid input unchanged', () => {
+ expect(rewriteObjectTypeAttribute('foo.references')).toEqual('foo.references');
+ });
+});
diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts
new file mode 100644
index 0000000000000..f817497e3759e
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { IndexMapping } from '../../../mappings';
+import { fieldDefined, hasFilterKeyError } from '../filter_utils';
+
+/**
+ * Returns true if the given attribute path is a valid root level SO attribute path
+ *
+ * @example
+ * ```ts
+ * isRootLevelAttribute('myType.updated_at', indexMapping, ['myType']})
+ * // => true
+ * ```
+ */
+export const isRootLevelAttribute = (
+ attributePath: string,
+ indexMapping: IndexMapping,
+ allowedTypes: string[]
+): boolean => {
+ const splits = attributePath.split('.');
+ if (splits.length !== 2) {
+ return false;
+ }
+
+ const [type, fieldName] = splits;
+ if (allowedTypes.includes(fieldName)) {
+ return false;
+ }
+ return allowedTypes.includes(type) && fieldDefined(indexMapping, fieldName);
+};
+
+/**
+ * Rewrites a root level attribute path to strip the type
+ *
+ * @example
+ * ```ts
+ * rewriteRootLevelAttribute('myType.updated_at')
+ * // => 'updated_at'
+ * ```
+ */
+export const rewriteRootLevelAttribute = (attributePath: string) => {
+ return attributePath.split('.')[1];
+};
+
+/**
+ * Returns true if the given attribute path is a valid object type level SO attribute path
+ *
+ * @example
+ * ```ts
+ * isObjectTypeAttribute('myType.attributes.someField', indexMapping, ['myType']})
+ * // => true
+ * ```
+ */
+export const isObjectTypeAttribute = (
+ attributePath: string,
+ indexMapping: IndexMapping,
+ allowedTypes: string[]
+): boolean => {
+ const error = hasFilterKeyError(attributePath, allowedTypes, indexMapping);
+ return error == null;
+};
+
+/**
+ * Rewrites a object type attribute path to strip the type
+ *
+ * @example
+ * ```ts
+ * rewriteObjectTypeAttribute('myType.attributes.foo')
+ * // => 'myType.foo'
+ * ```
+ */
+export const rewriteObjectTypeAttribute = (attributePath: string) => {
+ return attributePath.replace('.attributes', '');
+};
diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts
index b50326627cf09..2ef5219ccfff1 100644
--- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts
+++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts
@@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
+import { cloneDeep } from 'lodash';
// @ts-expect-error no ts
import { esKuery } from '../../es_query';
@@ -18,7 +19,7 @@ import {
const mockMappings = {
properties: {
- updatedAt: {
+ updated_at: {
type: 'date',
},
foo: {
@@ -105,6 +106,22 @@ describe('Filter Utils', () => {
)
).toEqual(esKuery.fromKueryExpression('foo.title: "best"'));
});
+
+ test('does not mutate the input KueryNode', () => {
+ const input = esKuery.nodeTypes.function.buildNode(
+ 'is',
+ `foo.attributes.title`,
+ 'best',
+ true
+ );
+
+ const inputCopy = cloneDeep(input);
+
+ validateConvertFilterToKueryNode(['foo'], input, mockMappings);
+
+ expect(input).toEqual(inputCopy);
+ });
+
test('Validate a simple KQL expression filter', () => {
expect(
validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings)
@@ -123,12 +140,12 @@ describe('Filter Utils', () => {
expect(
validateConvertFilterToKueryNode(
['foo'],
- 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
+ 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockMappings
)
).toEqual(
esKuery.fromKueryExpression(
- '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)'
+ '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)'
)
);
});
@@ -137,12 +154,12 @@ describe('Filter Utils', () => {
expect(
validateConvertFilterToKueryNode(
['foo', 'bar'],
- 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
+ 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockMappings
)
).toEqual(
esKuery.fromKueryExpression(
- '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)'
+ '(type: foo and updated_at: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)'
)
);
});
@@ -151,12 +168,12 @@ describe('Filter Utils', () => {
expect(
validateConvertFilterToKueryNode(
['foo', 'bar'],
- '(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)',
+ '(bar.updated_at: 5678654567 OR foo.updated_at: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)',
mockMappings
)
).toEqual(
esKuery.fromKueryExpression(
- '((type: bar and updatedAt: 5678654567) or (type: foo and updatedAt: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)'
+ '((type: bar and updated_at: 5678654567) or (type: foo and updated_at: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)'
)
);
});
@@ -181,11 +198,11 @@ describe('Filter Utils', () => {
expect(() => {
validateConvertFilterToKueryNode(
['foo', 'bar'],
- 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
+ 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockMappings
);
}).toThrowErrorMatchingInlineSnapshot(
- `"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"`
+ `"This key 'updated_at' need to be wrapped by a saved object type like foo,bar: Bad Request"`
);
});
@@ -200,7 +217,7 @@ describe('Filter Utils', () => {
test('Validate filter query through KueryNode - happy path', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
- 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
+ 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
types: ['foo'],
indexMapping: mockMappings,
@@ -211,7 +228,7 @@ describe('Filter Utils', () => {
astPath: 'arguments.0',
error: null,
isSavedObjectAttr: true,
- key: 'foo.updatedAt',
+ key: 'foo.updated_at',
type: 'foo',
},
{
@@ -275,7 +292,7 @@ describe('Filter Utils', () => {
test('Return Error if key is not wrapper by a saved object type', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
- 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
+ 'updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
types: ['foo'],
indexMapping: mockMappings,
@@ -284,9 +301,9 @@ describe('Filter Utils', () => {
expect(validationObject).toEqual([
{
astPath: 'arguments.0',
- error: "This key 'updatedAt' need to be wrapped by a saved object type like foo",
+ error: "This key 'updated_at' need to be wrapped by a saved object type like foo",
isSavedObjectAttr: true,
- key: 'updatedAt',
+ key: 'updated_at',
type: null,
},
{
@@ -330,7 +347,7 @@ describe('Filter Utils', () => {
test('Return Error if key of a saved object type is not wrapped with attributes', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
- 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)'
+ 'foo.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)'
),
types: ['foo'],
indexMapping: mockMappings,
@@ -341,7 +358,7 @@ describe('Filter Utils', () => {
astPath: 'arguments.0',
error: null,
isSavedObjectAttr: true,
- key: 'foo.updatedAt',
+ key: 'foo.updated_at',
type: 'foo',
},
{
@@ -387,7 +404,7 @@ describe('Filter Utils', () => {
test('Return Error if filter is not using an allowed type', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
- 'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
+ 'bar.updated_at: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
types: ['foo'],
indexMapping: mockMappings,
@@ -398,7 +415,7 @@ describe('Filter Utils', () => {
astPath: 'arguments.0',
error: 'This type bar is not allowed',
isSavedObjectAttr: true,
- key: 'bar.updatedAt',
+ key: 'bar.updated_at',
type: 'bar',
},
{
@@ -442,7 +459,7 @@ describe('Filter Utils', () => {
test('Return Error if filter is using an non-existing key in the index patterns of the saved object type', () => {
const validationObject = validateFilterKueryNode({
astFilter: esKuery.fromKueryExpression(
- 'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
+ 'foo.updated_at33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
types: ['foo'],
indexMapping: mockMappings,
@@ -451,9 +468,9 @@ describe('Filter Utils', () => {
expect(validationObject).toEqual([
{
astPath: 'arguments.0',
- error: "This key 'foo.updatedAt33' does NOT exist in foo saved object index patterns",
+ error: "This key 'foo.updated_at33' does NOT exist in foo saved object index patterns",
isSavedObjectAttr: false,
- key: 'foo.updatedAt33',
+ key: 'foo.updated_at33',
type: 'foo',
},
{
@@ -519,6 +536,33 @@ describe('Filter Utils', () => {
},
]);
});
+
+ test('Validate multiple items nested filter query through KueryNode', () => {
+ const validationObject = validateFilterKueryNode({
+ astFilter: esKuery.fromKueryExpression(
+ 'alert.attributes.actions:{ actionTypeId: ".server-log" AND actionRef: "foo" }'
+ ),
+ types: ['alert'],
+ indexMapping: mockMappings,
+ });
+
+ expect(validationObject).toEqual([
+ {
+ astPath: 'arguments.1.arguments.0',
+ error: null,
+ isSavedObjectAttr: false,
+ key: 'alert.attributes.actions.actionTypeId',
+ type: 'alert',
+ },
+ {
+ astPath: 'arguments.1.arguments.1',
+ error: null,
+ isSavedObjectAttr: false,
+ key: 'alert.attributes.actions.actionRef',
+ type: 'alert',
+ },
+ ]);
+ });
});
describe('#hasFilterKeyError', () => {
diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts
index 688b7ad96e8ed..a41a25a27b70d 100644
--- a/src/core/server/saved_objects/service/lib/filter_utils.ts
+++ b/src/core/server/saved_objects/service/lib/filter_utils.ts
@@ -7,11 +7,12 @@
*/
import { set } from '@elastic/safer-lodash-set';
-import { get } from 'lodash';
+import { get, cloneDeep } from 'lodash';
import { SavedObjectsErrorHelpers } from './errors';
import { IndexMapping } from '../../mappings';
// @ts-expect-error no ts
import { esKuery } from '../../es_query';
+
type KueryNode = any;
const astFunctionType = ['is', 'range', 'nested'];
@@ -23,7 +24,7 @@ export const validateConvertFilterToKueryNode = (
): KueryNode | undefined => {
if (filter && indexMapping) {
const filterKueryNode =
- typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : filter;
+ typeof filter === 'string' ? esKuery.fromKueryExpression(filter) : cloneDeep(filter);
const validationFilterKuery = validateFilterKueryNode({
astFilter: filterKueryNode,
@@ -109,7 +110,15 @@ export const validateFilterKueryNode = ({
return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => {
if (hasNestedKey && ast.type === 'literal' && ast.value != null) {
localNestedKeys = ast.value;
+ } else if (ast.type === 'literal' && ast.value && typeof ast.value === 'string') {
+ const key = ast.value.replace('.attributes', '');
+ const mappingKey = 'properties.' + key.split('.').join('.properties.');
+ const field = get(indexMapping, mappingKey);
+ if (field != null && field.type === 'nested') {
+ localNestedKeys = ast.value;
+ }
}
+
if (ast.arguments) {
const myPath = `${path}.${index}`;
return [
@@ -121,7 +130,7 @@ export const validateFilterKueryNode = ({
storeValue: ast.type === 'function' && astFunctionType.includes(ast.function),
path: `${myPath}.arguments`,
hasNestedKey: ast.type === 'function' && ast.function === 'nested',
- nestedKeys: localNestedKeys,
+ nestedKeys: localNestedKeys || nestedKeys,
}),
];
}
@@ -226,7 +235,7 @@ export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean
return true;
}
- // If the path is for a flattned type field, we'll assume the mappings are defined.
+ // If the path is for a flattened type field, we'll assume the mappings are defined.
const keys = key.split('.');
for (let i = 0; i < keys.length; i++) {
const path = `properties.${keys.slice(0, i + 1).join('.properties.')}`;
diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts
index 7c719ac56a835..c0e2cdc333363 100644
--- a/src/core/server/saved_objects/service/lib/repository.ts
+++ b/src/core/server/saved_objects/service/lib/repository.ts
@@ -66,6 +66,7 @@ import {
import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { validateConvertFilterToKueryNode } from './filter_utils';
+import { validateAndConvertAggregations } from './aggregations';
import {
ALL_NAMESPACES_STRING,
FIND_DEFAULT_PAGE,
@@ -748,7 +749,9 @@ export class SavedObjectsRepository {
* @property {string} [options.preference]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
- async find(options: SavedObjectsFindOptions): Promise> {
+ async find(
+ options: SavedObjectsFindOptions
+ ): Promise> {
const {
search,
defaultSearchOperator = 'OR',
@@ -768,6 +771,7 @@ export class SavedObjectsRepository {
typeToNamespacesMap,
filter,
preference,
+ aggs,
} = options;
if (!type && !typeToNamespacesMap) {
@@ -799,7 +803,7 @@ export class SavedObjectsRepository {
: Array.from(typeToNamespacesMap!.keys());
const allowedTypes = types.filter((t) => this._allowedTypes.includes(t));
if (allowedTypes.length === 0) {
- return SavedObjectsUtils.createEmptyFindResponse(options);
+ return SavedObjectsUtils.createEmptyFindResponse(options);
}
if (searchFields && !Array.isArray(searchFields)) {
@@ -811,16 +815,24 @@ export class SavedObjectsRepository {
}
let kueryNode;
-
- try {
- if (filter) {
+ if (filter) {
+ try {
kueryNode = validateConvertFilterToKueryNode(allowedTypes, filter, this._mappings);
+ } catch (e) {
+ if (e.name === 'KQLSyntaxError') {
+ throw SavedObjectsErrorHelpers.createBadRequestError(`KQLSyntaxError: ${e.message}`);
+ } else {
+ throw e;
+ }
}
- } catch (e) {
- if (e.name === 'KQLSyntaxError') {
- throw SavedObjectsErrorHelpers.createBadRequestError('KQLSyntaxError: ' + e.message);
- } else {
- throw e;
+ }
+
+ let aggsObject;
+ if (aggs) {
+ try {
+ aggsObject = validateAndConvertAggregations(allowedTypes, aggs, this._mappings);
+ } catch (e) {
+ throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid aggregation: ${e.message}`);
}
}
@@ -838,6 +850,7 @@ export class SavedObjectsRepository {
seq_no_primary_term: true,
from: perPage * (page - 1),
_source: includedFields(type, fields),
+ ...(aggsObject ? { aggs: aggsObject } : {}),
...getSearchDsl(this._mappings, this._registry, {
search,
defaultSearchOperator,
@@ -872,6 +885,7 @@ export class SavedObjectsRepository {
}
return {
+ ...(body.aggregations ? { aggregations: (body.aggregations as unknown) as A } : {}),
page,
per_page: perPage,
total: body.hits.total,
@@ -885,7 +899,7 @@ export class SavedObjectsRepository {
})
),
pit_id: body.pit_id,
- } as SavedObjectsFindResponse;
+ } as SavedObjectsFindResponse;
}
/**
diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts
index ebad13e5edc25..494ac6ce9fad5 100644
--- a/src/core/server/saved_objects/service/lib/utils.ts
+++ b/src/core/server/saved_objects/service/lib/utils.ts
@@ -51,10 +51,10 @@ export class SavedObjectsUtils {
/**
* Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers.
*/
- public static createEmptyFindResponse = ({
+ public static createEmptyFindResponse = ({
page = FIND_DEFAULT_PAGE,
perPage = FIND_DEFAULT_PER_PAGE,
- }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({
+ }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({
page,
per_page: perPage,
total: 0,
diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts
index 9a0ccb88d3555..12451ace02836 100644
--- a/src/core/server/saved_objects/service/saved_objects_client.ts
+++ b/src/core/server/saved_objects/service/saved_objects_client.ts
@@ -173,7 +173,8 @@ export interface SavedObjectsFindResult extends SavedObject {
*
* @public
*/
-export interface SavedObjectsFindResponse {
+export interface SavedObjectsFindResponse {
+ aggregations?: A;
saved_objects: Array>;
total: number;
per_page: number;
@@ -463,7 +464,9 @@ export class SavedObjectsClient {
*
* @param options
*/
- async find(options: SavedObjectsFindOptions): Promise> {
+ async find(
+ options: SavedObjectsFindOptions
+ ): Promise> {
return await this._repository.find(options);
}
diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts
index ecda120e025d8..d3bfdcc6923dc 100644
--- a/src/core/server/saved_objects/types.ts
+++ b/src/core/server/saved_objects/types.ts
@@ -116,6 +116,28 @@ export interface SavedObjectsFindOptions {
*/
defaultSearchOperator?: 'AND' | 'OR';
filter?: string | KueryNode;
+ /**
+ * A record of aggregations to perform.
+ * The API currently only supports a limited set of metrics and bucket aggregation types.
+ * Additional aggregation types can be contributed to Core.
+ *
+ * @example
+ * Aggregating on SO attribute field
+ * ```ts
+ * const aggs = { latest_version: { max: { field: 'dashboard.attributes.version' } } };
+ * return client.find({ type: 'dashboard', aggs })
+ * ```
+ *
+ * @example
+ * Aggregating on SO root field
+ * ```ts
+ * const aggs = { latest_update: { max: { field: 'dashboard.updated_at' } } };
+ * return client.find({ type: 'dashboard', aggs })
+ * ```
+ *
+ * @alpha
+ */
+ aggs?: Record;
namespaces?: string[];
/**
* This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 05af684053f39..e8f9dab435754 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2244,7 +2244,7 @@ export class SavedObjectsClient {
static errors: typeof SavedObjectsErrorHelpers;
// (undocumented)
errors: typeof SavedObjectsErrorHelpers;
- find(options: SavedObjectsFindOptions): Promise>;
+ find(options: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise;
@@ -2501,6 +2501,8 @@ export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjec
// @public (undocumented)
export interface SavedObjectsFindOptions {
+ // @alpha
+ aggs?: Record;
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
// Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts
@@ -2539,7 +2541,9 @@ export interface SavedObjectsFindOptionsReference {
}
// @public
-export interface SavedObjectsFindResponse {
+export interface SavedObjectsFindResponse {
+ // (undocumented)
+ aggregations?: A;
// (undocumented)
page: number;
// (undocumented)
@@ -2849,7 +2853,7 @@ export class SavedObjectsRepository {
deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise;
deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise;
// (undocumented)
- find(options: SavedObjectsFindOptions): Promise>;
+ find(options: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>;
openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise;
@@ -2970,7 +2974,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse;
+ static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse;
static generateId(): string;
static isRandomId(id: string | undefined): boolean;
static namespaceIdToString: (namespace?: string | undefined) => string;
diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json
index 41335069461fa..54eaf461b73d7 100644
--- a/src/plugins/dashboard/kibana.json
+++ b/src/plugins/dashboard/kibana.json
@@ -11,7 +11,8 @@
"share",
"uiActions",
"urlForwarding",
- "presentationUtil"
+ "presentationUtil",
+ "visualizations"
],
"optionalPlugins": [
"home",
diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss
index 30253afff391f..f6525377cce70 100644
--- a/src/plugins/dashboard/public/application/_dashboard_app.scss
+++ b/src/plugins/dashboard/public/application/_dashboard_app.scss
@@ -66,4 +66,17 @@
.dshUnsavedListingItem__actions {
flex-direction: column;
}
-}
\ No newline at end of file
+}
+
+// Temporary fix for two tone icons to make them monochrome
+.dshSolutionToolbar__editorContextMenu--dark {
+ .euiIcon path {
+ fill: $euiColorGhost;
+ }
+}
+
+.dshSolutionToolbar__editorContextMenu--light {
+ .euiIcon path {
+ fill: $euiColorInk;
+ }
+}
diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx
index e5281a257ee13..ed68afc5e97b1 100644
--- a/src/plugins/dashboard/public/application/dashboard_router.tsx
+++ b/src/plugins/dashboard/public/application/dashboard_router.tsx
@@ -80,6 +80,7 @@ export async function mountApp({
embeddable: embeddableStart,
kibanaLegacy: { dashboardConfig },
savedObjectsTaggingOss,
+ visualizations,
} = pluginsStart;
const spacesApi = pluginsStart.spacesOss?.isSpacesAvailable ? pluginsStart.spacesOss : undefined;
@@ -123,6 +124,7 @@ export async function mountApp({
visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) },
storeSearchSession: Boolean(coreStart.application.capabilities.dashboard.storeSearchSession),
},
+ visualizations,
};
const getUrlStateStorage = (history: RouteComponentProps['history']) =>
diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx
index 9b93f0bbd0711..ff592742488f5 100644
--- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx
+++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx
@@ -49,7 +49,7 @@ export class DashboardContainerFactoryDefinition
public readonly getDisplayName = () => {
return i18n.translate('dashboard.factory.displayName', {
- defaultMessage: 'dashboard',
+ defaultMessage: 'Dashboard',
});
};
diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
index 4cd3eb13f3609..138d665866af0 100644
--- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
+++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
@@ -287,7 +287,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `
- Add your first panel
+ Add your first visualization
().services;
const [state, setState] = useState({ chromeIsVisible: false });
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
+ const lensAlias = visualizations.getAliases().find(({ name }) => name === 'lens');
+ const quickButtonVisTypes = ['markdown', 'maps'];
const stateTransferService = embeddable.getStateTransfer();
+ const IS_DARK_THEME = uiSettings.get('theme:darkMode');
+
+ const trackUiMetric = usageCollection?.reportUiCounter.bind(
+ usageCollection,
+ DashboardConstants.DASHBOARDS_ID
+ );
useEffect(() => {
const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => {
@@ -152,27 +161,36 @@ export function DashboardTopNav({
uiSettings,
]);
- const createNew = useCallback(async () => {
- const type = 'visualization';
- const factory = embeddable.getEmbeddableFactory(type);
+ const createNewVisType = useCallback(
+ (visType?: BaseVisType | VisTypeAlias) => () => {
+ let path = '';
+ let appId = '';
- if (!factory) {
- throw new EmbeddableFactoryNotFoundError(type);
- }
+ if (visType) {
+ if (trackUiMetric) {
+ trackUiMetric(METRIC_TYPE.CLICK, visType.name);
+ }
- await factory.create({} as EmbeddableInput, dashboardContainer);
- }, [dashboardContainer, embeddable]);
+ if ('aliasPath' in visType) {
+ appId = visType.aliasApp;
+ path = visType.aliasPath;
+ } else {
+ appId = 'visualize';
+ path = `#/create?type=${encodeURIComponent(visType.name)}`;
+ }
+ } else {
+ appId = 'visualize';
+ path = '#/create?';
+ }
- const createNewVisType = useCallback(
- (newVisType: string) => async () => {
- stateTransferService.navigateToEditor('visualize', {
- path: `#/create?type=${encodeURIComponent(newVisType)}`,
+ stateTransferService.navigateToEditor(appId, {
+ path,
state: {
originatingApp: DashboardConstants.DASHBOARDS_ID,
},
});
},
- [stateTransferService]
+ [trackUiMetric, stateTransferService]
);
const clearAddPanel = useCallback(() => {
@@ -563,38 +581,57 @@ export function DashboardTopNav({
const { TopNavMenu } = navigation.ui;
- const quickButtons = [
- {
- iconType: 'visText',
- createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', {
- defaultMessage: 'Markdown',
- }),
- onClick: createNewVisType('markdown'),
- 'data-test-subj': 'dashboardMarkdownQuickButton',
- },
- {
- iconType: 'controlsHorizontal',
- createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', {
- defaultMessage: 'Input control',
- }),
- onClick: createNewVisType('input_control_vis'),
- 'data-test-subj': 'dashboardInputControlsQuickButton',
- },
- ];
+ const getVisTypeQuickButton = (visTypeName: string) => {
+ const visType =
+ visualizations.get(visTypeName) ||
+ visualizations.getAliases().find(({ name }) => name === visTypeName);
+
+ if (visType) {
+ if ('aliasPath' in visType) {
+ const { name, icon, title } = visType as VisTypeAlias;
+
+ return {
+ iconType: icon,
+ createType: title,
+ onClick: createNewVisType(visType as VisTypeAlias),
+ 'data-test-subj': `dashboardQuickButton${name}`,
+ isDarkModeEnabled: IS_DARK_THEME,
+ };
+ } else {
+ const { name, icon, title, titleInWizard } = visType as BaseVisType;
+
+ return {
+ iconType: icon,
+ createType: titleInWizard || title,
+ onClick: createNewVisType(visType as BaseVisType),
+ 'data-test-subj': `dashboardQuickButton${name}`,
+ isDarkModeEnabled: IS_DARK_THEME,
+ };
+ }
+ }
+
+ return;
+ };
+
+ const quickButtons = quickButtonVisTypes
+ .map(getVisTypeQuickButton)
+ .filter((button) => button) as QuickButtonProps[];
return (
<>
+
{viewMode !== ViewMode.VIEW ? (
-
+
{{
primaryActionButton: (
),
@@ -605,6 +642,12 @@ export function DashboardTopNav({
data-test-subj="dashboardAddPanelButton"
/>
),
+ extraButtons: [
+ ,
+ ],
}}
) : null}
diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx
new file mode 100644
index 0000000000000..5205f5b294c4f
--- /dev/null
+++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx
@@ -0,0 +1,255 @@
+/*
+ * 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, { useCallback } from 'react';
+import {
+ EuiContextMenu,
+ EuiContextMenuPanelItemDescriptor,
+ EuiContextMenuItemIcon,
+} from '@elastic/eui';
+import { METRIC_TYPE } from '@kbn/analytics';
+import { i18n } from '@kbn/i18n';
+import { BaseVisType, VisGroups, VisTypeAlias } from '../../../../visualizations/public';
+import { SolutionToolbarPopover } from '../../../../presentation_util/public';
+import { EmbeddableFactoryDefinition, EmbeddableInput } from '../../services/embeddable';
+import { useKibana } from '../../services/kibana_react';
+import { DashboardAppServices } from '../types';
+import { DashboardContainer } from '..';
+import { DashboardConstants } from '../../dashboard_constants';
+import { dashboardReplacePanelAction } from '../../dashboard_strings';
+
+interface Props {
+ /** Dashboard container */
+ dashboardContainer: DashboardContainer;
+ /** Handler for creating new visualization of a specified type */
+ createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
+}
+
+interface FactoryGroup {
+ id: string;
+ appName: string;
+ icon: EuiContextMenuItemIcon;
+ panelId: number;
+ factories: EmbeddableFactoryDefinition[];
+}
+
+export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => {
+ const {
+ core,
+ embeddable,
+ visualizations,
+ usageCollection,
+ uiSettings,
+ } = useKibana().services;
+
+ const IS_DARK_THEME = uiSettings.get('theme:darkMode');
+
+ const trackUiMetric = usageCollection?.reportUiCounter.bind(
+ usageCollection,
+ DashboardConstants.DASHBOARDS_ID
+ );
+
+ const createNewAggsBasedVis = useCallback(
+ (visType?: BaseVisType) => () =>
+ visualizations.showNewVisModal({
+ originatingApp: DashboardConstants.DASHBOARDS_ID,
+ outsideVisualizeApp: true,
+ showAggsSelection: true,
+ selectedVisType: visType,
+ }),
+ [visualizations]
+ );
+
+ const getVisTypesByGroup = (group: VisGroups) =>
+ visualizations
+ .getByGroup(group)
+ .sort(({ name: a }: BaseVisType | VisTypeAlias, { name: b }: BaseVisType | VisTypeAlias) => {
+ if (a < b) {
+ return -1;
+ }
+ if (a > b) {
+ return 1;
+ }
+ return 0;
+ })
+ .filter(({ hidden }: BaseVisType) => !hidden);
+
+ const promotedVisTypes = getVisTypesByGroup(VisGroups.PROMOTED);
+ const aggsBasedVisTypes = getVisTypesByGroup(VisGroups.AGGBASED);
+ const toolVisTypes = getVisTypesByGroup(VisGroups.TOOLS);
+ const visTypeAliases = visualizations
+ .getAliases()
+ .sort(({ promotion: a = false }: VisTypeAlias, { promotion: b = false }: VisTypeAlias) =>
+ a === b ? 0 : a ? -1 : 1
+ );
+
+ const factories = embeddable
+ ? Array.from(embeddable.getEmbeddableFactories()).filter(
+ ({ type, isEditable, canCreateNew, isContainerType }) =>
+ isEditable() && !isContainerType && canCreateNew() && type !== 'visualization'
+ )
+ : [];
+
+ const factoryGroupMap: Record = {};
+ const ungroupedFactories: EmbeddableFactoryDefinition[] = [];
+ const aggBasedPanelID = 1;
+
+ let panelCount = 1 + aggBasedPanelID;
+
+ factories.forEach((factory: EmbeddableFactoryDefinition, index) => {
+ const { grouping } = factory;
+
+ if (grouping) {
+ grouping.forEach((group) => {
+ if (factoryGroupMap[group.id]) {
+ factoryGroupMap[group.id].factories.push(factory);
+ } else {
+ factoryGroupMap[group.id] = {
+ id: group.id,
+ appName: group.getDisplayName ? group.getDisplayName({ embeddable }) : group.id,
+ icon: (group.getIconType
+ ? group.getIconType({ embeddable })
+ : 'empty') as EuiContextMenuItemIcon,
+ factories: [factory],
+ panelId: panelCount,
+ };
+
+ panelCount++;
+ }
+ });
+ } else {
+ ungroupedFactories.push(factory);
+ }
+ });
+
+ const getVisTypeMenuItem = (visType: BaseVisType): EuiContextMenuPanelItemDescriptor => {
+ const { name, title, titleInWizard, description, icon = 'empty', group } = visType;
+ return {
+ name: titleInWizard || title,
+ icon: icon as string,
+ onClick:
+ group === VisGroups.AGGBASED ? createNewAggsBasedVis(visType) : createNewVisType(visType),
+ 'data-test-subj': `visType-${name}`,
+ toolTipContent: description,
+ };
+ };
+
+ const getVisTypeAliasMenuItem = (
+ visTypeAlias: VisTypeAlias
+ ): EuiContextMenuPanelItemDescriptor => {
+ const { name, title, description, icon = 'empty' } = visTypeAlias;
+
+ return {
+ name: title,
+ icon,
+ onClick: createNewVisType(visTypeAlias),
+ 'data-test-subj': `visType-${name}`,
+ toolTipContent: description,
+ };
+ };
+
+ const getEmbeddableFactoryMenuItem = (
+ factory: EmbeddableFactoryDefinition
+ ): EuiContextMenuPanelItemDescriptor => {
+ const icon = factory?.getIconType ? factory.getIconType() : 'empty';
+
+ const toolTipContent = factory?.getDescription ? factory.getDescription() : undefined;
+
+ return {
+ name: factory.getDisplayName(),
+ icon,
+ toolTipContent,
+ onClick: async () => {
+ if (trackUiMetric) {
+ trackUiMetric(METRIC_TYPE.CLICK, factory.type);
+ }
+ let newEmbeddable;
+ if (factory.getExplicitInput) {
+ const explicitInput = await factory.getExplicitInput();
+ newEmbeddable = await dashboardContainer.addNewEmbeddable(factory.type, explicitInput);
+ } else {
+ newEmbeddable = await factory.create({} as EmbeddableInput, dashboardContainer);
+ }
+
+ if (newEmbeddable) {
+ core.notifications.toasts.addSuccess({
+ title: dashboardReplacePanelAction.getSuccessMessage(
+ `'${newEmbeddable.getInput().title}'` || ''
+ ),
+ 'data-test-subj': 'addEmbeddableToDashboardSuccess',
+ });
+ }
+ },
+ 'data-test-subj': `createNew-${factory.type}`,
+ };
+ };
+
+ const aggsPanelTitle = i18n.translate('dashboard.editorMenu.aggBasedGroupTitle', {
+ defaultMessage: 'Aggregation based',
+ });
+
+ const editorMenuPanels = [
+ {
+ id: 0,
+ items: [
+ ...visTypeAliases.map(getVisTypeAliasMenuItem),
+ ...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({
+ name: appName,
+ icon,
+ panel: panelId,
+ 'data-test-subj': `dashboardEditorMenu-${id}Group`,
+ })),
+ ...ungroupedFactories.map(getEmbeddableFactoryMenuItem),
+ ...promotedVisTypes.map(getVisTypeMenuItem),
+ {
+ name: aggsPanelTitle,
+ icon: 'visualizeApp',
+ panel: aggBasedPanelID,
+ 'data-test-subj': `dashboardEditorAggBasedMenuItem`,
+ },
+ ...toolVisTypes.map(getVisTypeMenuItem),
+ ],
+ },
+ {
+ id: aggBasedPanelID,
+ title: aggsPanelTitle,
+ items: aggsBasedVisTypes.map(getVisTypeMenuItem),
+ },
+ ...Object.values(factoryGroupMap).map(
+ ({ appName, panelId, factories: groupFactories }: FactoryGroup) => ({
+ id: panelId,
+ title: appName,
+ items: groupFactories.map(getEmbeddableFactoryMenuItem),
+ })
+ ),
+ ];
+
+ return (
+
+
+
+ );
+};
diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts
index 6415fdfd73ee8..dd291291ce9d6 100644
--- a/src/plugins/dashboard/public/application/types.ts
+++ b/src/plugins/dashboard/public/application/types.ts
@@ -25,6 +25,7 @@ import { DataPublicPluginStart, IndexPatternsContract } from '../services/data';
import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects';
import { DashboardPanelStorage } from './lib';
import { UrlForwardingStart } from '../../../url_forwarding/public';
+import { VisualizationsStart } from '../../../visualizations/public';
export type DashboardRedirect = (props: RedirectToProps) => void;
export type RedirectToProps =
@@ -83,4 +84,5 @@ export interface DashboardAppServices {
savedObjectsClient: SavedObjectsClientContract;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
savedQueryService: DataPublicPluginStart['query']['savedQueries'];
+ visualizations: VisualizationsStart;
}
diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts
index 79a59d0cfa605..531ff815312cf 100644
--- a/src/plugins/dashboard/public/dashboard_strings.ts
+++ b/src/plugins/dashboard/public/dashboard_strings.ts
@@ -377,7 +377,7 @@ export const emptyScreenStrings = {
}),
getEmptyWidgetTitle: () =>
i18n.translate('dashboard.emptyWidget.addPanelTitle', {
- defaultMessage: 'Add your first panel',
+ defaultMessage: 'Add your first visualization',
}),
getEmptyWidgetDescription: () =>
i18n.translate('dashboard.emptyWidget.addPanelDescription', {
diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx
index e2f52a47455b3..0fad1c51f433a 100644
--- a/src/plugins/dashboard/public/plugin.tsx
+++ b/src/plugins/dashboard/public/plugin.tsx
@@ -24,6 +24,7 @@ import {
PluginInitializerContext,
SavedObjectsClientContract,
} from '../../../core/public';
+import { VisualizationsStart } from '../../visualizations/public';
import { createKbnUrlTracker } from './services/kibana_utils';
import { UsageCollectionSetup } from './services/usage_collection';
@@ -115,6 +116,7 @@ export interface DashboardStartDependencies {
presentationUtil: PresentationUtilPluginStart;
savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart;
spacesOss?: SpacesOssPluginStart;
+ visualizations: VisualizationsStart;
}
export type DashboardSetup = void;
diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts
index 3ce528e6ed893..28102544ae055 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.test.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts
@@ -342,8 +342,8 @@ describe('AggConfigs', () => {
{ enabled: true, type: 'max', schema: 'metric', params: { field: 'bytes' } },
];
- const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
- const topLevelDsl = ac.toDsl(true);
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true });
+ const topLevelDsl = ac.toDsl();
const buckets = ac.bySchemaName('buckets');
const metrics = ac.bySchemaName('metrics');
@@ -412,8 +412,8 @@ describe('AggConfigs', () => {
},
];
- const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
- const topLevelDsl = ac.toDsl(true)['2'];
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, hierarchical: true });
+ const topLevelDsl = ac.toDsl()['2'];
expect(Object.keys(topLevelDsl.aggs)).toContain('1');
expect(Object.keys(topLevelDsl.aggs)).toContain('1-bucket');
diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts
index 4d5d49754387d..2932ef7325aed 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.ts
@@ -43,6 +43,7 @@ function parseParentAggs(dslLvlCursor: any, dsl: any) {
export interface AggConfigsOptions {
typesRegistry: AggTypesRegistryStart;
+ hierarchical?: boolean;
}
export type CreateAggConfigParams = Assign;
@@ -65,6 +66,8 @@ export class AggConfigs {
public indexPattern: IndexPattern;
public timeRange?: TimeRange;
public timeFields?: string[];
+ public hierarchical?: boolean = false;
+
private readonly typesRegistry: AggTypesRegistryStart;
aggs: IAggConfig[];
@@ -80,6 +83,7 @@ export class AggConfigs {
this.aggs = [];
this.indexPattern = indexPattern;
+ this.hierarchical = opts.hierarchical;
configStates.forEach((params: any) => this.createAggConfig(params));
}
@@ -174,12 +178,12 @@ export class AggConfigs {
return true;
}
- toDsl(hierarchical: boolean = false): Record {
+ toDsl(): Record {
const dslTopLvl = {};
let dslLvlCursor: Record;
let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | [];
- if (hierarchical) {
+ if (this.hierarchical) {
// collect all metrics, and filter out the ones that we won't be copying
nestedMetrics = this.aggs
.filter(function (agg) {
diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts
index 33fdc45a605b7..f0f3912bf64fe 100644
--- a/src/plugins/data/common/search/aggs/agg_type.ts
+++ b/src/plugins/data/common/search/aggs/agg_type.ts
@@ -13,12 +13,23 @@ import { ISearchSource } from 'src/plugins/data/public';
import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common';
import type { RequestAdapter } from 'src/plugins/inspector/common';
+import { estypes } from '@elastic/elasticsearch';
import { initParams } from './agg_params';
import { AggConfig } from './agg_config';
import { IAggConfigs } from './agg_configs';
import { BaseParamType } from './param_types/base';
import { AggParamType } from './param_types/agg';
+type PostFlightRequestFn = (
+ resp: estypes.SearchResponse,
+ aggConfigs: IAggConfigs,
+ aggConfig: TAggConfig,
+ searchSource: ISearchSource,
+ inspectorRequestAdapter?: RequestAdapter,
+ abortSignal?: AbortSignal,
+ searchSessionId?: string
+) => Promise>;
+
export interface AggTypeConfig<
TAggConfig extends AggConfig = AggConfig,
TParam extends AggParamType = AggParamType
@@ -40,15 +51,7 @@ export interface AggTypeConfig<
customLabels?: boolean;
json?: boolean;
decorateAggConfig?: () => any;
- postFlightRequest?: (
- resp: any,
- aggConfigs: IAggConfigs,
- aggConfig: TAggConfig,
- searchSource: ISearchSource,
- inspectorRequestAdapter?: RequestAdapter,
- abortSignal?: AbortSignal,
- searchSessionId?: string
- ) => Promise;
+ postFlightRequest?: PostFlightRequestFn;
getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat;
getValue?: (agg: TAggConfig, bucket: any) => any;
getKey?: (bucket: any, key: any, agg: TAggConfig) => any;
@@ -188,15 +191,7 @@ export class AggType<
* @param searchSessionId - searchSessionId to be used for grouping requests into a single search session
* @return {Promise}
*/
- postFlightRequest: (
- resp: any,
- aggConfigs: IAggConfigs,
- aggConfig: TAggConfig,
- searchSource: ISearchSource,
- inspectorRequestAdapter?: RequestAdapter,
- abortSignal?: AbortSignal,
- searchSessionId?: string
- ) => Promise;
+ postFlightRequest: PostFlightRequestFn;
/**
* Get the serialized format for the values produced by this agg type,
* overridden by several metrics that always output a simple number.
diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
index 56e720d237c45..2aa0d346afe34 100644
--- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
+++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.test.ts
@@ -433,7 +433,7 @@ describe('Terms Agg Other bucket helper', () => {
aggConfigs.aggs[0] as IBucketAggConfig,
otherAggConfig()
);
- expect(mergedResponse.aggregations['1'].buckets[3].key).toEqual('__other__');
+ expect((mergedResponse!.aggregations!['1'] as any).buckets[3].key).toEqual('__other__');
}
});
@@ -455,7 +455,7 @@ describe('Terms Agg Other bucket helper', () => {
otherAggConfig()
);
- expect(mergedResponse.aggregations['1'].buckets[1]['2'].buckets[3].key).toEqual(
+ expect((mergedResponse!.aggregations!['1'] as any).buckets[1]['2'].buckets[3].key).toEqual(
'__other__'
);
}
@@ -471,7 +471,7 @@ describe('Terms Agg Other bucket helper', () => {
aggConfigs.aggs[0] as IBucketAggConfig
);
expect(
- updatedResponse.aggregations['1'].buckets.find(
+ (updatedResponse!.aggregations!['1'] as any).buckets.find(
(bucket: Record) => bucket.key === '__missing__'
)
).toBeDefined();
diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
index 742615bc49d8f..6230ae897b170 100644
--- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
+++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
@@ -7,6 +7,7 @@
*/
import { isNumber, keys, values, find, each, cloneDeep, flatten } from 'lodash';
+import { estypes } from '@elastic/elasticsearch';
import { buildExistsFilter, buildPhrasesFilter, buildQueryFromFilters } from '../../../../common';
import { AggGroupNames } from '../agg_groups';
import { IAggConfigs } from '../agg_configs';
@@ -42,7 +43,7 @@ const getNestedAggDSL = (aggNestedDsl: Record, startFromAggId: stri
*/
const getAggResultBuckets = (
aggConfigs: IAggConfigs,
- response: any,
+ response: estypes.SearchResponse['aggregations'],
aggWithOtherBucket: IBucketAggConfig,
key: string
) => {
@@ -72,8 +73,8 @@ const getAggResultBuckets = (
}
}
}
- if (responseAgg[aggWithOtherBucket.id]) {
- return responseAgg[aggWithOtherBucket.id].buckets;
+ if (responseAgg?.[aggWithOtherBucket.id]) {
+ return (responseAgg[aggWithOtherBucket.id] as any).buckets;
}
return [];
};
@@ -235,11 +236,11 @@ export const buildOtherBucketAgg = (
export const mergeOtherBucketAggResponse = (
aggsConfig: IAggConfigs,
- response: any,
+ response: estypes.SearchResponse,
otherResponse: any,
otherAgg: IBucketAggConfig,
requestAgg: Record
-) => {
+): estypes.SearchResponse => {
const updatedResponse = cloneDeep(response);
each(otherResponse.aggregations['other-filter'].buckets, (bucket, key) => {
if (!bucket.doc_count || key === undefined) return;
@@ -276,7 +277,7 @@ export const mergeOtherBucketAggResponse = (
};
export const updateMissingBucket = (
- response: any,
+ response: estypes.SearchResponse,
aggConfigs: IAggConfigs,
agg: IBucketAggConfig
) => {
diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts
index 77c9c6e391c0a..03cf14a577a50 100644
--- a/src/plugins/data/common/search/aggs/buckets/terms.ts
+++ b/src/plugins/data/common/search/aggs/buckets/terms.ts
@@ -101,25 +101,21 @@ export const getTermsBucketAgg = () =>
nestedSearchSource.setField('aggs', filterAgg);
- const requestResponder = inspectorRequestAdapter?.start(
- i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', {
- defaultMessage: 'Other bucket',
- }),
- {
- description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', {
- defaultMessage:
- 'This request counts the number of documents that fall ' +
- 'outside the criterion of the data buckets.',
- }),
- searchSessionId,
- }
- );
-
const response = await nestedSearchSource
.fetch$({
abortSignal,
sessionId: searchSessionId,
- requestResponder,
+ inspector: {
+ adapter: inspectorRequestAdapter,
+ title: i18n.translate('data.search.aggs.buckets.terms.otherBucketTitle', {
+ defaultMessage: 'Other bucket',
+ }),
+ description: i18n.translate('data.search.aggs.buckets.terms.otherBucketDescription', {
+ defaultMessage:
+ 'This request counts the number of documents that fall ' +
+ 'outside the criterion of the data buckets.',
+ }),
+ },
})
.toPromise();
diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
index c2566535916a8..b30e5740fa3fb 100644
--- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
+++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
@@ -9,7 +9,7 @@
import type { MockedKeys } from '@kbn/utility-types/jest';
import type { Filter } from '../../../es_query';
import type { IndexPattern } from '../../../index_patterns';
-import type { IAggConfig, IAggConfigs } from '../../aggs';
+import type { IAggConfigs } from '../../aggs';
import type { ISearchSource } from '../../search_source';
import { searchSourceCommonMock } from '../../search_source/mocks';
@@ -38,7 +38,6 @@ describe('esaggs expression function - public', () => {
filters: undefined,
indexPattern: ({ id: 'logstash-*' } as unknown) as jest.Mocked,
inspectorAdapters: {},
- metricsAtAllLevels: false,
partialRows: false,
query: undefined,
searchSessionId: 'abc123',
@@ -76,21 +75,7 @@ describe('esaggs expression function - public', () => {
test('setField(aggs)', async () => {
expect(searchSource.setField).toHaveBeenCalledTimes(5);
- expect(typeof (searchSource.setField as jest.Mock).mock.calls[2][1]).toBe('function');
- expect((searchSource.setField as jest.Mock).mock.calls[2][1]()).toEqual(
- mockParams.aggs.toDsl()
- );
- expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(mockParams.metricsAtAllLevels);
-
- // make sure param is passed through
- jest.clearAllMocks();
- await handleRequest({
- ...mockParams,
- metricsAtAllLevels: true,
- });
- searchSource = await mockParams.searchSourceService.create();
- (searchSource.setField as jest.Mock).mock.calls[2][1]();
- expect(mockParams.aggs.toDsl).toHaveBeenCalledWith(true);
+ expect((searchSource.setField as jest.Mock).mock.calls[2][1]).toEqual(mockParams.aggs);
});
test('setField(filter)', async () => {
@@ -133,36 +118,24 @@ describe('esaggs expression function - public', () => {
test('calls searchSource.fetch', async () => {
await handleRequest(mockParams);
const searchSource = await mockParams.searchSourceService.create();
+
expect(searchSource.fetch$).toHaveBeenCalledWith({
abortSignal: mockParams.abortSignal,
sessionId: mockParams.searchSessionId,
+ inspector: {
+ title: 'Data',
+ description: 'This request queries Elasticsearch to fetch the data for the visualization.',
+ adapter: undefined,
+ },
});
});
- test('calls agg.postFlightRequest if it exiests and agg is enabled', async () => {
- mockParams.aggs.aggs[0].enabled = true;
- await handleRequest(mockParams);
- expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(1);
-
- // ensure it works if the function doesn't exist
- jest.clearAllMocks();
- mockParams.aggs.aggs[0] = ({ type: { name: 'count' } } as unknown) as IAggConfig;
- expect(async () => await handleRequest(mockParams)).not.toThrowError();
- });
-
- test('should skip agg.postFlightRequest call if the agg is disabled', async () => {
- mockParams.aggs.aggs[0].enabled = false;
- await handleRequest(mockParams);
- expect(mockParams.aggs.aggs[0].type.postFlightRequest).toHaveBeenCalledTimes(0);
- });
-
test('tabifies response data', async () => {
await handleRequest(mockParams);
expect(tabifyAggResponse).toHaveBeenCalledWith(
mockParams.aggs,
{},
{
- metricsAtAllLevels: mockParams.metricsAtAllLevels,
partialRows: mockParams.partialRows,
timeRange: mockParams.timeRange,
}
diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
index 5620698a47538..173b2067cad6b 100644
--- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
+++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
@@ -40,28 +40,12 @@ export interface RequestHandlerParams {
getNow?: () => Date;
}
-function getRequestMainResponder(inspectorAdapters: Adapters, searchSessionId?: string) {
- return inspectorAdapters.requests?.start(
- i18n.translate('data.functions.esaggs.inspector.dataRequest.title', {
- defaultMessage: 'Data',
- }),
- {
- description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', {
- defaultMessage:
- 'This request queries Elasticsearch to fetch the data for the visualization.',
- }),
- searchSessionId,
- }
- );
-}
-
export const handleRequest = async ({
abortSignal,
aggs,
filters,
indexPattern,
inspectorAdapters,
- metricsAtAllLevels,
partialRows,
query,
searchSessionId,
@@ -100,9 +84,7 @@ export const handleRequest = async ({
},
});
- requestSearchSource.setField('aggs', function () {
- return aggs.toDsl(metricsAtAllLevels);
- });
+ requestSearchSource.setField('aggs', aggs);
requestSearchSource.onRequestStart((paramSearchSource, options) => {
return aggs.onSearchRequestStart(paramSearchSource, options);
@@ -128,35 +110,27 @@ export const handleRequest = async ({
requestSearchSource.setField('query', query);
inspectorAdapters.requests?.reset();
- const requestResponder = getRequestMainResponder(inspectorAdapters, searchSessionId);
- const response$ = await requestSearchSource.fetch$({
- abortSignal,
- sessionId: searchSessionId,
- requestResponder,
- });
-
- // Note that rawResponse is not deeply cloned here, so downstream applications using courier
- // must take care not to mutate it, or it could have unintended side effects, e.g. displaying
- // response data incorrectly in the inspector.
- let response = await response$.toPromise();
- for (const agg of aggs.aggs) {
- if (agg.enabled && typeof agg.type.postFlightRequest === 'function') {
- response = await agg.type.postFlightRequest(
- response,
- aggs,
- agg,
- requestSearchSource,
- inspectorAdapters.requests,
- abortSignal,
- searchSessionId
- );
- }
- }
+ const response = await requestSearchSource
+ .fetch$({
+ abortSignal,
+ sessionId: searchSessionId,
+ inspector: {
+ adapter: inspectorAdapters.requests,
+ title: i18n.translate('data.functions.esaggs.inspector.dataRequest.title', {
+ defaultMessage: 'Data',
+ }),
+ description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', {
+ defaultMessage:
+ 'This request queries Elasticsearch to fetch the data for the visualization.',
+ }),
+ },
+ })
+ .toPromise();
const parsedTimeRange = timeRange ? calculateBounds(timeRange, { forceNow }) : null;
const tabifyParams = {
- metricsAtAllLevels,
+ metricsAtAllLevels: aggs.hierarchical,
partialRows,
timeRange: parsedTimeRange
? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields }
diff --git a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts
index 24507a7e13058..e5a3acc23eee8 100644
--- a/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts
+++ b/src/plugins/data/common/search/search_source/inspect/inspector_stats.ts
@@ -50,7 +50,7 @@ export function getRequestInspectorStats(searchSource: ISearchSource) {
/** @public */
export function getResponseInspectorStats(
- resp: estypes.SearchResponse,
+ resp?: estypes.SearchResponse,
searchSource?: ISearchSource
) {
const lastRequest =
diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts
index 3726e5d0c33e8..7f8a4fceff05d 100644
--- a/src/plugins/data/common/search/search_source/search_source.test.ts
+++ b/src/plugins/data/common/search/search_source/search_source.test.ts
@@ -11,6 +11,10 @@ import { IndexPattern } from '../../index_patterns';
import { GetConfigFn } from '../../types';
import { fetchSoon } from './legacy';
import { SearchSource, SearchSourceDependencies, SortDirection } from './';
+import { AggConfigs, AggTypesRegistryStart } from '../../';
+import { mockAggTypesRegistry } from '../aggs/test_helpers';
+import { RequestResponder } from 'src/plugins/inspector/common';
+import { switchMap } from 'rxjs/operators';
jest.mock('./legacy', () => ({
fetchSoon: jest.fn().mockResolvedValue({}),
@@ -39,6 +43,21 @@ const indexPattern2 = ({
getSourceFiltering: () => mockSource2,
} as unknown) as IndexPattern;
+const fields3 = [{ name: 'foo-bar' }, { name: 'field1' }, { name: 'field2' }];
+const indexPattern3 = ({
+ title: 'foo',
+ fields: {
+ getByName: (name: string) => {
+ return fields3.find((field) => field.name === name);
+ },
+ filter: () => {
+ return fields3;
+ },
+ },
+ getComputedFields,
+ getSourceFiltering: () => mockSource,
+} as unknown) as IndexPattern;
+
const runtimeFieldDef = {
type: 'keyword',
script: {
@@ -61,8 +80,8 @@ describe('SearchSource', () => {
.fn()
.mockReturnValue(
of(
- { rawResponse: { isPartial: true, isRunning: true } },
- { rawResponse: { isPartial: false, isRunning: false } }
+ { rawResponse: { test: 1 }, isPartial: true, isRunning: true },
+ { rawResponse: { test: 2 }, isPartial: false, isRunning: false }
)
);
@@ -81,17 +100,19 @@ describe('SearchSource', () => {
describe('#getField()', () => {
test('gets the value for the property', () => {
- searchSource.setField('aggs', 5);
- expect(searchSource.getField('aggs')).toBe(5);
+ searchSource.setField('aggs', { i: 5 });
+ expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 });
});
});
describe('#getFields()', () => {
test('gets the value for the property', () => {
- searchSource.setField('aggs', 5);
+ searchSource.setField('aggs', { i: 5 });
expect(searchSource.getFields()).toMatchInlineSnapshot(`
Object {
- "aggs": 5,
+ "aggs": Object {
+ "i": 5,
+ },
}
`);
});
@@ -100,7 +121,7 @@ describe('SearchSource', () => {
describe('#removeField()', () => {
test('remove property', () => {
searchSource = new SearchSource({}, searchSourceDependencies);
- searchSource.setField('aggs', 5);
+ searchSource.setField('aggs', { i: 5 });
searchSource.removeField('aggs');
expect(searchSource.getField('aggs')).toBeFalsy();
});
@@ -108,8 +129,20 @@ describe('SearchSource', () => {
describe('#setField() / #flatten', () => {
test('sets the value for the property', () => {
- searchSource.setField('aggs', 5);
- expect(searchSource.getField('aggs')).toBe(5);
+ searchSource.setField('aggs', { i: 5 });
+ expect(searchSource.getField('aggs')).toStrictEqual({ i: 5 });
+ });
+
+ test('sets the value for the property with AggConfigs', () => {
+ const typesRegistry = mockAggTypesRegistry();
+
+ const ac = new AggConfigs(indexPattern3, [{ type: 'avg', params: { field: 'field1' } }], {
+ typesRegistry,
+ });
+
+ searchSource.setField('aggs', ac);
+ const request = searchSource.getSearchRequestBody();
+ expect(request.aggs).toStrictEqual({ '1': { avg: { field: 'field1' } } });
});
describe('computed fields handling', () => {
@@ -631,7 +664,7 @@ describe('SearchSource', () => {
const fn = jest.fn();
searchSource.onRequestStart(fn);
const options = {};
- await searchSource.fetch(options);
+ await searchSource.fetch$(options).toPromise();
expect(fn).toBeCalledWith(searchSource, options);
});
@@ -644,7 +677,7 @@ describe('SearchSource', () => {
const parentFn = jest.fn();
parent.onRequestStart(parentFn);
const options = {};
- await searchSource.fetch(options);
+ await searchSource.fetch$(options).toPromise();
expect(fn).toBeCalledWith(searchSource, options);
expect(parentFn).not.toBeCalled();
@@ -664,69 +697,13 @@ describe('SearchSource', () => {
const parentFn = jest.fn();
parent.onRequestStart(parentFn);
const options = {};
- await searchSource.fetch(options);
+ await searchSource.fetch$(options).toPromise();
expect(fn).toBeCalledWith(searchSource, options);
expect(parentFn).toBeCalledWith(searchSource, options);
});
});
- describe('#legacy fetch()', () => {
- beforeEach(() => {
- searchSourceDependencies = {
- ...searchSourceDependencies,
- getConfig: jest.fn(() => {
- return true; // batchSearches = true
- }) as GetConfigFn,
- };
- });
-
- test('should call msearch', async () => {
- searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
- const options = {};
- await searchSource.fetch(options);
- expect(fetchSoon).toBeCalledTimes(1);
- });
- });
-
- describe('#search service fetch()', () => {
- test('should call msearch', async () => {
- searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
- const options = {};
-
- await searchSource.fetch(options);
- expect(mockSearchMethod).toBeCalledTimes(1);
- });
-
- test('should return partial results', (done) => {
- searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
- const options = {};
-
- const next = jest.fn();
- const complete = () => {
- expect(next).toBeCalledTimes(2);
- expect(next.mock.calls[0]).toMatchInlineSnapshot(`
- Array [
- Object {
- "isPartial": true,
- "isRunning": true,
- },
- ]
- `);
- expect(next.mock.calls[1]).toMatchInlineSnapshot(`
- Array [
- Object {
- "isPartial": false,
- "isRunning": false,
- },
- ]
- `);
- done();
- };
- searchSource.fetch$(options).subscribe({ next, complete });
- });
- });
-
describe('#serialize', () => {
test('should reference index patterns', () => {
const indexPattern123 = { id: '123' } as IndexPattern;
@@ -884,4 +861,373 @@ describe('SearchSource', () => {
);
});
});
+
+ describe('fetch$', () => {
+ describe('#legacy fetch()', () => {
+ beforeEach(() => {
+ searchSourceDependencies = {
+ ...searchSourceDependencies,
+ getConfig: jest.fn(() => {
+ return true; // batchSearches = true
+ }) as GetConfigFn,
+ };
+ });
+
+ test('should call msearch', async () => {
+ searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
+ const options = {};
+ await searchSource.fetch$(options).toPromise();
+ expect(fetchSoon).toBeCalledTimes(1);
+ });
+ });
+
+ describe('responses', () => {
+ test('should return partial results', async () => {
+ searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
+ const options = {};
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ const res$ = searchSource.fetch$(options);
+ res$.subscribe({ next, complete });
+ await res$.toPromise();
+
+ expect(next).toBeCalledTimes(2);
+ expect(complete).toBeCalledTimes(1);
+ expect(next.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "test": 1,
+ },
+ ]
+ `);
+ expect(next.mock.calls[1]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "test": 2,
+ },
+ ]
+ `);
+ });
+
+ test('shareReplays result', async () => {
+ searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
+ const options = {};
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ const next2 = jest.fn();
+ const complete2 = jest.fn();
+ const res$ = searchSource.fetch$(options);
+ res$.subscribe({ next, complete });
+ res$.subscribe({ next: next2, complete: complete2 });
+ await res$.toPromise();
+
+ expect(next).toBeCalledTimes(2);
+ expect(next2).toBeCalledTimes(2);
+ expect(complete).toBeCalledTimes(1);
+ expect(complete2).toBeCalledTimes(1);
+ expect(searchSourceDependencies.search).toHaveBeenCalledTimes(1);
+ });
+
+ test('should emit error on empty response', async () => {
+ searchSourceDependencies.search = mockSearchMethod = jest
+ .fn()
+ .mockReturnValue(
+ of({ rawResponse: { test: 1 }, isPartial: true, isRunning: true }, undefined)
+ );
+
+ searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies);
+ const options = {};
+
+ const next = jest.fn();
+ const error = jest.fn();
+ const complete = jest.fn();
+ const res$ = searchSource.fetch$(options);
+ res$.subscribe({ next, error, complete });
+ await res$.toPromise().catch((e) => {});
+
+ expect(next).toBeCalledTimes(1);
+ expect(error).toBeCalledTimes(1);
+ expect(complete).toBeCalledTimes(0);
+ expect(next.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "test": 1,
+ },
+ ]
+ `);
+ expect(error.mock.calls[0][0]).toBe(undefined);
+ });
+ });
+
+ describe('inspector', () => {
+ let requestResponder: RequestResponder;
+ beforeEach(() => {
+ requestResponder = ({
+ stats: jest.fn(),
+ ok: jest.fn(),
+ error: jest.fn(),
+ json: jest.fn(),
+ } as unknown) as RequestResponder;
+ });
+
+ test('calls inspector if provided', async () => {
+ const options = {
+ inspector: {
+ title: 'a',
+ adapter: {
+ start: jest.fn().mockReturnValue(requestResponder),
+ } as any,
+ },
+ };
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ await searchSource.fetch$(options).toPromise();
+
+ expect(options.inspector.adapter.start).toBeCalledTimes(1);
+ expect(requestResponder.error).not.toBeCalled();
+ expect(requestResponder.json).toBeCalledTimes(1);
+ expect(requestResponder.ok).toBeCalledTimes(1);
+ // First and last
+ expect(requestResponder.stats).toBeCalledTimes(2);
+ });
+
+ test('calls inspector only once, with multiple subs (shareReplay)', async () => {
+ const options = {
+ inspector: {
+ title: 'a',
+ adapter: {
+ start: jest.fn().mockReturnValue(requestResponder),
+ } as any,
+ },
+ };
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ const res$ = searchSource.fetch$(options);
+
+ const complete1 = jest.fn();
+ const complete2 = jest.fn();
+
+ res$.subscribe({
+ complete: complete1,
+ });
+ res$.subscribe({
+ complete: complete2,
+ });
+
+ await res$.toPromise();
+
+ expect(complete1).toBeCalledTimes(1);
+ expect(complete2).toBeCalledTimes(1);
+ expect(options.inspector.adapter.start).toBeCalledTimes(1);
+ });
+
+ test('calls error on inspector', async () => {
+ const options = {
+ inspector: {
+ title: 'a',
+ adapter: {
+ start: jest.fn().mockReturnValue(requestResponder),
+ } as any,
+ },
+ };
+
+ searchSourceDependencies.search = jest.fn().mockReturnValue(of(Promise.reject('aaaaa')));
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ await searchSource
+ .fetch$(options)
+ .toPromise()
+ .catch(() => {});
+
+ expect(options.inspector.adapter.start).toBeCalledTimes(1);
+ expect(requestResponder.json).toBeCalledTimes(1);
+ expect(requestResponder.error).toBeCalledTimes(1);
+ expect(requestResponder.ok).toBeCalledTimes(0);
+ expect(requestResponder.stats).toBeCalledTimes(0);
+ });
+ });
+
+ describe('postFlightRequest', () => {
+ let fetchSub: any;
+
+ function getAggConfigs(typesRegistry: AggTypesRegistryStart, enabled: boolean) {
+ return new AggConfigs(
+ indexPattern3,
+ [
+ {
+ type: 'avg',
+ enabled,
+ params: { field: 'field1' },
+ },
+ ],
+ {
+ typesRegistry,
+ }
+ );
+ }
+
+ beforeEach(() => {
+ fetchSub = {
+ next: jest.fn(),
+ complete: jest.fn(),
+ error: jest.fn(),
+ };
+ });
+
+ test('doesnt call any post flight requests if disabled', async () => {
+ const typesRegistry = mockAggTypesRegistry();
+ typesRegistry.get('avg').postFlightRequest = jest.fn();
+ const ac = getAggConfigs(typesRegistry, false);
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('aggs', ac);
+ const fetch$ = searchSource.fetch$({});
+ fetch$.subscribe(fetchSub);
+ await fetch$.toPromise();
+
+ expect(fetchSub.next).toHaveBeenCalledTimes(2);
+ expect(fetchSub.complete).toHaveBeenCalledTimes(1);
+ expect(fetchSub.error).toHaveBeenCalledTimes(0);
+
+ expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0);
+ });
+
+ test('doesnt call any post flight if searchsource has error', async () => {
+ const typesRegistry = mockAggTypesRegistry();
+ typesRegistry.get('avg').postFlightRequest = jest.fn();
+ const ac = getAggConfigs(typesRegistry, true);
+
+ searchSourceDependencies.search = jest.fn().mockImplementation(() =>
+ of(1).pipe(
+ switchMap((r) => {
+ throw r;
+ })
+ )
+ );
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('aggs', ac);
+ const fetch$ = searchSource.fetch$({});
+ fetch$.subscribe(fetchSub);
+ await fetch$.toPromise().catch((e) => {});
+
+ expect(fetchSub.next).toHaveBeenCalledTimes(0);
+ expect(fetchSub.complete).toHaveBeenCalledTimes(0);
+ expect(fetchSub.error).toHaveBeenNthCalledWith(1, 1);
+
+ expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(0);
+ });
+
+ test('calls post flight requests, fires 1 extra response, returns last response', async () => {
+ const typesRegistry = mockAggTypesRegistry();
+ typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({
+ other: 5,
+ });
+
+ const allac = new AggConfigs(
+ indexPattern3,
+ [
+ {
+ type: 'avg',
+ enabled: true,
+ params: { field: 'field1' },
+ },
+ {
+ type: 'avg',
+ enabled: true,
+ params: { field: 'field2' },
+ },
+ {
+ type: 'avg',
+ enabled: true,
+ params: { field: 'foo-bar' },
+ },
+ ],
+ {
+ typesRegistry,
+ }
+ );
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('aggs', allac);
+ const fetch$ = searchSource.fetch$({});
+ fetch$.subscribe(fetchSub);
+
+ const resp = await fetch$.toPromise();
+
+ expect(fetchSub.next).toHaveBeenCalledTimes(3);
+ expect(fetchSub.complete).toHaveBeenCalledTimes(1);
+ expect(fetchSub.error).toHaveBeenCalledTimes(0);
+ expect(resp).toStrictEqual({ other: 5 });
+ expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3);
+ });
+
+ test('calls post flight requests only once, with multiple subs (shareReplay)', async () => {
+ const typesRegistry = mockAggTypesRegistry();
+ typesRegistry.get('avg').postFlightRequest = jest.fn().mockResolvedValue({
+ other: 5,
+ });
+
+ const allac = new AggConfigs(
+ indexPattern3,
+ [
+ {
+ type: 'avg',
+ enabled: true,
+ params: { field: 'field1' },
+ },
+ ],
+ {
+ typesRegistry,
+ }
+ );
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('aggs', allac);
+ const fetch$ = searchSource.fetch$({});
+ fetch$.subscribe(fetchSub);
+
+ const fetchSub2 = {
+ next: jest.fn(),
+ complete: jest.fn(),
+ error: jest.fn(),
+ };
+ fetch$.subscribe(fetchSub2);
+
+ await fetch$.toPromise();
+
+ expect(fetchSub.next).toHaveBeenCalledTimes(3);
+ expect(fetchSub.complete).toHaveBeenCalledTimes(1);
+ expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1);
+ });
+
+ test('calls post flight requests, handles error', async () => {
+ const typesRegistry = mockAggTypesRegistry();
+ typesRegistry.get('avg').postFlightRequest = jest.fn().mockRejectedValue(undefined);
+ const ac = getAggConfigs(typesRegistry, true);
+
+ searchSource = new SearchSource({}, searchSourceDependencies);
+ searchSource.setField('index', indexPattern);
+ searchSource.setField('aggs', ac);
+ const fetch$ = searchSource.fetch$({});
+ fetch$.subscribe(fetchSub);
+
+ await fetch$.toPromise().catch(() => {});
+
+ expect(fetchSub.next).toHaveBeenCalledTimes(2);
+ expect(fetchSub.complete).toHaveBeenCalledTimes(0);
+ expect(fetchSub.error).toHaveBeenCalledTimes(1);
+ expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
});
diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts
index e1e7a8292d677..1c1c32228703f 100644
--- a/src/plugins/data/common/search/search_source/search_source.ts
+++ b/src/plugins/data/common/search/search_source/search_source.ts
@@ -60,12 +60,22 @@
import { setWith } from '@elastic/safer-lodash-set';
import { uniqueId, keyBy, pick, difference, isFunction, isEqual, uniqWith, isObject } from 'lodash';
-import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
-import { defer, from } from 'rxjs';
+import {
+ catchError,
+ finalize,
+ first,
+ last,
+ map,
+ shareReplay,
+ switchMap,
+ tap,
+} from 'rxjs/operators';
+import { defer, EMPTY, from, Observable } from 'rxjs';
+import { estypes } from '@elastic/elasticsearch';
import { normalizeSortRequest } from './normalize_sort_request';
import { fieldWildcardFilter } from '../../../../kibana_utils/common';
import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns';
-import { ISearchGeneric, ISearchOptions } from '../..';
+import { AggConfigs, ISearchGeneric, ISearchOptions } from '../..';
import type {
ISearchSource,
SearchFieldValue,
@@ -75,7 +85,15 @@ import type {
import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch';
import { getRequestInspectorStats, getResponseInspectorStats } from './inspect';
-import { getEsQueryConfig, buildEsQuery, Filter, UI_SETTINGS } from '../../../common';
+import {
+ getEsQueryConfig,
+ buildEsQuery,
+ Filter,
+ UI_SETTINGS,
+ isErrorResponse,
+ isPartialResponse,
+ IKibanaSearchResponse,
+} from '../../../common';
import { getHighlightRequest } from '../../../common/field_formats';
import { fetchSoon } from './legacy';
import { extractReferences } from './extract_references';
@@ -256,10 +274,8 @@ export class SearchSource {
*/
fetch$(options: ISearchOptions = {}) {
const { getConfig } = this.dependencies;
- return defer(() => this.requestIsStarting(options)).pipe(
- tap(() => {
- options.requestResponder?.stats(getRequestInspectorStats(this));
- }),
+
+ const s$ = defer(() => this.requestIsStarting(options)).pipe(
switchMap(() => {
const searchRequest = this.flatten();
this.history = [searchRequest];
@@ -273,21 +289,14 @@ export class SearchSource {
}),
tap((response) => {
// TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved
- if ((response as any).error) {
+ if (!response || (response as any).error) {
throw new RequestFailure(null, response);
- } else {
- options.requestResponder?.stats(getResponseInspectorStats(response, this));
- options.requestResponder?.ok({ json: response });
}
}),
- catchError((e) => {
- options.requestResponder?.error({ json: e });
- throw e;
- }),
- finalize(() => {
- options.requestResponder?.json(this.getSearchRequestBody());
- })
+ shareReplay()
);
+
+ return this.inspectSearch(s$, options);
}
/**
@@ -328,9 +337,96 @@ export class SearchSource {
* PRIVATE APIS
******/
+ private inspectSearch(s$: Observable>, options: ISearchOptions) {
+ const { id, title, description, adapter } = options.inspector || { title: '' };
+
+ const requestResponder = adapter?.start(title, {
+ id,
+ description,
+ searchSessionId: options.sessionId,
+ });
+
+ const trackRequestBody = () => {
+ try {
+ requestResponder?.json(this.getSearchRequestBody());
+ } catch (e) {} // eslint-disable-line no-empty
+ };
+
+ // Track request stats on first emit, swallow errors
+ const first$ = s$
+ .pipe(
+ first(undefined, null),
+ tap(() => {
+ requestResponder?.stats(getRequestInspectorStats(this));
+ trackRequestBody();
+ }),
+ catchError(() => {
+ trackRequestBody();
+ return EMPTY;
+ }),
+ finalize(() => {
+ first$.unsubscribe();
+ })
+ )
+ .subscribe();
+
+ // Track response stats on last emit, as well as errors
+ const last$ = s$
+ .pipe(
+ catchError((e) => {
+ requestResponder?.error({ json: e });
+ return EMPTY;
+ }),
+ last(undefined, null),
+ tap((finalResponse) => {
+ if (finalResponse) {
+ requestResponder?.stats(getResponseInspectorStats(finalResponse, this));
+ requestResponder?.ok({ json: finalResponse });
+ }
+ }),
+ finalize(() => {
+ last$.unsubscribe();
+ })
+ )
+ .subscribe();
+
+ return s$;
+ }
+
+ private hasPostFlightRequests() {
+ const aggs = this.getField('aggs');
+ if (aggs instanceof AggConfigs) {
+ return aggs.aggs.some(
+ (agg) => agg.enabled && typeof agg.type.postFlightRequest === 'function'
+ );
+ } else {
+ return false;
+ }
+ }
+
+ private async fetchOthers(response: estypes.SearchResponse, options: ISearchOptions) {
+ const aggs = this.getField('aggs');
+ if (aggs instanceof AggConfigs) {
+ for (const agg of aggs.aggs) {
+ if (agg.enabled && typeof agg.type.postFlightRequest === 'function') {
+ response = await agg.type.postFlightRequest(
+ response,
+ aggs,
+ agg,
+ this,
+ options.inspector?.adapter,
+ options.abortSignal,
+ options.sessionId
+ );
+ }
+ }
+ return response;
+ }
+ }
+
/**
* Run a search using the search service
- * @return {Promise>}
+ * @return {Observable>}
*/
private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) {
const { search, getConfig, onResponse } = this.dependencies;
@@ -340,6 +436,43 @@ export class SearchSource {
});
return search({ params, indexType: searchRequest.indexType }, options).pipe(
+ switchMap((response) => {
+ return new Observable>((obs) => {
+ if (isErrorResponse(response)) {
+ obs.error(response);
+ } else if (isPartialResponse(response)) {
+ obs.next(response);
+ } else {
+ if (!this.hasPostFlightRequests()) {
+ obs.next(response);
+ obs.complete();
+ } else {
+ // Treat the complete response as partial, then run the postFlightRequests.
+ obs.next({
+ ...response,
+ isPartial: true,
+ isRunning: true,
+ });
+ const sub = from(this.fetchOthers(response.rawResponse, options)).subscribe({
+ next: (responseWithOther) => {
+ obs.next({
+ ...response,
+ rawResponse: responseWithOther,
+ });
+ },
+ error: (e) => {
+ obs.error(e);
+ sub.unsubscribe();
+ },
+ complete: () => {
+ obs.complete();
+ sub.unsubscribe();
+ },
+ });
+ }
+ }
+ });
+ }),
map(({ rawResponse }) => onResponse(searchRequest, rawResponse))
);
}
@@ -452,6 +585,12 @@ export class SearchSource {
getConfig(UI_SETTINGS.SORT_OPTIONS)
);
return addToBody(key, sort);
+ case 'aggs':
+ if ((val as any) instanceof AggConfigs) {
+ return addToBody('aggs', val.toDsl());
+ } else {
+ return addToBody('aggs', val);
+ }
default:
return addToBody(key, val);
}
diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts
index a178b38693d92..99f3f67a5e257 100644
--- a/src/plugins/data/common/search/search_source/types.ts
+++ b/src/plugins/data/common/search/search_source/types.ts
@@ -7,6 +7,7 @@
*/
import { NameList } from 'elasticsearch';
+import { IAggConfigs } from 'src/plugins/data/public';
import { Query } from '../..';
import { Filter } from '../../es_query';
import { IndexPattern } from '../../index_patterns';
@@ -78,7 +79,7 @@ export interface SearchSourceFields {
/**
* {@link AggConfigs}
*/
- aggs?: any;
+ aggs?: object | IAggConfigs | (() => object);
from?: number;
size?: number;
source?: NameList;
diff --git a/src/plugins/data/common/search/tabify/index.ts b/src/plugins/data/common/search/tabify/index.ts
index 168d4cf9d4c37..74fbc7ba4cfa4 100644
--- a/src/plugins/data/common/search/tabify/index.ts
+++ b/src/plugins/data/common/search/tabify/index.ts
@@ -6,27 +6,6 @@
* Side Public License, v 1.
*/
-import { SearchResponse } from 'elasticsearch';
-import { SearchSource } from '../search_source';
-import { tabifyAggResponse } from './tabify';
-import { tabifyDocs, TabifyDocsOptions } from './tabify_docs';
-import { TabbedResponseWriterOptions } from './types';
-
-export const tabify = (
- searchSource: SearchSource,
- esResponse: SearchResponse,
- opts: Partial | TabifyDocsOptions
-) => {
- return !esResponse.aggregations
- ? tabifyDocs(esResponse, searchSource.getField('index'), opts as TabifyDocsOptions)
- : tabifyAggResponse(
- searchSource.getField('aggs'),
- esResponse,
- opts as Partial
- );
-};
-
-export { tabifyDocs };
-
+export { tabifyDocs } from './tabify_docs';
export { tabifyAggResponse } from './tabify';
export { tabifyGetColumns } from './get_columns';
diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts
index 9f096886491ad..4a8972d4384c2 100644
--- a/src/plugins/data/common/search/tabify/tabify.ts
+++ b/src/plugins/data/common/search/tabify/tabify.ts
@@ -139,7 +139,7 @@ export function tabifyAggResponse(
const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {});
const topLevelBucket: AggResponseBucket = {
...esResponse.aggregations,
- doc_count: esResponse.hits.total,
+ doc_count: esResponse.hits?.total,
};
collectBucket(aggConfigs, write, topLevelBucket, '', 1);
diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts
index 37de8dc49d3c6..e3ec499a0020d 100644
--- a/src/plugins/data/common/search/types.ts
+++ b/src/plugins/data/common/search/types.ts
@@ -9,7 +9,7 @@
import { Observable } from 'rxjs';
import { IEsSearchRequest, IEsSearchResponse } from './es_search';
import { IndexPattern } from '..';
-import type { RequestResponder } from '../../../inspector/common';
+import type { RequestAdapter } from '../../../inspector/common';
export type ISearchGeneric = <
SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest,
@@ -81,6 +81,13 @@ export interface IKibanaSearchRequest {
params?: Params;
}
+export interface IInspectorInfo {
+ adapter?: RequestAdapter;
+ title: string;
+ id?: string;
+ description?: string;
+}
+
export interface ISearchOptions {
/**
* An `AbortSignal` that allows the caller of `search` to abort a search request.
@@ -117,10 +124,12 @@ export interface ISearchOptions {
/**
* Index pattern reference is used for better error messages
*/
-
indexPattern?: IndexPattern;
- requestResponder?: RequestResponder;
+ /**
+ * Inspector integration options
+ */
+ inspector?: IInspectorInfo;
}
/**
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index d99d754a3364d..0dd06691d68bb 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -46,6 +46,7 @@ import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_
import { History } from 'history';
import { Href } from 'history';
import { HttpSetup } from 'kibana/public';
+import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public';
import { IconType } from '@elastic/eui';
import { IncomingHttpHeaders } from 'http';
import { InjectedIntl } from '@kbn/i18n/react';
@@ -254,6 +255,8 @@ export class AggConfigs {
getResponseAggById(id: string): AggConfig | undefined;
getResponseAggs(): AggConfig[];
// (undocumented)
+ hierarchical?: boolean;
+ // (undocumented)
indexPattern: IndexPattern;
jsonDataEquals(aggConfigs: AggConfig[]): boolean;
// (undocumented)
@@ -267,7 +270,7 @@ export class AggConfigs {
// (undocumented)
timeRange?: TimeRange;
// (undocumented)
- toDsl(hierarchical?: boolean): Record;
+ toDsl(): Record;
}
// @internal (undocumented)
@@ -1672,13 +1675,11 @@ export type ISearchGeneric = ;
+ // (undocumented)
protected getTimeoutMode(): TimeoutErrorMode;
// Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts
@@ -2428,9 +2431,9 @@ export class SearchSource {
createChild(options?: {}): SearchSource;
createCopy(): SearchSource;
destroy(): void;
- fetch$(options?: ISearchOptions): import("rxjs").Observable>;
+ fetch$(options?: ISearchOptions): Observable>;
// @deprecated
- fetch(options?: ISearchOptions): Promise>;
+ fetch(options?: ISearchOptions): Promise>;
getField(field: K, recurse?: boolean): SearchSourceFields[K];
getFields(): SearchSourceFields;
getId(): string;
@@ -2460,7 +2463,7 @@ export class SearchSource {
// @public
export interface SearchSourceFields {
// (undocumented)
- aggs?: any;
+ aggs?: object | IAggConfigs_2 | (() => object);
// Warning: (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts
fields?: SearchFieldValue[];
// @deprecated
diff --git a/src/plugins/data/public/search/expressions/esaggs.test.ts b/src/plugins/data/public/search/expressions/esaggs.test.ts
index d7a6446781c43..e75bd7be219de 100644
--- a/src/plugins/data/public/search/expressions/esaggs.test.ts
+++ b/src/plugins/data/public/search/expressions/esaggs.test.ts
@@ -100,17 +100,20 @@ describe('esaggs expression function - public', () => {
expect(handleEsaggsRequest).toHaveBeenCalledWith({
abortSignal: mockHandlers.abortSignal,
- aggs: { foo: 'bar' },
+ aggs: {
+ foo: 'bar',
+ hierarchical: true,
+ },
filters: undefined,
indexPattern: {},
inspectorAdapters: mockHandlers.inspectorAdapters,
- metricsAtAllLevels: args.metricsAtAllLevels,
partialRows: args.partialRows,
query: undefined,
searchSessionId: 'abc123',
searchSourceService: startDependencies.searchSource,
timeFields: args.timeFields,
timeRange: undefined,
+ getNow: undefined,
});
});
diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts
index 45d24af3a6ebb..1e3d56c71e423 100644
--- a/src/plugins/data/public/search/expressions/esaggs.ts
+++ b/src/plugins/data/public/search/expressions/esaggs.ts
@@ -8,7 +8,6 @@
import { get } from 'lodash';
import { StartServicesAccessor } from 'src/core/public';
-import { Adapters } from 'src/plugins/inspector/common';
import {
EsaggsExpressionFunctionDefinition,
EsaggsStartDependencies,
@@ -44,14 +43,14 @@ export function getFunctionDefinition({
indexPattern,
args.aggs!.map((agg) => agg.value)
);
+ aggConfigs.hierarchical = args.metricsAtAllLevels;
return await handleEsaggsRequest({
- abortSignal: (abortSignal as unknown) as AbortSignal,
+ abortSignal,
aggs: aggConfigs,
filters: get(input, 'filters', undefined),
indexPattern,
- inspectorAdapters: inspectorAdapters as Adapters,
- metricsAtAllLevels: args.metricsAtAllLevels,
+ inspectorAdapters,
partialRows: args.partialRows,
query: get(input, 'query', undefined) as any,
searchSessionId: getSearchSessionId(),
diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts
index 3df2313f83798..e3fb31c9179fd 100644
--- a/src/plugins/data/public/search/search_interceptor.ts
+++ b/src/plugins/data/public/search/search_interceptor.ts
@@ -113,20 +113,14 @@ export class SearchInterceptor {
}
}
- /**
- * @internal
- * @throws `AbortError` | `ErrorLike`
- */
- protected runSearch(
- request: IKibanaSearchRequest,
- options?: ISearchOptions
- ): Promise {
- const { abortSignal, sessionId, ...requestOptions } = options || {};
+ protected getSerializableOptions(options?: ISearchOptions) {
+ const { sessionId, ...requestOptions } = options || {};
+
+ const serializableOptions: ISearchOptionsSerializable = {};
const combined = {
...requestOptions,
...this.deps.session.getSearchOptions(sessionId),
};
- const serializableOptions: ISearchOptionsSerializable = {};
if (combined.sessionId !== undefined) serializableOptions.sessionId = combined.sessionId;
if (combined.isRestore !== undefined) serializableOptions.isRestore = combined.isRestore;
@@ -135,10 +129,22 @@ export class SearchInterceptor {
if (combined.strategy !== undefined) serializableOptions.strategy = combined.strategy;
if (combined.isStored !== undefined) serializableOptions.isStored = combined.isStored;
+ return serializableOptions;
+ }
+
+ /**
+ * @internal
+ * @throws `AbortError` | `ErrorLike`
+ */
+ protected runSearch(
+ request: IKibanaSearchRequest,
+ options?: ISearchOptions
+ ): Promise {
+ const { abortSignal } = options || {};
return this.batchedFetch(
{
request,
- options: serializableOptions,
+ options: this.getSerializableOptions(options),
},
abortSignal
);
diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts
index 381410574ecda..71f51b4bc8d83 100644
--- a/src/plugins/data/public/search/session/session_service.ts
+++ b/src/plugins/data/public/search/session/session_service.ts
@@ -73,7 +73,7 @@ export interface SearchSessionIndicatorUiConfig {
}
/**
- * Responsible for tracking a current search session. Supports only a single session at a time.
+ * Responsible for tracking a current search session. Supports a single session at a time.
*/
export class SessionService {
public readonly state$: Observable;
diff --git a/src/plugins/data/server/search/expressions/esaggs.test.ts b/src/plugins/data/server/search/expressions/esaggs.test.ts
index 124a171de6378..15287e9d8cf5b 100644
--- a/src/plugins/data/server/search/expressions/esaggs.test.ts
+++ b/src/plugins/data/server/search/expressions/esaggs.test.ts
@@ -108,11 +108,13 @@ describe('esaggs expression function - server', () => {
expect(handleEsaggsRequest).toHaveBeenCalledWith({
abortSignal: mockHandlers.abortSignal,
- aggs: { foo: 'bar' },
+ aggs: {
+ foo: 'bar',
+ hierarchical: args.metricsAtAllLevels,
+ },
filters: undefined,
indexPattern: {},
inspectorAdapters: mockHandlers.inspectorAdapters,
- metricsAtAllLevels: args.metricsAtAllLevels,
partialRows: args.partialRows,
query: undefined,
searchSessionId: 'abc123',
diff --git a/src/plugins/data/server/search/expressions/esaggs.ts b/src/plugins/data/server/search/expressions/esaggs.ts
index 61fd320d89b95..bb22a491b157e 100644
--- a/src/plugins/data/server/search/expressions/esaggs.ts
+++ b/src/plugins/data/server/search/expressions/esaggs.ts
@@ -9,7 +9,6 @@
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { KibanaRequest, StartServicesAccessor } from 'src/core/server';
-import { Adapters } from 'src/plugins/inspector/common';
import {
EsaggsExpressionFunctionDefinition,
EsaggsStartDependencies,
@@ -61,13 +60,14 @@ export function getFunctionDefinition({
args.aggs!.map((agg) => agg.value)
);
+ aggConfigs.hierarchical = args.metricsAtAllLevels;
+
return await handleEsaggsRequest({
- abortSignal: (abortSignal as unknown) as AbortSignal,
+ abortSignal,
aggs: aggConfigs,
filters: get(input, 'filters', undefined),
indexPattern,
- inspectorAdapters: inspectorAdapters as Adapters,
- metricsAtAllLevels: args.metricsAtAllLevels,
+ inspectorAdapters,
partialRows: args.partialRows,
query: get(input, 'query', undefined) as any,
searchSessionId: getSearchSessionId(),
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index 622356c4441ac..3316e8102e50a 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -26,12 +26,14 @@ import { Ensure } from '@kbn/utility-types';
import { EnvironmentMode } from '@kbn/config';
import { ErrorToastOptions } from 'src/core/public/notifications';
import { estypes } from '@elastic/elasticsearch';
+import { EventEmitter } from 'events';
import { ExecutionContext } from 'src/plugins/expressions/common';
import { ExpressionAstExpression } from 'src/plugins/expressions/common';
import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import { ExpressionValueBoxed } from 'src/plugins/expressions/common';
import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils';
+import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public';
import { ISavedObjectsRepository } from 'src/core/server';
import { IScopedClusterClient } from 'src/core/server';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
@@ -999,13 +1001,11 @@ export interface IScopedSearchClient extends ISearchClient {
export interface ISearchOptions {
abortSignal?: AbortSignal;
indexPattern?: IndexPattern;
+ // Warning: (ae-forgotten-export) The symbol "IInspectorInfo" needs to be exported by the entry point index.d.ts
+ inspector?: IInspectorInfo;
isRestore?: boolean;
isStored?: boolean;
legacyHitsTotal?: boolean;
- // Warning: (ae-forgotten-export) The symbol "RequestResponder" needs to be exported by the entry point index.d.ts
- //
- // (undocumented)
- requestResponder?: RequestResponder;
sessionId?: string;
strategy?: string;
}
diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js
index 35a89eb45f35e..4099d5e8ef7e2 100644
--- a/src/plugins/discover/public/application/angular/discover.js
+++ b/src/plugins/discover/public/application/angular/discover.js
@@ -415,11 +415,20 @@ function discoverController($route, $scope) {
$scope.fetchStatus = fetchStatuses.LOADING;
$scope.resultState = getResultState($scope.fetchStatus, $scope.rows);
+ inspectorAdapters.requests.reset();
return $scope.volatileSearchSource
.fetch$({
abortSignal: abortController.signal,
sessionId: searchSessionId,
- requestResponder: getRequestResponder({ searchSessionId }),
+ inspector: {
+ adapter: inspectorAdapters.requests,
+ title: i18n.translate('discover.inspectorRequestDataTitle', {
+ defaultMessage: 'data',
+ }),
+ description: i18n.translate('discover.inspectorRequestDescription', {
+ defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.',
+ }),
+ },
})
.toPromise()
.then(onResults)
@@ -465,17 +474,6 @@ function discoverController($route, $scope) {
await refetch$.next();
};
- function getRequestResponder({ searchSessionId = null } = { searchSessionId: null }) {
- inspectorAdapters.requests.reset();
- const title = i18n.translate('discover.inspectorRequestDataTitle', {
- defaultMessage: 'data',
- });
- const description = i18n.translate('discover.inspectorRequestDescription', {
- defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.',
- });
- return inspectorAdapters.requests.start(title, { description, searchSessionId });
- }
-
$scope.resetQuery = function () {
history.push(
$route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/'
diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts
index 237da72ae3a52..dbaf07fed18c2 100644
--- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts
+++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts
@@ -317,17 +317,6 @@ export class SearchEmbeddable
// Log request to inspector
this.inspectorAdapters.requests!.reset();
- const title = i18n.translate('discover.embeddable.inspectorRequestDataTitle', {
- defaultMessage: 'Data',
- });
- const description = i18n.translate('discover.embeddable.inspectorRequestDescription', {
- defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.',
- });
-
- const requestResponder = this.inspectorAdapters.requests!.start(title, {
- description,
- searchSessionId,
- });
this.searchScope.$apply(() => {
this.searchScope!.isLoading = true;
@@ -340,7 +329,16 @@ export class SearchEmbeddable
.fetch$({
abortSignal: this.abortController.signal,
sessionId: searchSessionId,
- requestResponder,
+ inspector: {
+ adapter: this.inspectorAdapters.requests,
+ title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', {
+ defaultMessage: 'Data',
+ }),
+ description: i18n.translate('discover.embeddable.inspectorRequestDescription', {
+ defaultMessage:
+ 'This request queries Elasticsearch to fetch the data for the search.',
+ }),
+ },
})
.toPromise();
this.updateOutput({ loading: false, error: undefined });
diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts
index 27164b3cddbc2..b260c594591fa 100644
--- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts
+++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts
@@ -37,11 +37,14 @@ export const defaultEmbeddableFactoryProvider = <
type: def.type,
isEditable: def.isEditable.bind(def),
getDisplayName: def.getDisplayName.bind(def),
+ getDescription: def.getDescription ? def.getDescription.bind(def) : () => '',
+ getIconType: def.getIconType ? def.getIconType.bind(def) : () => 'empty',
savedObjectMetaData: def.savedObjectMetaData,
telemetry: def.telemetry || (() => ({})),
inject: def.inject || ((state: EmbeddableStateWithType) => state),
extract: def.extract || ((state: EmbeddableStateWithType) => ({ state, references: [] })),
migrations: def.migrations || {},
+ grouping: def.grouping,
};
return factory;
};
diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts
index 7f3277130f90f..6ec035f442dd2 100644
--- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts
+++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts
@@ -14,6 +14,7 @@ import { IContainer } from '../containers/i_container';
import { PropertySpec } from '../types';
import { PersistableState } from '../../../../kibana_utils/common';
import { EmbeddableStateWithType } from '../../../common/types';
+import { UiActionsPresentableGrouping } from '../../../../ui_actions/public';
export interface EmbeddableInstanceConfiguration {
id: string;
@@ -48,6 +49,12 @@ export interface EmbeddableFactory<
readonly savedObjectMetaData?: SavedObjectMetaData;
+ /**
+ * Indicates the grouping this factory should appear in a sub-menu. Example, this is used for grouping
+ * options in the editors menu in Dashboard for creating new embeddables
+ */
+ readonly grouping?: UiActionsPresentableGrouping;
+
/**
* True if is this factory create embeddables that are Containers. Used in the add panel to
* conditionally show whether these can be added to another container. It's just not
@@ -62,6 +69,16 @@ export interface EmbeddableFactory<
*/
getDisplayName(): string;
+ /**
+ * Returns an EUI Icon type to be displayed in a menu.
+ */
+ getIconType(): string;
+
+ /**
+ * Returns a description about the embeddable.
+ */
+ getDescription(): string;
+
/**
* If false, this type of embeddable can't be created with the "createNew" functionality. Instead,
* use createFromSavedObject, where an existing saved object must first exist.
diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts
index a64aa32c6e7c4..f2819f2a2e664 100644
--- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts
+++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts
@@ -33,5 +33,8 @@ export type EmbeddableFactoryDefinition<
| 'extract'
| 'inject'
| 'migrations'
+ | 'grouping'
+ | 'getIconType'
+ | 'getDescription'
>
>;
diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx
index 432897763aa04..1c96945f014c8 100644
--- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx
+++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx
@@ -61,6 +61,7 @@ test('createNewEmbeddable() add embeddable to container', async () => {
getAllFactories={start.getEmbeddableFactories}
notifications={core.notifications}
SavedObjectFinder={() => null}
+ showCreateNewMenu
/>
) as ReactWrapper;
@@ -112,6 +113,7 @@ test('selecting embeddable in "Create new ..." list calls createNewEmbeddable()'
getAllFactories={start.getEmbeddableFactories}
notifications={core.notifications}
SavedObjectFinder={(props) => }
+ showCreateNewMenu
/>
) as ReactWrapper;
diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx
index 8caec4a4428c3..6d6a68d7e5e2a 100644
--- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx
+++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx
@@ -26,6 +26,7 @@ interface Props {
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
notifications: CoreSetup['notifications'];
SavedObjectFinder: React.ComponentType;
+ showCreateNewMenu?: boolean;
}
interface State {
@@ -134,7 +135,9 @@ export class AddPanelFlyout extends React.Component {
defaultMessage: 'No matching objects found.',
})}
>
-
+ {this.props.showCreateNewMenu ? (
+
+ ) : null}
);
diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx
index bed97c82095c7..f0c6e81644b3d 100644
--- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx
+++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx
@@ -20,6 +20,7 @@ export function openAddPanelFlyout(options: {
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType;
+ showCreateNewMenu?: boolean;
}): OverlayRef {
const {
embeddable,
@@ -28,6 +29,7 @@ export function openAddPanelFlyout(options: {
overlays,
notifications,
SavedObjectFinder,
+ showCreateNewMenu,
} = options;
const flyoutSession = overlays.openFlyout(
toMountPoint(
@@ -42,6 +44,7 @@ export function openAddPanelFlyout(options: {
getAllFactories={getAllFactories}
notifications={notifications}
SavedObjectFinder={SavedObjectFinder}
+ showCreateNewMenu={showCreateNewMenu}
/>
),
{
diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md
index 220039de2f34e..d522a4e5fa8e8 100644
--- a/src/plugins/embeddable/public/public.api.md
+++ b/src/plugins/embeddable/public/public.api.md
@@ -378,8 +378,12 @@ export interface EmbeddableFactory;
createFromSavedObject(savedObjectId: string, input: Partial, parent?: IContainer): Promise;
getDefaultInput(partial: Partial): Partial;
+ getDescription(): string;
getDisplayName(): string;
getExplicitInput(): Promise>;
+ getIconType(): string;
+ // Warning: (ae-forgotten-export) The symbol "PresentableGrouping" needs to be exported by the entry point index.d.ts
+ readonly grouping?: PresentableGrouping;
readonly isContainerType: boolean;
readonly isEditable: () => Promise;
// Warning: (ae-forgotten-export) The symbol "SavedObjectMetaData" needs to be exported by the entry point index.d.ts
@@ -393,7 +397,7 @@ export interface EmbeddableFactory = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations'>>;
+export type EmbeddableFactoryDefinition = IEmbeddable, T extends SavedObjectAttributes = SavedObjectAttributes> = Pick, 'create' | 'type' | 'isEditable' | 'getDisplayName'> & Partial, 'createFromSavedObject' | 'isContainerType' | 'getExplicitInput' | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' | 'telemetry' | 'extract' | 'inject' | 'migrations' | 'grouping' | 'getIconType' | 'getDescription'>>;
// Warning: (ae-missing-release-tag) "EmbeddableFactoryNotFoundError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -724,6 +728,7 @@ export function openAddPanelFlyout(options: {
overlays: OverlayStart_2;
notifications: NotificationsStart_2;
SavedObjectFinder: React.ComponentType;
+ showCreateNewMenu?: boolean;
}): OverlayRef_2;
// Warning: (ae-missing-release-tag) "OutputSpec" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
diff --git a/src/plugins/es_ui_shared/tsconfig.json b/src/plugins/es_ui_shared/tsconfig.json
index 9bcda2e0614de..5f136d09b2ce4 100644
--- a/src/plugins/es_ui_shared/tsconfig.json
+++ b/src/plugins/es_ui_shared/tsconfig.json
@@ -16,6 +16,6 @@
],
"references": [
{ "path": "../../core/tsconfig.json" },
- { "path": "../data/tsconfig.json" },
+ { "path": "../data/tsconfig.json" }
]
}
diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts
index a20c3e350222f..e5ff33d5c199d 100644
--- a/src/plugins/home/server/services/sample_data/routes/install.ts
+++ b/src/plugins/home/server/services/sample_data/routes/install.ts
@@ -7,7 +7,7 @@
*/
import { schema } from '@kbn/config-schema';
-import { IRouter, Logger, RequestHandlerContext } from 'src/core/server';
+import { IRouter, Logger, IScopedClusterClient } from 'src/core/server';
import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types';
import { createIndexName } from '../lib/create_index_name';
import {
@@ -22,7 +22,7 @@ const insertDataIntoIndex = (
dataIndexConfig: any,
index: string,
nowReference: string,
- context: RequestHandlerContext,
+ esClient: IScopedClusterClient,
logger: Logger
) => {
function updateTimestamps(doc: any) {
@@ -51,9 +51,11 @@ const insertDataIntoIndex = (
bulk.push(insertCmd);
bulk.push(updateTimestamps(doc));
});
- const resp = await context.core.elasticsearch.legacy.client.callAsCurrentUser('bulk', {
+
+ const { body: resp } = await esClient.asCurrentUser.bulk({
body: bulk,
});
+
if (resp.errors) {
const errMsg = `sample_data install errors while bulk inserting. Elasticsearch response: ${JSON.stringify(
resp,
@@ -100,7 +102,7 @@ export function createInstallRoute(
// clean up any old installation of dataset
try {
- await context.core.elasticsearch.legacy.client.callAsCurrentUser('indices.delete', {
+ await context.core.elasticsearch.client.asCurrentUser.indices.delete({
index,
});
} catch (err) {
@@ -108,17 +110,13 @@ export function createInstallRoute(
}
try {
- const createIndexParams = {
+ await context.core.elasticsearch.client.asCurrentUser.indices.create({
index,
body: {
settings: { index: { number_of_shards: 1, auto_expand_replicas: '0-1' } },
mappings: { properties: dataIndexConfig.fields },
},
- };
- await context.core.elasticsearch.legacy.client.callAsCurrentUser(
- 'indices.create',
- createIndexParams
- );
+ });
} catch (err) {
const errMsg = `Unable to create sample data index "${index}", error: ${err.message}`;
logger.warn(errMsg);
@@ -130,7 +128,7 @@ export function createInstallRoute(
dataIndexConfig,
index,
nowReference,
- context,
+ context.core.elasticsearch.client,
logger
);
(counts as any)[index] = count;
diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts
index 86e286644f936..72d8c31cbafd7 100644
--- a/src/plugins/home/server/services/sample_data/routes/list.ts
+++ b/src/plugins/home/server/services/sample_data/routes/list.ts
@@ -36,22 +36,20 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc
const dataIndexConfig = sampleDataset.dataIndices[i];
const index = createIndexName(sampleDataset.id, dataIndexConfig.id);
try {
- const indexExists = await context.core.elasticsearch.legacy.client.callAsCurrentUser(
- 'indices.exists',
- { index }
- );
+ const {
+ body: indexExists,
+ } = await context.core.elasticsearch.client.asCurrentUser.indices.exists({
+ index,
+ });
if (!indexExists) {
sampleDataset.status = NOT_INSTALLED;
return;
}
- const { count } = await context.core.elasticsearch.legacy.client.callAsCurrentUser(
- 'count',
- {
- index,
- }
- );
- if (count === 0) {
+ const { body: count } = await context.core.elasticsearch.client.asCurrentUser.count({
+ index,
+ });
+ if (count.count === 0) {
sampleDataset.status = NOT_INSTALLED;
return;
}
diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts
index aa8ed67cf840a..3108c06492dd8 100644
--- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts
+++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts
@@ -28,11 +28,7 @@ export function createUninstallRoute(
async (
{
core: {
- elasticsearch: {
- legacy: {
- client: { callAsCurrentUser },
- },
- },
+ elasticsearch: { client: esClient },
savedObjects: { getClient: getSavedObjectsClient, typeRegistry },
},
},
@@ -50,7 +46,9 @@ export function createUninstallRoute(
const index = createIndexName(sampleDataset.id, dataIndexConfig.id);
try {
- await callAsCurrentUser('indices.delete', { index });
+ await esClient.asCurrentUser.indices.delete({
+ index,
+ });
} catch (err) {
return response.customError({
statusCode: err.status,
diff --git a/src/plugins/home/server/services/sample_data/usage/collector.ts b/src/plugins/home/server/services/sample_data/usage/collector.ts
index 81958a2e3c878..df7d485c1f6fa 100644
--- a/src/plugins/home/server/services/sample_data/usage/collector.ts
+++ b/src/plugins/home/server/services/sample_data/usage/collector.ts
@@ -6,22 +6,17 @@
* Side Public License, v 1.
*/
-import { PluginInitializerContext } from 'kibana/server';
-import { first } from 'rxjs/operators';
+import type { PluginInitializerContext } from 'kibana/server';
+import type { UsageCollectionSetup } from '../../../../../usage_collection/server';
import { fetchProvider, TelemetryResponse } from './collector_fetch';
-import { UsageCollectionSetup } from '../../../../../usage_collection/server';
-export async function makeSampleDataUsageCollector(
+export function makeSampleDataUsageCollector(
usageCollection: UsageCollectionSetup,
context: PluginInitializerContext
) {
- let index: string;
- try {
- const config = await context.config.legacy.globalConfig$.pipe(first()).toPromise();
- index = config.kibana.index;
- } catch (err) {
- return; // kibana plugin is not enabled (test environment)
- }
+ const config = context.config.legacy.get();
+ const index = config.kibana.index;
+
const collector = usageCollection.makeUsageCollector({
type: 'sample-data',
fetch: fetchProvider(index),
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss
index 79c3d4cca7ace..b8022201acf59 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss
@@ -1,4 +1,3 @@
-
.solutionToolbarButton {
line-height: $euiButtonHeight; // Keeps alignment of text and chart icon
background-color: $euiColorEmptyShade;
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx
index 5de8e24ef5f0d..ee1bbd64b5f87 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx
@@ -12,17 +12,19 @@ import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/butt
import './button.scss';
-export interface Props extends Pick {
+export interface Props
+ extends Pick {
label: string;
primary?: boolean;
+ isDarkModeEnabled?: boolean;
}
-export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => (
+export const SolutionToolbarButton = ({ label, primary, className, ...rest }: Props) => (
{label}
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx
index fbb34e165190d..33850005b498b 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx
@@ -20,14 +20,20 @@ type AllowedPopoverProps = Omit<
export type Props = AllowedButtonProps & AllowedPopoverProps;
-export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => {
+export const SolutionToolbarPopover = ({
+ label,
+ iconType,
+ primary,
+ iconSide,
+ ...popover
+}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const onButtonClick = () => setIsOpen((status) => !status);
const closePopover = () => setIsOpen(false);
const button = (
-
+
);
return (
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss
new file mode 100644
index 0000000000000..c3d89f430d70c
--- /dev/null
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.scss
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 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.
+ */
+
+// Temporary fix for lensApp icon not support ghost color
+.solutionToolbar__primaryButton--dark {
+ .euiIcon path {
+ fill: $euiColorInk;
+ }
+}
+
+.solutionToolbar__primaryButton--light {
+ .euiIcon path {
+ fill: $euiColorGhost;
+ }
+}
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx
index e2ef75e45a404..dcf16228ac63b 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_button.tsx
@@ -10,6 +10,20 @@ import React from 'react';
import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button';
-export const PrimaryActionButton = (props: Omit) => (
-
+import './primary_button.scss';
+
+export interface Props extends Omit {
+ isDarkModeEnabled?: boolean;
+}
+
+export const PrimaryActionButton = ({ isDarkModeEnabled, ...props }: Props) => (
+
);
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss
index 639ff5bf2a117..870a9a945ed5d 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss
@@ -2,4 +2,17 @@
.quickButtonGroup__button {
background-color: $euiColorEmptyShade;
}
+
+ // Temporary fix for two tone icons to make them monochrome
+ .quickButtonGroup__button--dark {
+ .euiIcon path {
+ fill: $euiColorGhost;
+ }
+ }
+ // Temporary fix for two tone icons to make them monochrome
+ .quickButtonGroup__button--light {
+ .euiIcon path {
+ fill: $euiColorInk;
+ }
+ }
}
diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx
index 58f8bd803b636..eb0a395548cd9 100644
--- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx
+++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx
@@ -17,23 +17,27 @@ import './quick_group.scss';
export interface QuickButtonProps extends Pick {
createType: string;
onClick: () => void;
+ isDarkModeEnabled?: boolean;
}
export interface Props {
buttons: QuickButtonProps[];
}
-type Option = EuiButtonGroupOptionProps & Omit;
+type Option = EuiButtonGroupOptionProps &
+ Omit;
export const QuickButtonGroup = ({ buttons }: Props) => {
const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => {
- const { createType: label, ...rest } = button;
+ const { createType: label, isDarkModeEnabled, ...rest } = button;
const title = strings.getAriaButtonLabel(label);
return {
...rest,
'aria-label': title,
- className: 'quickButtonGroup__button',
+ className: `quickButtonGroup__button ${
+ isDarkModeEnabled ? 'quickButtonGroup__button--dark' : 'quickButtonGroup__button--light'
+ }`,
id: `${htmlIdGenerator()()}${index}`,
label,
title,
@@ -46,7 +50,7 @@ export const QuickButtonGroup = ({ buttons }: Props) => {
return (
{
+export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => {
const {
primaryActionButton,
quickButtonGroup,
@@ -49,8 +50,10 @@ export const SolutionToolbar = ({ children }: Props) => {
return (
{primaryActionButton}
diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts
index 9c5f65de40955..fd3ae89419297 100644
--- a/src/plugins/presentation_util/public/index.ts
+++ b/src/plugins/presentation_util/public/index.ts
@@ -19,6 +19,7 @@ export {
LazySavedObjectSaveModalDashboard,
withSuspense,
} from './components';
+
export {
AddFromLibraryButton,
PrimaryActionButton,
diff --git a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts
index d639b053565d1..01d89c5731158 100644
--- a/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts
+++ b/src/plugins/telemetry_collection_manager/server/telemetry_saved_objects_client.ts
@@ -17,7 +17,9 @@ export class TelemetrySavedObjectsClient extends SavedObjectsClient {
* Find the SavedObjects matching the search query in all the Spaces by default
* @param options
*/
- async find(options: SavedObjectsFindOptions): Promise> {
+ async find(
+ options: SavedObjectsFindOptions
+ ): Promise> {
return super.find({ namespaces: ['*'], ...options });
}
}
diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts
index b0ccdbba021ed..8f5770500253f 100644
--- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts
+++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts
@@ -34,6 +34,7 @@ export class VegaBaseView {
destroy(): Promise;
_$container: any;
+ _$controls: any;
_parser: any;
_vegaViewConfig: any;
_serviceSettings: VegaViewParams['serviceSettings'];
diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts
index da4c14c77bc98..53337388dc190 100644
--- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts
+++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts
@@ -36,6 +36,7 @@ describe('vega_map_view/tms_raster_layer', () => {
vegaView: ({
initialize: jest.fn(),
} as unknown) as View,
+ vegaControls: 'element',
updateVegaView: jest.fn(),
};
});
diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts
index a3efba804b454..8972b80cb99c5 100644
--- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts
+++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts
@@ -13,12 +13,13 @@ import type { LayerParameters } from './types';
export interface VegaLayerContext {
vegaView: View;
updateVegaView: (map: Map, view: View) => void;
+ vegaControls: any;
}
export function initVegaLayer({
id,
map: mapInstance,
- context: { vegaView, updateVegaView },
+ context: { vegaView, vegaControls, updateVegaView },
}: LayerParameters) {
const vegaLayer: CustomLayerInterface = {
id,
@@ -34,7 +35,7 @@ export function initVegaLayer({
vegaContainer.style.height = mapCanvas.style.height;
mapContainer.appendChild(vegaContainer);
- vegaView.initialize(vegaContainer);
+ vegaView.initialize(vegaContainer, vegaControls);
},
render() {
updateVegaView(mapInstance, vegaView);
diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts
index b1ec79e6b8310..61ae1ce4e5d78 100644
--- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts
+++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts
@@ -175,6 +175,7 @@ export class VegaMapView extends VegaBaseView {
map: mapBoxInstance,
context: {
vegaView,
+ vegaControls: this._$controls.get(0),
updateVegaView,
},
});
diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx
index 2b5a611cd946e..48bff8d203ebd 100644
--- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx
+++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx
@@ -113,7 +113,7 @@ export class VisualizeEmbeddableFactory
public getDisplayName() {
return i18n.translate('visualizations.displayName', {
- defaultMessage: 'visualization',
+ defaultMessage: 'Visualization',
});
}
diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts
index e5b1ba73d9d1c..dbcbb864d2316 100644
--- a/src/plugins/visualizations/public/index.ts
+++ b/src/plugins/visualizations/public/index.ts
@@ -25,7 +25,7 @@ export { getVisSchemas } from './vis_schemas';
/** @public types */
export { VisualizationsSetup, VisualizationsStart };
export { VisGroups } from './vis_types';
-export type { VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types';
+export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types';
export { SerializedVis, SerializedVisData, VisData } from './vis';
export type VisualizeEmbeddableFactoryContract = PublicContract;
export type VisualizeEmbeddableContract = PublicContract;
diff --git a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx
index 1de177e12f40d..c92514d54166f 100644
--- a/src/plugins/visualizations/public/wizard/dialog_navigation.tsx
+++ b/src/plugins/visualizations/public/wizard/dialog_navigation.tsx
@@ -24,7 +24,7 @@ function DialogNavigation(props: DialogNavigationProps) {