diff --git a/docs/discover/kuery.asciidoc b/docs/discover/kuery.asciidoc index abf3e05fb7819..c835c15028074 100644 --- a/docs/discover/kuery.asciidoc +++ b/docs/discover/kuery.asciidoc @@ -19,14 +19,13 @@ they appear. This means documents with "quick brown fox" will match, but so will to search for a phrase. The query parser will no longer split on whitespace. Multiple search terms must be separated by explicit -boolean operators. Note that boolean operators are not case sensitive. +boolean operators. Lucene will combine search terms with an `or` by default, so `response:200 extension:php` would +become `response:200 or extension:php` in KQL. This will match documents where response matches 200, extension matches php, or both. +Note that boolean operators are not case sensitive. -`response:200 extension:php` in lucene would become `response:200 and extension:php`. - This will match documents where response matches 200 and extension matches php. +We can make terms required by using `and`. -We can make terms optional by using `or`. - -`response:200 or extension:php` will match documents where response matches 200, extension matches php, or both. +`response:200 and extension:php` will match documents where response matches 200 and extension matches php. By default, `and` has a higher precedence than `or`. @@ -73,7 +72,7 @@ set these terms will be matched against all fields. For example, a query for `re in the response field, but a query for just `200` will search for 200 across all fields in your index. ============ -===== Nested Field Support +==== Nested Field Support KQL supports querying on {ref}/nested.html[nested fields] through a special syntax. You can query nested fields in subtly different ways, depending on the results you want, so crafting nested queries requires extra thought. @@ -85,7 +84,8 @@ There are two main approaches to take: * *Parts of the query can match different nested documents.* This is how a regular object field works. Although generally less useful, there might be occasions where you want to query a nested field in this way. -Let's take a look at the first approach. In the following document, `items` is a nested field: +Let's take a look at the first approach. In the following document, `items` is a nested field. Each document in the nested +field contains a name, stock, and category. [source,json] ---------------------------------- @@ -116,21 +116,38 @@ Let's take a look at the first approach. In the following document, `items` is a } ---------------------------------- +===== Match a single nested document + To find stores that have more than 10 bananas in stock, you would write a query like this: `items:{ name:banana and stock > 10 }` -`items` is the "nested path". Everything inside the curly braces (the "nested group") must match a single document. -For example, `items:{ name:banana and stock:9 }` does not match because there isn't a single nested document that -matches the entire query in the nested group. +`items` is the "nested path". Everything inside the curly braces (the "nested group") must match a single nested document. + +The following example returns no matches because no single nested document has bananas with a stock of 9. + +`items:{ name:banana and stock:9 }` + +==== Match different nested documents -What if you want to find a store with more than 10 bananas that *also* stocks vegetables? This is the second way of querying a nested field, and you can do it like this: +The subqueries in this example are in separate nested groups and can match different nested documents. + +`items:{ name:banana } and items:{ stock:9 }` + +`name:banana` matches the first document in the array and `stock:9` matches the third document in the array. + +==== Combine approaches + +You can combine these two approaches to create complex queries. What if you wanted to find a store with more than 10 +bananas that *also* stocks vegetables? You could do this: `items:{ name:banana and stock > 10 } and items:{ category:vegetable }` The first nested group (`name:banana and stock > 10`) must still match a single document, but the `category:vegetables` subquery can match a different nested document because it is in a separate group. +==== Nested fields inside other nested fields + KQL's syntax also supports nested fields inside of other nested fields—you simply have to specify the full path. Suppose you have a document where `level1` and `level2` are both nested fields: diff --git a/docs/images/lens_data_info.gif b/docs/images/lens_data_info.gif new file mode 100644 index 0000000000000..e2c565de9f6a7 Binary files /dev/null and b/docs/images/lens_data_info.gif differ diff --git a/docs/images/lens_drag_drop.gif b/docs/images/lens_drag_drop.gif new file mode 100644 index 0000000000000..39cde64fb97eb Binary files /dev/null and b/docs/images/lens_drag_drop.gif differ diff --git a/docs/images/lens_remove_layer.png b/docs/images/lens_remove_layer.png new file mode 100644 index 0000000000000..4184e5b846870 Binary files /dev/null and b/docs/images/lens_remove_layer.png differ diff --git a/docs/images/lens_suggestions.gif b/docs/images/lens_suggestions.gif new file mode 100644 index 0000000000000..0452207b86456 Binary files /dev/null and b/docs/images/lens_suggestions.gif differ diff --git a/docs/images/lens_tutorial_1.png b/docs/images/lens_tutorial_1.png new file mode 100644 index 0000000000000..7992276c833e7 Binary files /dev/null and b/docs/images/lens_tutorial_1.png differ diff --git a/docs/images/lens_tutorial_2.png b/docs/images/lens_tutorial_2.png new file mode 100644 index 0000000000000..b47e7feff3b9f Binary files /dev/null and b/docs/images/lens_tutorial_2.png differ diff --git a/docs/images/lens_tutorial_3.png b/docs/images/lens_tutorial_3.png new file mode 100644 index 0000000000000..ea40b458202b7 Binary files /dev/null and b/docs/images/lens_tutorial_3.png differ diff --git a/docs/user/visualize.asciidoc b/docs/user/visualize.asciidoc index eec9ef65cba90..ed74525d22e7c 100644 --- a/docs/user/visualize.asciidoc +++ b/docs/user/visualize.asciidoc @@ -24,9 +24,10 @@ To create a visualization: . Click on *Visualize* in the side navigation. . Click the *Create new visualization* button or the **+** button. . Choose the visualization type: -+ + * *Basic charts* [horizontal] +<>:: Quickly build several types of basic visualizations by simply dragging and dropping the data fields you want to display. <>:: Compare different series in X/Y charts. <>:: Shade cells within a matrix. <>:: Display each source's contribution to a total. @@ -142,6 +143,8 @@ include::{kib-repo-dir}/visualize/saving.asciidoc[] include::{kib-repo-dir}/visualize/visualize_rollup_data.asciidoc[] +include::{kib-repo-dir}/visualize/lens.asciidoc[] + include::{kib-repo-dir}/visualize/xychart.asciidoc[] include::{kib-repo-dir}/visualize/controls.asciidoc[] diff --git a/docs/visualize/lens.asciidoc b/docs/visualize/lens.asciidoc new file mode 100644 index 0000000000000..086f88c636c81 --- /dev/null +++ b/docs/visualize/lens.asciidoc @@ -0,0 +1,186 @@ +[role="xpack"] +[[lens]] +== Lens + +beta[] + +*Lens* provides you with a simple and fast way to create visualizations from your Elasticsearch data. With Lens, you can: + +* Quickly build visualizations by dragging and dropping data fields. + +* Understand your data with a summary view on each field. + +* Easily change the visualization type by selecting the automatically generated visualization suggestions. + +* Save your visualization for use in a dashboard. + +[float] +[[drag-drop]] +=== Drag and drop + +The data panel in the left column shows the data fields for the selected time period. When +you drag a field from the data panel, Lens highlights where you can drop that field. The first time you drag a data field, +you'll see two places highlighted in green: + +* The visualization builder pane + +* The *X-axis* or *Y-axis* fields in the right column + +You can incorporate many fields into your visualization, and Lens uses heuristics to decide how +to apply each one to the visualization. + +[role="screenshot"] +image::images/lens_drag_drop.gif[] + +TIP: Drag-and-drop capabilities are available only when Lens knows how to use the data. You can still customize +your visualization if Lens is unable to make a suggestion. + +[float] +[[apply-lens-filters]] +==== Find the right data + +Lens shows you fields based on the <> you have defined in +{kib}, and the current time range. When you change the index pattern or time filter, +the list of fields are updated. + +To narrow the list of fields you see in the left panel, you can: + +* Enter the field name in *Search field names*. + +* Click *Filter by type*, then select the filter. You can also select *Only show fields with data* +to show the full list of fields from the index pattern. + +[float] +[[view-data-summaries]] +==== Data summaries + +To help you decide exactly the data you want to display, get a quick summary of each data field. +The summary shows the distribution of values in the time range. + +To view the data information, navigate to a data field, then click *i*. + +[role="screenshot"] +image::images/lens_data_info.gif[] + +[float] +[[change-the-visualization-type]] +==== Change the visualization type + +With Lens, you are no longer required to build each visualization from scratch. Lens allows +you to switch between any supported chart type at any time. Lens also provides +suggestions, which are shortcuts to alternate visualizations based on the data you have. + +You can switch between suggestions without losing your previous state: + +[role="screenshot"] +image::images/lens_suggestions.gif[] + +If you want to switch to a chart type that is not suggested, click the chart type in the +top right, then select a chart type. When there is an exclamation point (!) +next to a chart type, Lens is unable to transfer your current data, but +still allows you to make the change. + +[float] +[[customize-operation]] +==== Customize the data for your visualization + +Lens allows some customizations of the data for each visualization. + +. Change the index pattern. + +.. In the left column, click the index pattern name. + +.. Select the new index pattern. ++ +If there is a match, Lens displays the new data. All fields that do not match the index pattern are removed. + +. Change the data field options, such as the aggregation or label. + +.. Click *Drop a field here* or the field name in the right column. + +.. Change the options that appear depending on the type of field. + +[float] +[[layers]] +==== Layers in bar, line, and area charts + +The bar, line, and area charts allow you to layer two different series. To add a layer, click *+*. + +To remove a layer, click the chart icon next to the index name: + +[role="screenshot"] +image::images/lens_remove_layer.png[] + +[float] +[[lens-tutorial]] +=== Lens tutorial + +Ready to create your own visualization with Lens? Use the following tutorial to create a visualization that +lets you compare sales over time. + +[float] +[[lens-before-begin]] +==== Before you begin + +To start, you'll need to add the <>. + +[float] +==== Build the visualization + +Drag and drop your data onto the visualization builder pane. + +. Open *Visualize*, then click *Create visualization*. + +. On the *New Visualization* window, click *Lens*. + +. In the left column, select the *kibana_sample_data_ecommerce* index. + +. Click image:images/time-filter-calendar.png[], then click *Last 7 days*. The list of data fields are updated. + +. Drag and drop the *taxful_total_price* data field to the visualization builder pane. ++ +[role="screenshot"] +image::images/lens_tutorial_1.png[Lens tutorial] + +Lens has taken your intent to see *taxful_total_price* and added in the *order_date* field to show +average order prices over time. + +To break down your data, drag the *category.keyword* field to the visualization builder pane. Lens +understands that you want to show the top categories and compare them across the dates, +and creates a chart that compares the sales for each of the top 3 categories: + +[role="screenshot"] +image::images/lens_tutorial_2.png[Lens tutorial] + +[float] +[[customize-lens-visualization]] +==== Further customization + +Customize your visualization to look exactly how you want. + +. In the right column, click *Average of taxful_total_price*. + +.. Change the *Label* to `Sales`, or a name that you prefer for the data. + +. Click *Top values of category.keyword*. + +.. Increase *Number of values* to `10`. The visualization updates in the background to show there are only +six available categories. + +. Look at the suggestions. None of them show an area chart, but for sales data, a stacked area chart +might make sense. To switch the chart type: + +.. Click *Stacked bar chart* in the right column. + +.. Click *Stacked area*. ++ +[role="screenshot"] +image::images/lens_tutorial_3.png[Lens tutorial] + +[float] +[[lens-tutorial-next-steps]] +==== Next steps + +Now that you've created your visualization in Lens, you can add it to a Dashboard. + +For more information, see <>. diff --git a/packages/kbn-es/src/custom_snapshots.js b/packages/kbn-es/src/custom_snapshots.js index be6bbeca538ff..74de3c2c792fd 100644 --- a/packages/kbn-es/src/custom_snapshots.js +++ b/packages/kbn-es/src/custom_snapshots.js @@ -24,10 +24,9 @@ function isVersionFlag(a) { } function getCustomSnapshotUrl() { - // force use of manually created snapshots until live ones are available + // force use of manually created snapshots until ReindexPutMappings fix if (!process.env.KBN_ES_SNAPSHOT_URL && !process.argv.some(isVersionFlag)) { - // return 'https://storage.googleapis.com/kibana-ci-tmp-artifacts/{name}-{version}-{os}-x86_64.{ext}'; - return; + return 'https://storage.googleapis.com/kibana-ci-tmp-artifacts/{name}-{version}-{os}-x86_64.{ext}'; } if (process.env.KBN_ES_SNAPSHOT_URL && process.env.KBN_ES_SNAPSHOT_URL !== 'false') { diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 6989c2159dce3..0dcc8d55d31a4 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1139,7 +1139,7 @@ import { setup, start } from '../core_plugins/visualizations/public/legacy'; | `ui/embeddable` | `embeddables` | still in progress | | `ui/filter_manager` | `data.filter` | -- | | `ui/index_patterns` | `data.indexPatterns` | still in progress | -| `ui/registry/feature_catalogue | `feature_catalogue.register` | Must add `feature_catalogue` as a dependency in your kibana.json. | +| `ui/registry/feature_catalogue` | `home.featureCatalogue.register` | Must add `home` as a dependency in your kibana.json. | | `ui/registry/vis_types` | `visualizations.types` | -- | | `ui/vis` | `visualizations.types` | -- | | `ui/share` | `share` | `showShareContextMenu` is now called `toggleShareContextMenu`, `ShareContextMenuExtensionsRegistryProvider` is now called `register` | @@ -1182,7 +1182,7 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `fieldFormatEditors` | | | | `fieldFormats` | | | | `hacks` | n/a | Just run the code in your plugin's `start` method. | -| `home` | [`plugins.feature_catalogue.register`](./src/plugins/feature_catalogue) | Must add `feature_catalogue` as a dependency in your kibana.json. | +| `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | | `injectDefaultVars` | n/a | Plugins will only be able to "whitelist" config values for the frontend. See [#41990](https://github.com/elastic/kibana/issues/41990) | | `inspectorViews` | | Should be an API on the data (?) plugin. | diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index b9cd2577c2217..afd0825ec986c 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -117,9 +117,22 @@ function createCoreContext(): CoreContext { }; } +function createStorageMock() { + const storageMock: jest.Mocked = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 10, + }; + return storageMock; +} + export const coreMock = { createCoreContext, createSetup: createCoreSetupMock, createStart: createCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, + createStorage: createStorageMock, }; 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 80a6a96aeaf2b..4c4f321695d70 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 @@ -405,5 +405,29 @@ describe('Filter Utils', () => { }, ]); }); + + test('Return Error if filter is using an non-existing key null key', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression('foo.attributes.description: hello AND bye'), + ['foo'], + mockMappings + ); + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1', + error: 'The key is empty and needs to be wrapped by a saved object type like foo', + isSavedObjectAttr: false, + key: null, + type: null, + }, + ]); + }); }); }); 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 64abf268cacd6..e331d3eff990f 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -128,7 +128,8 @@ export const validateFilterKueryNode = ( }, []); }; -const getType = (key: string) => (key.includes('.') ? key.split('.')[0] : null); +const getType = (key: string | undefined | null) => + key != null && key.includes('.') ? key.split('.')[0] : null; /** * Is this filter key referring to a a top-level SavedObject attribute such as @@ -137,8 +138,8 @@ const getType = (key: string) => (key.includes('.') ? key.split('.')[0] : null); * @param key * @param indexMapping */ -export const isSavedObjectAttr = (key: string, indexMapping: IndexMapping) => { - const keySplit = key.split('.'); +export const isSavedObjectAttr = (key: string | null | undefined, indexMapping: IndexMapping) => { + const keySplit = key != null ? key.split('.') : []; if (keySplit.length === 1 && fieldDefined(indexMapping, keySplit[0])) { return true; } else if (keySplit.length === 2 && fieldDefined(indexMapping, keySplit[1])) { @@ -149,10 +150,13 @@ export const isSavedObjectAttr = (key: string, indexMapping: IndexMapping) => { }; export const hasFilterKeyError = ( - key: string, + key: string | null | undefined, types: string[], indexMapping: IndexMapping ): string | null => { + if (key == null) { + return `The key is empty and needs to be wrapped by a saved object type like ${types.join()}`; + } if (!key.includes('.')) { return `This key '${key}' need to be wrapped by a saved object type like ${types.join()}`; } else if (key.includes('.')) { diff --git a/src/core/utils/poller.test.ts b/src/core/utils/poller.test.ts deleted file mode 100644 index df89f7341c956..0000000000000 --- a/src/core/utils/poller.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Poller } from './poller'; - -const delay = (duration: number) => new Promise(r => setTimeout(r, duration)); - -// FLAKY: https://github.com/elastic/kibana/issues/44560 -describe.skip('Poller', () => { - let handler: jest.Mock; - let poller: Poller; - - beforeEach(() => { - handler = jest.fn().mockImplementation((iteration: number) => `polling-${iteration}`); - poller = new Poller(100, 'polling', handler); - }); - - afterEach(() => { - poller.unsubscribe(); - }); - - it('returns an observable of subject', async () => { - await delay(300); - expect(poller.subject$.getValue()).toBe('polling-2'); - }); - - it('executes a function on an interval', async () => { - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('no longer polls after unsubscribing', async () => { - await delay(300); - poller.unsubscribe(); - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('does not add next value if returns undefined', async () => { - const values: any[] = []; - const polling = new Poller(100, 'polling', iteration => { - if (iteration % 2 === 0) { - return `polling-${iteration}`; - } - }); - - polling.subject$.subscribe(value => { - values.push(value); - }); - await delay(300); - polling.unsubscribe(); - - expect(values).toEqual(['polling', 'polling-0', 'polling-2']); - }); -}); diff --git a/src/core/utils/poller.ts b/src/core/utils/poller.ts deleted file mode 100644 index 7c50db74bcefb..0000000000000 --- a/src/core/utils/poller.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { BehaviorSubject, timer } from 'rxjs'; - -/** - * Create an Observable BehaviorSubject to invoke a function on an interval - * which returns the next value for the observable. - * @public - */ -export class Poller { - /** - * The observable to observe for changes to the poller value. - */ - public readonly subject$ = new BehaviorSubject(this.initialValue); - private poller$ = timer(0, this.frequency); - private subscription = this.poller$.subscribe(async iteration => { - const next = await this.handler(iteration); - - if (next !== undefined) { - this.subject$.next(next); - } - - return iteration; - }); - - constructor( - private frequency: number, - private initialValue: T, - private handler: (iteration: number) => Promise | T | undefined - ) {} - - /** - * Permanently end the polling operation. - */ - unsubscribe() { - return this.subscription.unsubscribe(); - } -} diff --git a/src/legacy/core_plugins/console/np_ready/public/legacy.ts b/src/legacy/core_plugins/console/np_ready/public/legacy.ts index 463aac74da944..b610cf7e6a3bb 100644 --- a/src/legacy/core_plugins/console/np_ready/public/legacy.ts +++ b/src/legacy/core_plugins/console/np_ready/public/legacy.ts @@ -30,7 +30,7 @@ import { I18nContext } from 'ui/i18n'; export interface XPluginSet { devTools: DevToolsSetup; - feature_catalogue: FeatureCatalogueSetup; + home: HomePublicPluginSetup; __LEGACY: { I18nContext: any; }; @@ -38,7 +38,7 @@ export interface XPluginSet { import { plugin } from '.'; import { DevToolsSetup } from '../../../../../plugins/dev_tools/public'; -import { FeatureCatalogueSetup } from '../../../../../plugins/feature_catalogue/public'; +import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; const pluginInstance = plugin({} as any); diff --git a/src/legacy/core_plugins/console/np_ready/public/plugin.ts b/src/legacy/core_plugins/console/np_ready/public/plugin.ts index 301b85b6e7395..4050f20a4fb07 100644 --- a/src/legacy/core_plugins/console/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/console/np_ready/public/plugin.ts @@ -32,10 +32,10 @@ export class ConsoleUIPlugin implements Plugin { const { __LEGACY: { I18nContext }, devTools, - feature_catalogue, + home, } = pluginSet; - feature_catalogue.register({ + home.featureCatalogue.register({ id: 'console', title: i18n.translate('console.devToolsTitle', { defaultMessage: 'Console', diff --git a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/get_app_state_mock.ts b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/get_app_state_mock.ts index 1f2094d68063d..d9dea35a8a1c0 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/get_app_state_mock.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/get_app_state_mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { AppStateClass } from 'ui/state_management/app_state'; +import { AppStateClass } from '../legacy_imports'; /** * A poor excuse for a mock just to get some basic tests to run in jest without requiring the injector. diff --git a/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss b/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss index eebfad5979d68..14c35759d70a9 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss +++ b/src/legacy/core_plugins/kibana/public/dashboard/_dashboard_app.scss @@ -1,7 +1,7 @@ .dshAppContainer { - flex: 1; display: flex; flex-direction: column; + height: 100%; } .dshStartScreen { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/application.ts b/src/legacy/core_plugins/kibana/public/dashboard/application.ts new file mode 100644 index 0000000000000..d507d547d9ba9 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/application.ts @@ -0,0 +1,228 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiConfirmModal, EuiIcon } from '@elastic/eui'; +import angular, { IModule } from 'angular'; +import { IPrivate } from 'ui/private'; +import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; +import { + AppMountContext, + ChromeStart, + LegacyCoreStart, + SavedObjectsClientContract, + UiSettingsClientContract, +} from 'kibana/public'; +import { Storage } from '../../../../../plugins/kibana_utils/public'; +import { + GlobalStateProvider, + StateManagementConfigProvider, + AppStateProvider, + PrivateProvider, + EventsProvider, + PersistedState, + createTopNavDirective, + createTopNavHelper, + PromiseServiceCreator, + KbnUrlProvider, + RedirectWhenMissingProvider, + confirmModalFactory, + configureAppAngularModule, +} from './legacy_imports'; + +// @ts-ignore +import { initDashboardApp } from './legacy_app'; +import { DataStart } from '../../../data/public'; +import { EmbeddablePublicPlugin } from '../../../../../plugins/embeddable/public'; +import { NavigationStart } from '../../../navigation/public'; +import { DataPublicPluginStart as NpDataStart } from '../../../../../plugins/data/public'; +import { SharePluginStart } from '../../../../../plugins/share/public'; + +export interface RenderDeps { + core: LegacyCoreStart; + indexPatterns: DataStart['indexPatterns']['indexPatterns']; + dataStart: DataStart; + npDataStart: NpDataStart; + navigation: NavigationStart; + savedObjectsClient: SavedObjectsClientContract; + savedObjectRegistry: any; + dashboardConfig: any; + savedDashboards: any; + dashboardCapabilities: any; + uiSettings: UiSettingsClientContract; + chrome: ChromeStart; + addBasePath: (path: string) => string; + savedQueryService: DataStart['search']['services']['savedQueryService']; + embeddables: ReturnType; + localStorage: Storage; + share: SharePluginStart; +} + +let angularModuleInstance: IModule | null = null; + +export const renderApp = (element: HTMLElement, appBasePath: string, deps: RenderDeps) => { + if (!angularModuleInstance) { + angularModuleInstance = createLocalAngularModule(deps.core, deps.navigation); + // global routing stuff + configureAppAngularModule(angularModuleInstance, deps.core as LegacyCoreStart, true); + // custom routing stuff + initDashboardApp(angularModuleInstance, deps); + } + const $injector = mountDashboardApp(appBasePath, element); + return () => { + $injector.get('$rootScope').$destroy(); + }; +}; + +const mainTemplate = (basePath: string) => `
+ +
+
+`; + +const moduleName = 'app/dashboard'; + +const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; + +function mountDashboardApp(appBasePath: string, element: HTMLElement) { + const mountpoint = document.createElement('div'); + mountpoint.setAttribute('style', 'height: 100%'); + // eslint-disable-next-line + mountpoint.innerHTML = mainTemplate(appBasePath); + // bootstrap angular into detached element and attach it later to + // make angular-within-angular possible + const $injector = angular.bootstrap(mountpoint, [moduleName]); + // initialize global state handler + element.appendChild(mountpoint); + return $injector; +} + +function createLocalAngularModule(core: AppMountContext['core'], navigation: NavigationStart) { + createLocalI18nModule(); + createLocalPrivateModule(); + createLocalPromiseModule(); + createLocalConfigModule(core); + createLocalKbnUrlModule(); + createLocalStateModule(); + createLocalPersistedStateModule(); + createLocalTopNavModule(navigation); + createLocalConfirmModalModule(); + createLocalIconModule(); + + const dashboardAngularModule = angular.module(moduleName, [ + ...thirdPartyAngularDependencies, + 'app/dashboard/Config', + 'app/dashboard/I18n', + 'app/dashboard/Private', + 'app/dashboard/PersistedState', + 'app/dashboard/TopNav', + 'app/dashboard/State', + 'app/dashboard/ConfirmModal', + 'app/dashboard/icon', + ]); + return dashboardAngularModule; +} + +function createLocalIconModule() { + angular + .module('app/dashboard/icon', ['react']) + .directive('icon', reactDirective => reactDirective(EuiIcon)); +} + +function createLocalConfirmModalModule() { + angular + .module('app/dashboard/ConfirmModal', ['react']) + .factory('confirmModal', confirmModalFactory) + .directive('confirmModal', reactDirective => reactDirective(EuiConfirmModal)); +} + +function createLocalStateModule() { + angular + .module('app/dashboard/State', [ + 'app/dashboard/Private', + 'app/dashboard/Config', + 'app/dashboard/KbnUrl', + 'app/dashboard/Promise', + 'app/dashboard/PersistedState', + ]) + .factory('AppState', function(Private: any) { + return Private(AppStateProvider); + }) + .service('getAppState', function(Private: any) { + return Private(AppStateProvider).getAppState; + }) + .service('globalState', function(Private: any) { + return Private(GlobalStateProvider); + }); +} + +function createLocalPersistedStateModule() { + angular + .module('app/dashboard/PersistedState', ['app/dashboard/Private', 'app/dashboard/Promise']) + .factory('PersistedState', (Private: IPrivate) => { + const Events = Private(EventsProvider); + return class AngularPersistedState extends PersistedState { + constructor(value: any, path: any) { + super(value, path, Events); + } + }; + }); +} + +function createLocalKbnUrlModule() { + angular + .module('app/dashboard/KbnUrl', ['app/dashboard/Private', 'ngRoute']) + .service('kbnUrl', (Private: IPrivate) => Private(KbnUrlProvider)) + .service('redirectWhenMissing', (Private: IPrivate) => Private(RedirectWhenMissingProvider)); +} + +function createLocalConfigModule(core: AppMountContext['core']) { + angular + .module('app/dashboard/Config', ['app/dashboard/Private']) + .provider('stateManagementConfig', StateManagementConfigProvider) + .provider('config', () => { + return { + $get: () => ({ + get: core.uiSettings.get.bind(core.uiSettings), + }), + }; + }); +} + +function createLocalPromiseModule() { + angular.module('app/dashboard/Promise', []).service('Promise', PromiseServiceCreator); +} + +function createLocalPrivateModule() { + angular.module('app/dashboard/Private', []).provider('Private', PrivateProvider); +} + +function createLocalTopNavModule(navigation: NavigationStart) { + angular + .module('app/dashboard/TopNav', ['react']) + .directive('kbnTopNav', createTopNavDirective) + .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); +} + +function createLocalI18nModule() { + angular + .module('app/dashboard/I18n', []) + .provider('i18n', I18nProvider) + .filter('i18n', i18nFilter) + .directive('i18nId', i18nDirective); +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index f644f3811e3e0..a94fd500257d9 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -4,11 +4,11 @@ >
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx index d5da4ba51e55b..0ce8f2ef59fc0 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.tsx @@ -17,26 +17,16 @@ * under the License. */ -import _ from 'lodash'; - -// @ts-ignore -import { uiModules } from 'ui/modules'; -import { IInjector } from 'ui/chrome'; - -// @ts-ignore -import * as filterActions from 'plugins/kibana/discover/doc_table/actions/filter'; +import { StaticIndexPattern, SavedQuery } from 'plugins/data'; +import moment from 'moment'; +import { Subscription } from 'rxjs'; import { AppStateClass as TAppStateClass, AppState as TAppState, -} from 'ui/state_management/app_state'; - -import { KbnUrl } from 'ui/url/kbn_url'; -import { IndexPattern } from 'ui/index_patterns'; -import { IPrivate } from 'ui/private'; -import { StaticIndexPattern, SavedQuery } from 'plugins/data'; -import moment from 'moment'; -import { Subscription } from 'rxjs'; + IInjector, + KbnUrl, +} from './legacy_imports'; import { ViewMode } from '../../../embeddable_api/public/np_ready/public'; import { SavedObjectDashboard } from './saved_dashboard/saved_dashboard'; @@ -44,6 +34,7 @@ import { DashboardAppState, SavedDashboardPanel, ConfirmModalFn } from './types' import { TimeRange, Query, esFilters } from '../../../../../../src/plugins/data/public'; import { DashboardAppController } from './dashboard_app_controller'; +import { RenderDeps } from './application'; export interface DashboardAppScope extends ng.IScope { dash: SavedObjectDashboard; @@ -90,54 +81,40 @@ export interface DashboardAppScope extends ng.IScope { kbnTopNav: any; enterEditMode: () => void; timefilterSubscriptions$: Subscription; + isVisible: boolean; } -const app = uiModules.get('app/dashboard', ['elasticsearch', 'ngRoute', 'react', 'kibana/config']); - -app.directive('dashboardApp', function($injector: IInjector) { - const AppState = $injector.get>('AppState'); - const kbnUrl = $injector.get('kbnUrl'); - const confirmModal = $injector.get('confirmModal'); - const config = $injector.get('config'); - - const Private = $injector.get('Private'); +export function initDashboardAppDirective(app: any, deps: RenderDeps) { + app.directive('dashboardApp', function($injector: IInjector) { + const AppState = $injector.get>('AppState'); + const kbnUrl = $injector.get('kbnUrl'); + const confirmModal = $injector.get('confirmModal'); + const config = deps.uiSettings; - const indexPatterns = $injector.get<{ - getDefault: () => Promise; - }>('indexPatterns'); - - return { - restrict: 'E', - controllerAs: 'dashboardApp', - controller: ( - $scope: DashboardAppScope, - $route: any, - $routeParams: { - id?: string; - }, - getAppState: { - previouslyStored: () => TAppState | undefined; - }, - dashboardConfig: { - getHideWriteControls: () => boolean; - }, - localStorage: { - get: (prop: string) => unknown; - } - ) => - new DashboardAppController({ - $route, - $scope, - $routeParams, - getAppState, - dashboardConfig, - localStorage, - Private, - kbnUrl, - AppStateClass: AppState, - indexPatterns, - config, - confirmModal, - }), - }; -}); + return { + restrict: 'E', + controllerAs: 'dashboardApp', + controller: ( + $scope: DashboardAppScope, + $route: any, + $routeParams: { + id?: string; + }, + getAppState: any, + globalState: any + ) => + new DashboardAppController({ + $route, + $scope, + $routeParams, + getAppState, + globalState, + kbnUrl, + AppStateClass: AppState, + config, + confirmModal, + ...deps, + }), + }; + }); +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx index 457d8972876ae..16c0e4437c344 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx @@ -23,41 +23,23 @@ import React from 'react'; import angular from 'angular'; import { uniq } from 'lodash'; -import chrome from 'ui/chrome'; -import { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; -import { toastNotifications } from 'ui/notify'; - -// @ts-ignore -import { ConfirmationButtonTypes } from 'ui/modals/confirm_modal'; -import { FilterBarQueryFilterProvider } from 'ui/filter_manager/query_filter'; - -import { docTitle } from 'ui/doc_title/doc_title'; - -import { showSaveModal, SaveResult } from 'ui/saved_objects/show_saved_object_save_modal'; - -import { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query'; - -import { timefilter } from 'ui/timefilter'; - -import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing/get_unhashable_states_provider'; +import { Subscription } from 'rxjs'; import { + subscribeWithScope, + ConfirmationButtonTypes, + showSaveModal, + SaveResult, + migrateLegacyQuery, + State, AppStateClass as TAppStateClass, - AppState as TAppState, -} from 'ui/state_management/app_state'; - -import { KbnUrl } from 'ui/url/kbn_url'; -import { IndexPattern } from 'ui/index_patterns'; -import { IPrivate } from 'ui/private'; -import { SavedQuery } from 'src/legacy/core_plugins/data/public'; -import { SaveOptions } from 'ui/saved_objects/saved_object'; -import { capabilities } from 'ui/capabilities'; -import { Subscription } from 'rxjs'; -import { npStart } from 'ui/new_platform'; -import { unhashUrl } from 'ui/state_management/state_hashing'; -import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; + KbnUrl, + SaveOptions, + SavedObjectFinder, + unhashUrl, +} from './legacy_imports'; +import { FilterStateManager, IndexPattern, SavedQuery } from '../../../data/public'; import { Query } from '../../../../../plugins/data/public'; -import { start as data } from '../../../data/public/legacy'; import { DashboardContainer, @@ -72,7 +54,6 @@ import { ViewMode, openAddPanelFlyout, } from '../../../embeddable_api/public/np_ready/public'; -import { start } from '../../../embeddable_api/public/np_ready/public/legacy'; import { DashboardAppState, NavAction, ConfirmModalFn, SavedDashboardPanel } from './types'; import { showOptionsPopover } from './top_nav/show_options_popover'; @@ -87,8 +68,23 @@ import { getDashboardTitle } from './dashboard_strings'; import { DashboardAppScope } from './dashboard_app'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../visualize/embeddable'; import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters'; - -const { savedQueryService } = data.search.services; +import { RenderDeps } from './application'; + +export interface DashboardAppControllerDependencies extends RenderDeps { + $scope: DashboardAppScope; + $route: any; + $routeParams: any; + getAppState: any; + globalState: State; + indexPatterns: { + getDefault: () => Promise; + }; + dashboardConfig: any; + kbnUrl: KbnUrl; + AppStateClass: TAppStateClass; + config: any; + confirmModal: ConfirmModalFn; +} export class DashboardAppController { // Part of the exposed plugin API - do not remove without careful consideration. @@ -101,58 +97,55 @@ export class DashboardAppController { $route, $routeParams, getAppState, + globalState, dashboardConfig, localStorage, - Private, kbnUrl, AppStateClass, indexPatterns, config, confirmModal, - }: { - $scope: DashboardAppScope; - $route: any; - $routeParams: any; - getAppState: { - previouslyStored: () => TAppState | undefined; - }; - indexPatterns: { - getDefault: () => Promise; - }; - dashboardConfig: any; - localStorage: { - get: (prop: string) => unknown; - }; - Private: IPrivate; - kbnUrl: KbnUrl; - AppStateClass: TAppStateClass; - config: any; - confirmModal: ConfirmModalFn; - }) { - const queryFilter = Private(FilterBarQueryFilterProvider); - const getUnhashableStates = Private(getUnhashableStatesProvider); + savedQueryService, + embeddables, + share, + dashboardCapabilities, + npDataStart: { + query: { + filterManager, + timefilter: { timefilter }, + }, + }, + core: { notifications, overlays, chrome, injectedMetadata }, + }: DashboardAppControllerDependencies) { + new FilterStateManager(globalState, getAppState, filterManager); + const queryFilter = filterManager; + + function getUnhashableStates(): State[] { + return [getAppState(), globalState].filter(Boolean); + } let lastReloadRequestTime = 0; const dash = ($scope.dash = $route.current.locals.dash); if (dash.id) { - docTitle.change(dash.title); + chrome.docTitle.change(dash.title); } const dashboardStateManager = new DashboardStateManager({ savedDashboard: dash, AppStateClass, hideWriteControls: dashboardConfig.getHideWriteControls(), + kibanaVersion: injectedMetadata.getKibanaVersion(), }); $scope.appState = dashboardStateManager.getAppState(); - // The 'previouslyStored' check is so we only update the time filter on dashboard open, not during + // The hash check is so we only update the time filter on dashboard open, not during // normal cross app navigation. - if (dashboardStateManager.getIsTimeSavedWithDashboard() && !getAppState.previouslyStored()) { + if (dashboardStateManager.getIsTimeSavedWithDashboard() && !globalState.$inheritedGlobalState) { dashboardStateManager.syncTimefilterWithDashboard(timefilter); } - $scope.showSaveQuery = capabilities.get().dashboard.saveQuery as boolean; + $scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean; const updateIndexPatterns = (container?: DashboardContainer) => { if (!container || isErrorEmbeddable(container)) { @@ -187,10 +180,7 @@ export class DashboardAppController { [key: string]: DashboardPanelState; } = {}; dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => { - embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState( - panel, - dashboardStateManager.getUseMargins() - ); + embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); }); let expandedPanelId; if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { @@ -239,7 +229,7 @@ export class DashboardAppController { let outputSubscription: Subscription | undefined; const dashboardDom = document.getElementById('dashboardViewport'); - const dashboardFactory = start.getEmbeddableFactory( + const dashboardFactory = embeddables.getEmbeddableFactory( DASHBOARD_CONTAINER_TYPE ) as DashboardContainerFactory; dashboardFactory @@ -334,7 +324,7 @@ export class DashboardAppController { // Push breadcrumbs to new header navigation const updateBreadcrumbs = () => { - chrome.breadcrumbs.set([ + chrome.setBreadcrumbs([ { text: i18n.translate('kbn.dashboard.dashboardAppBreadcrumbsTitle', { defaultMessage: 'Dashboard', @@ -495,7 +485,7 @@ export class DashboardAppController { }); $scope.$watch( - () => capabilities.get().dashboard.saveQuery, + () => dashboardCapabilities.saveQuery, newCapability => { $scope.showSaveQuery = newCapability as boolean; } @@ -595,7 +585,7 @@ export class DashboardAppController { return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions) .then(function(id) { if (id) { - toastNotifications.addSuccess({ + notifications.toasts.addSuccess({ title: i18n.translate('kbn.dashboard.dashboardWasSavedSuccessMessage', { defaultMessage: `Dashboard '{dashTitle}' was saved`, values: { dashTitle: dash.title }, @@ -606,14 +596,14 @@ export class DashboardAppController { if (dash.id !== $routeParams.id) { kbnUrl.change(createDashboardEditUrl(dash.id)); } else { - docTitle.change(dash.lastSavedTitle); + chrome.docTitle.change(dash.lastSavedTitle); updateViewMode(ViewMode.VIEW); } } return { id }; }) .catch(error => { - toastNotifications.addDanger({ + notifications.toasts.addDanger({ title: i18n.translate('kbn.dashboard.dashboardWasNotSavedDangerMessage', { defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, values: { @@ -734,10 +724,10 @@ export class DashboardAppController { if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { openAddPanelFlyout({ embeddable: dashboardContainer, - getAllFactories: start.getEmbeddableFactories, - getFactory: start.getEmbeddableFactory, - notifications: npStart.core.notifications, - overlays: npStart.core.overlays, + getAllFactories: embeddables.getEmbeddableFactories, + getFactory: embeddables.getEmbeddableFactory, + notifications, + overlays, SavedObjectFinder, }); } @@ -757,7 +747,7 @@ export class DashboardAppController { }); }; navActions[TopNavIds.SHARE] = anchorElement => { - npStart.plugins.share.toggleShareContextMenu({ + share.toggleShareContextMenu({ anchorElement, allowEmbed: true, allowShortUrl: !dashboardConfig.getHideWriteControls(), @@ -784,8 +774,15 @@ export class DashboardAppController { }, }); + const visibleSubscription = chrome.getIsVisible$().subscribe(isVisible => { + $scope.$evalAsync(() => { + $scope.isVisible = isVisible; + }); + }); + $scope.$on('$destroy', () => { updateSubscription.unsubscribe(); + visibleSubscription.unsubscribe(); $scope.timefilterSubscriptions$.unsubscribe(); dashboardStateManager.destroy(); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts index 5e81373001bf5..d5d776944ad7a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts @@ -21,11 +21,10 @@ import './np_core.test.mocks'; import { DashboardStateManager } from './dashboard_state_manager'; import { getAppStateMock, getSavedDashboardMock } from './__tests__'; -import { AppStateClass } from 'ui/state_management/app_state'; +import { AppStateClass } from './legacy_imports'; import { DashboardAppState } from './types'; -import { TimeRange, TimefilterContract } from 'src/plugins/data/public'; +import { TimeRange, TimefilterContract, InputTimeRange } from 'src/plugins/data/public'; import { ViewMode } from 'src/plugins/embeddable/public'; -import { InputTimeRange } from 'ui/timefilter'; jest.mock('ui/registry/field_formats', () => ({ fieldFormats: { @@ -33,6 +32,10 @@ jest.mock('ui/registry/field_formats', () => ({ }, })); +jest.mock('ui/state_management/state', () => ({ + State: {}, +})); + describe('DashboardState', function() { let dashboardState: DashboardStateManager; const savedDashboard = getSavedDashboardMock(); @@ -52,6 +55,7 @@ describe('DashboardState', function() { savedDashboard, AppStateClass: getAppStateMock() as AppStateClass, hideWriteControls: false, + kibanaVersion: '7.0.0', }); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts index d5af4c93d0e0c..ac8628ec2a9d9 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state_manager.ts @@ -20,15 +20,21 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { stateMonitorFactory, StateMonitor } from 'ui/state_management/state_monitor_factory'; -import { Timefilter } from 'ui/timefilter'; -import { AppStateClass as TAppStateClass } from 'ui/state_management/app_state'; -import { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query'; import { Moment } from 'moment'; import { DashboardContainer } from 'src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; -import { Query, esFilters } from '../../../../../../src/plugins/data/public'; +import { + stateMonitorFactory, + StateMonitor, + AppStateClass as TAppStateClass, + migrateLegacyQuery, +} from './legacy_imports'; +import { + Query, + esFilters, + TimefilterContract as Timefilter, +} from '../../../../../../src/plugins/data/public'; import { getAppStateDefaults, migrateAppState } from './lib'; import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters'; @@ -54,6 +60,7 @@ export class DashboardStateManager { }; private stateDefaults: DashboardAppStateDefaults; private hideWriteControls: boolean; + private kibanaVersion: string; public isDirty: boolean; private changeListeners: Array<(status: { dirty: boolean }) => void>; private stateMonitor: StateMonitor; @@ -68,11 +75,14 @@ export class DashboardStateManager { savedDashboard, AppStateClass, hideWriteControls, + kibanaVersion, }: { savedDashboard: SavedObjectDashboard; AppStateClass: TAppStateClass; hideWriteControls: boolean; + kibanaVersion: string; }) { + this.kibanaVersion = kibanaVersion; this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; @@ -84,7 +94,7 @@ export class DashboardStateManager { // appState based on the URL (the url trumps the defaults). This means if we update the state format at all and // want to handle BWC, we must not only migrate the data stored with saved Dashboard, but also any old state in the // url. - migrateAppState(this.appState); + migrateAppState(this.appState, kibanaVersion); this.isDirty = false; @@ -146,7 +156,8 @@ export class DashboardStateManager { } convertedPanelStateMap[panelState.explicitInput.id] = convertPanelStateToSavedDashboardPanel( - panelState + panelState, + this.kibanaVersion ); if ( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/global_state_sync.ts b/src/legacy/core_plugins/kibana/public/dashboard/global_state_sync.ts new file mode 100644 index 0000000000000..8a733f940734b --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/global_state_sync.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { State } from './legacy_imports'; +import { DataPublicPluginStart as NpDataStart } from '../../../../../plugins/data/public'; + +/** + * Helper function to sync the global state with the various state providers + * when a local angular application mounts. There are three different ways + * global state can be passed into the application: + * * parameter in the URL hash - e.g. shared link + * * in-memory state in the data plugin exports (timefilter and filterManager) - e.g. default values + * + * This function looks up the three sources (earlier in the list means it takes precedence), + * puts it into the globalState object and syncs it with the url. + * + * Currently the legacy chrome takes care of restoring the global state when navigating from + * one app to another - to migrate away from that it will become necessary to also write the current + * state to local storage + */ +export function syncOnMount( + globalState: State, + { + query: { + filterManager, + timefilter: { timefilter }, + }, + }: NpDataStart +) { + // pull in global state information from the URL + globalState.fetch(); + // remember whether there were info in the URL + const hasGlobalURLState = Boolean(Object.keys(globalState.toObject()).length); + + // sync kibana platform state with the angular global state + if (!globalState.time) { + globalState.time = timefilter.getTime(); + } + if (!globalState.refreshInterval) { + globalState.refreshInterval = timefilter.getRefreshInterval(); + } + if (!globalState.filters && filterManager.getGlobalFilters().length > 0) { + globalState.filters = filterManager.getGlobalFilters(); + } + // only inject cross app global state if there is none in the url itself (that takes precedence) + if (hasGlobalURLState) { + // set flag the global state is set from the URL + globalState.$inheritedGlobalState = true; + } + globalState.save(); +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu.js b/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu.js index 56b2bd253381c..1b1a7f84c8131 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu.js @@ -17,26 +17,30 @@ * under the License. */ -import React, { Fragment, PureComponent } from 'react'; +import React, { PureComponent } from 'react'; import { EuiButton, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; export class HelpMenu extends PureComponent { render() { return ( - - - - - - - + + <> + + + + + + + ); } } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu_util.js b/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu_util.js index aeabff2d97007..2dc8ce523a7da 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu_util.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/help_menu/help_menu_util.js @@ -21,9 +21,9 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HelpMenu } from './help_menu'; -export function addHelpMenuToAppChrome(chrome) { - chrome.helpExtension.set(domElement => { - render(, domElement); +export function addHelpMenuToAppChrome(chrome, docLinks) { + chrome.setHelpExtension(domElement => { + render(, domElement); return () => { unmountComponentAtNode(domElement); }; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.js b/src/legacy/core_plugins/kibana/public/dashboard/index.js deleted file mode 100644 index 712e05c92e5e8..0000000000000 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.js +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import './dashboard_app'; -import { i18n } from '@kbn/i18n'; -import './saved_dashboard/saved_dashboards'; -import './dashboard_config'; -import uiRoutes from 'ui/routes'; -import chrome from 'ui/chrome'; -import { wrapInI18nContext } from 'ui/i18n'; -import { toastNotifications } from 'ui/notify'; - -import dashboardTemplate from './dashboard_app.html'; -import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; - -import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; -import { InvalidJSONProperty, SavedObjectNotFound } from '../../../../../plugins/kibana_utils/public'; -import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; -import { SavedObjectsClientProvider } from 'ui/saved_objects'; -import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; -import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; -import { uiModules } from 'ui/modules'; -import 'ui/capabilities/route_setup'; -import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; - -import { npStart } from 'ui/new_platform'; - -// load directives -import '../../../data/public'; - -const app = uiModules.get('app/dashboard', [ - 'ngRoute', - 'react', -]); - -app.directive('dashboardListing', function (reactDirective) { - return reactDirective(wrapInI18nContext(DashboardListing)); -}); - -function createNewDashboardCtrl($scope) { - $scope.visitVisualizeAppLinkText = i18n.translate('kbn.dashboard.visitVisualizeAppLinkText', { - defaultMessage: 'visit the Visualize app', - }); - addHelpMenuToAppChrome(chrome); -} - -uiRoutes - .defaults(/dashboard/, { - requireDefaultIndex: true, - requireUICapability: 'dashboard.show', - badge: uiCapabilities => { - if (uiCapabilities.dashboard.showWriteControls) { - return undefined; - } - - return { - text: i18n.translate('kbn.dashboard.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('kbn.dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', - }), - iconType: 'glasses' - }; - } - }) - .when(DashboardConstants.LANDING_PAGE_PATH, { - template: dashboardListingTemplate, - controller($injector, $location, $scope, Private, config) { - const services = Private(SavedObjectRegistryProvider).byLoaderPropertiesName; - const kbnUrl = $injector.get('kbnUrl'); - const dashboardConfig = $injector.get('dashboardConfig'); - - $scope.listingLimit = config.get('savedObjects:listingLimit'); - $scope.create = () => { - kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - }; - $scope.find = (search) => { - return services.dashboards.find(search, $scope.listingLimit); - }; - $scope.editItem = ({ id }) => { - kbnUrl.redirect(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); - }; - $scope.getViewUrl = ({ id }) => { - return chrome.addBasePath(`#${createDashboardEditUrl(id)}`); - }; - $scope.delete = (dashboards) => { - return services.dashboards.delete(dashboards.map(d => d.id)); - }; - $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); - $scope.initialFilter = ($location.search()).filter || EMPTY_FILTER; - chrome.breadcrumbs.set([{ - text: i18n.translate('kbn.dashboard.dashboardBreadcrumbsTitle', { - defaultMessage: 'Dashboards', - }), - }]); - addHelpMenuToAppChrome(chrome); - }, - resolve: { - dash: function ($route, Private, redirectWhenMissing, kbnUrl) { - const savedObjectsClient = Private(SavedObjectsClientProvider); - const title = $route.current.params.title; - if (title) { - return savedObjectsClient.find({ - search: `"${title}"`, - search_fields: 'title', - type: 'dashboard', - }).then(results => { - // The search isn't an exact match, lets see if we can find a single exact match to use - const matchingDashboards = results.savedObjects.filter( - dashboard => dashboard.attributes.title.toLowerCase() === title.toLowerCase()); - if (matchingDashboards.length === 1) { - kbnUrl.redirect(createDashboardEditUrl(matchingDashboards[0].id)); - } else { - kbnUrl.redirect(`${DashboardConstants.LANDING_PAGE_PATH}?filter="${title}"`); - } - throw uiRoutes.WAIT_FOR_URL_CHANGE_TOKEN; - }).catch(redirectWhenMissing({ - 'dashboard': DashboardConstants.LANDING_PAGE_PATH - })); - } - } - } - }) - .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { - template: dashboardTemplate, - controller: createNewDashboardCtrl, - requireUICapability: 'dashboard.createNew', - resolve: { - dash: function (savedDashboards, redirectWhenMissing) { - return savedDashboards.get() - .catch(redirectWhenMissing({ - 'dashboard': DashboardConstants.LANDING_PAGE_PATH - })); - } - } - }) - .when(createDashboardEditUrl(':id'), { - template: dashboardTemplate, - controller: createNewDashboardCtrl, - resolve: { - dash: function (savedDashboards, $route, redirectWhenMissing, kbnUrl, AppState) { - const id = $route.current.params.id; - - return savedDashboards.get(id) - .then((savedDashboard) => { - npStart.core.chrome.recentlyAccessed.add(savedDashboard.getFullPath(), savedDashboard.title, id); - return savedDashboard; - }) - .catch((error) => { - // A corrupt dashboard was detected (e.g. with invalid JSON properties) - if (error instanceof InvalidJSONProperty) { - toastNotifications.addDanger(error.message); - kbnUrl.redirect(DashboardConstants.LANDING_PAGE_PATH); - return; - } - - // Preserve BWC of v5.3.0 links for new, unsaved dashboards. - // See https://github.com/elastic/kibana/issues/10951 for more context. - if (error instanceof SavedObjectNotFound && id === 'create') { - // Note "new AppState" is necessary so the state in the url is preserved through the redirect. - kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState()); - toastNotifications.addWarning(i18n.translate('kbn.dashboard.urlWasRemovedInSixZeroWarningMessage', - { defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.' } - )); - } else { - throw error; - } - }) - .catch(redirectWhenMissing({ - 'dashboard': DashboardConstants.LANDING_PAGE_PATH - })); - } - } - }); - -FeatureCatalogueRegistryProvider.register(() => { - return { - id: 'dashboard', - title: i18n.translate('kbn.dashboard.featureCatalogue.dashboardTitle', { - defaultMessage: 'Dashboard', - }), - description: i18n.translate('kbn.dashboard.featureCatalogue.dashboardDescription', { - defaultMessage: 'Display and share a collection of visualizations and saved searches.', - }), - icon: 'dashboardApp', - path: `/app/kibana#${DashboardConstants.LANDING_PAGE_PATH}`, - showOnHomePage: true, - category: FeatureCatalogueCategory.DATA - }; -}); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.ts b/src/legacy/core_plugins/kibana/public/dashboard/index.ts new file mode 100644 index 0000000000000..d134739aa24c2 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + npSetup, + npStart, + SavedObjectRegistryProvider, + legacyChrome, + IPrivate, +} from './legacy_imports'; +import { DashboardPlugin, LegacyAngularInjectedDependencies } from './plugin'; +import { start as data } from '../../../data/public/legacy'; +import { localApplicationService } from '../local_application_service'; +import { start as embeddables } from '../../../embeddable_api/public/np_ready/public/legacy'; +import { start as navigation } from '../../../navigation/public/legacy'; +import './saved_dashboard/saved_dashboards'; +import './dashboard_config'; + +/** + * Get dependencies relying on the global angular context. + * They also have to get resolved together with the legacy imports above + */ +async function getAngularDependencies(): Promise { + const injector = await legacyChrome.dangerouslyGetActiveInjector(); + + const Private = injector.get('Private'); + + const savedObjectRegistry = Private(SavedObjectRegistryProvider); + + return { + dashboardConfig: injector.get('dashboardConfig'), + savedObjectRegistry, + savedDashboards: injector.get('savedDashboards'), + }; +} + +(async () => { + const instance = new DashboardPlugin(); + instance.setup(npSetup.core, { + ...npSetup.plugins, + __LEGACY: { + localApplicationService, + getAngularDependencies, + }, + }); + instance.start(npStart.core, { + ...npStart.plugins, + data, + npData: npStart.plugins.data, + embeddables, + navigation, + }); +})(); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_app.js b/src/legacy/core_plugins/kibana/public/dashboard/legacy_app.js new file mode 100644 index 0000000000000..c7f2adb4b875b --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_app.js @@ -0,0 +1,224 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; + +import dashboardTemplate from './dashboard_app.html'; +import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; + +import { ensureDefaultIndexPattern } from './legacy_imports'; +import { initDashboardAppDirective } from './dashboard_app'; +import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; +import { + InvalidJSONProperty, + SavedObjectNotFound, +} from '../../../../../plugins/kibana_utils/public'; +import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; +import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; +import { registerTimefilterWithGlobalStateFactory } from '../../../../ui/public/timefilter/setup_router'; +import { syncOnMount } from './global_state_sync'; + +export function initDashboardApp(app, deps) { + initDashboardAppDirective(app, deps); + + app.directive('dashboardListing', function (reactDirective) { + return reactDirective(DashboardListing); + }); + + function createNewDashboardCtrl($scope) { + $scope.visitVisualizeAppLinkText = i18n.translate('kbn.dashboard.visitVisualizeAppLinkText', { + defaultMessage: 'visit the Visualize app', + }); + addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); + } + + app.run(globalState => { + syncOnMount(globalState, deps.npDataStart); + }); + + app.run((globalState, $rootScope) => { + registerTimefilterWithGlobalStateFactory( + deps.npDataStart.query.timefilter.timefilter, + globalState, + $rootScope + ); + }); + + app.config(function ($routeProvider) { + const defaults = { + reloadOnSearch: false, + requireUICapability: 'dashboard.show', + badge: () => { + if (deps.dashboardCapabilities.showWriteControls) { + return undefined; + } + + return { + text: i18n.translate('kbn.dashboard.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('kbn.dashboard.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save dashboards', + }), + iconType: 'glasses', + }; + }, + }; + + $routeProvider + .when(DashboardConstants.LANDING_PAGE_PATH, { + ...defaults, + template: dashboardListingTemplate, + controller($injector, $location, $scope) { + const services = deps.savedObjectRegistry.byLoaderPropertiesName; + const kbnUrl = $injector.get('kbnUrl'); + const dashboardConfig = deps.dashboardConfig; + + $scope.listingLimit = deps.uiSettings.get('savedObjects:listingLimit'); + $scope.create = () => { + kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL); + }; + $scope.find = search => { + return services.dashboards.find(search, $scope.listingLimit); + }; + $scope.editItem = ({ id }) => { + kbnUrl.redirect(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); + }; + $scope.getViewUrl = ({ id }) => { + return deps.addBasePath(`#${createDashboardEditUrl(id)}`); + }; + $scope.delete = dashboards => { + return services.dashboards.delete(dashboards.map(d => d.id)); + }; + $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); + $scope.initialFilter = $location.search().filter || EMPTY_FILTER; + deps.chrome.setBreadcrumbs([ + { + text: i18n.translate('kbn.dashboard.dashboardBreadcrumbsTitle', { + defaultMessage: 'Dashboards', + }), + }, + ]); + addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); + }, + resolve: { + dash: function ($rootScope, $route, redirectWhenMissing, kbnUrl) { + return ensureDefaultIndexPattern(deps.core, deps.dataStart, $rootScope, kbnUrl).then(() => { + const savedObjectsClient = deps.savedObjectsClient; + const title = $route.current.params.title; + if (title) { + return savedObjectsClient + .find({ + search: `"${title}"`, + search_fields: 'title', + type: 'dashboard', + }) + .then(results => { + // The search isn't an exact match, lets see if we can find a single exact match to use + const matchingDashboards = results.savedObjects.filter( + dashboard => dashboard.attributes.title.toLowerCase() === title.toLowerCase() + ); + if (matchingDashboards.length === 1) { + kbnUrl.redirect(createDashboardEditUrl(matchingDashboards[0].id)); + } else { + kbnUrl.redirect(`${DashboardConstants.LANDING_PAGE_PATH}?filter="${title}"`); + } + $rootScope.$digest(); + return new Promise(() => {}); + }); + } + }); + }, + }, + }) + .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { + ...defaults, + template: dashboardTemplate, + controller: createNewDashboardCtrl, + requireUICapability: 'dashboard.createNew', + resolve: { + dash: function (redirectWhenMissing, $rootScope, kbnUrl) { + return ensureDefaultIndexPattern(deps.core, deps.dataStart, $rootScope, kbnUrl) + .then(() => { + return deps.savedDashboards.get(); + }) + .catch( + redirectWhenMissing({ + dashboard: DashboardConstants.LANDING_PAGE_PATH, + }) + ); + }, + }, + }) + .when(createDashboardEditUrl(':id'), { + ...defaults, + template: dashboardTemplate, + controller: createNewDashboardCtrl, + resolve: { + dash: function ($rootScope, $route, redirectWhenMissing, kbnUrl, AppState) { + const id = $route.current.params.id; + + return ensureDefaultIndexPattern(deps.core, deps.dataStart, $rootScope, kbnUrl) + .then(() => { + return deps.savedDashboards.get(id); + }) + .then(savedDashboard => { + deps.chrome.recentlyAccessed.add( + savedDashboard.getFullPath(), + savedDashboard.title, + id + ); + return savedDashboard; + }) + .catch(error => { + // A corrupt dashboard was detected (e.g. with invalid JSON properties) + if (error instanceof InvalidJSONProperty) { + deps.toastNotifications.addDanger(error.message); + kbnUrl.redirect(DashboardConstants.LANDING_PAGE_PATH); + return; + } + + // Preserve BWC of v5.3.0 links for new, unsaved dashboards. + // See https://github.com/elastic/kibana/issues/10951 for more context. + if (error instanceof SavedObjectNotFound && id === 'create') { + // Note "new AppState" is necessary so the state in the url is preserved through the redirect. + kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState()); + deps.toastNotifications.addWarning( + i18n.translate('kbn.dashboard.urlWasRemovedInSixZeroWarningMessage', { + defaultMessage: + 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', + }) + ); + return new Promise(() => {}); + } else { + throw error; + } + }) + .catch( + redirectWhenMissing({ + dashboard: DashboardConstants.LANDING_PAGE_PATH, + }) + ); + }, + }, + }) + .when(`dashboard/:tail*?`, { redirectTo: `/${deps.core.injectedMetadata.getInjectedVar('kbnDefaultAppId')}` }) + .when(`dashboards/:tail*?`, { redirectTo: `/${deps.core.injectedMetadata.getInjectedVar('kbnDefaultAppId')}` }); + }); +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts new file mode 100644 index 0000000000000..7c3c389330887 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The imports in this file are static functions and types which still live in legacy folders and are used + * within dashboard. To consolidate them all in one place, they are re-exported from this file. Eventually + * this list should become empty. Imports from the top level of shimmed or moved plugins can be imported + * directly where they are needed. + */ + +import chrome from 'ui/chrome'; + +export const legacyChrome = chrome; +export { State } from 'ui/state_management/state'; +export { AppState } from 'ui/state_management/app_state'; +export { AppStateClass } from 'ui/state_management/app_state'; +export { SaveOptions } from 'ui/saved_objects/saved_object'; +export { npSetup, npStart } from 'ui/new_platform'; +export { SavedObjectRegistryProvider } from 'ui/saved_objects'; +export { IPrivate } from 'ui/private'; +export { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal'; +export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; +// @ts-ignore +export { ConfirmationButtonTypes } from 'ui/modals/confirm_modal'; +export { showSaveModal, SaveResult } from 'ui/saved_objects/show_saved_object_save_modal'; +export { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query'; +export { KbnUrl } from 'ui/url/kbn_url'; +// @ts-ignore +export { GlobalStateProvider } from 'ui/state_management/global_state'; +// @ts-ignore +export { StateManagementConfigProvider } from 'ui/state_management/config_provider'; +// @ts-ignore +export { AppStateProvider } from 'ui/state_management/app_state'; +// @ts-ignore +export { PrivateProvider } from 'ui/private/private'; +// @ts-ignore +export { EventsProvider } from 'ui/events'; +export { PersistedState } from 'ui/persisted_state'; +// @ts-ignore +export { createTopNavDirective, createTopNavHelper } from 'ui/kbn_top_nav/kbn_top_nav'; +// @ts-ignore +export { PromiseServiceCreator } from 'ui/promises/promises'; +// @ts-ignore +export { KbnUrlProvider, RedirectWhenMissingProvider } from 'ui/url'; +// @ts-ignore +export { confirmModalFactory } from 'ui/modals/confirm_modal'; +export { configureAppAngularModule } from 'ui/legacy_compat'; +export { stateMonitorFactory, StateMonitor } from 'ui/state_management/state_monitor_factory'; +export { ensureDefaultIndexPattern } from 'ui/legacy_compat'; +export { unhashUrl } from 'ui/state_management/state_hashing'; +export { IInjector } from 'ui/chrome'; +export { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.test.ts index 99bb6b115b985..3f04cad4f322b 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.test.ts @@ -48,7 +48,7 @@ test('convertSavedDashboardPanelToPanelState', () => { version: '7.0.0', }; - expect(convertSavedDashboardPanelToPanelState(savedDashboardPanel, true)).toEqual({ + expect(convertSavedDashboardPanelToPanelState(savedDashboardPanel)).toEqual({ gridData: { x: 0, y: 0, @@ -82,7 +82,7 @@ test('convertSavedDashboardPanelToPanelState does not include undefined id', () version: '7.0.0', }; - const converted = convertSavedDashboardPanelToPanelState(savedDashboardPanel, false); + const converted = convertSavedDashboardPanelToPanelState(savedDashboardPanel); expect(converted.hasOwnProperty('savedObjectId')).toBe(false); }); @@ -103,7 +103,7 @@ test('convertPanelStateToSavedDashboardPanel', () => { type: 'search', }; - expect(convertPanelStateToSavedDashboardPanel(dashboardPanel)).toEqual({ + expect(convertPanelStateToSavedDashboardPanel(dashboardPanel, '6.3.0')).toEqual({ type: 'search', embeddableConfig: { something: 'hi!', @@ -137,6 +137,6 @@ test('convertPanelStateToSavedDashboardPanel will not add an undefined id when n type: 'search', }; - const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel); + const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, '8.0.0'); expect(converted.hasOwnProperty('id')).toBe(false); }); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.ts index 4a3bc3b228106..2d42609e1e25f 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/embeddable_saved_object_converters.ts @@ -18,12 +18,10 @@ */ import { omit } from 'lodash'; import { DashboardPanelState } from 'src/legacy/core_plugins/dashboard_embeddable_container/public/np_ready/public'; -import chrome from 'ui/chrome'; import { SavedDashboardPanel } from '../types'; export function convertSavedDashboardPanelToPanelState( - savedDashboardPanel: SavedDashboardPanel, - useMargins: boolean + savedDashboardPanel: SavedDashboardPanel ): DashboardPanelState { return { type: savedDashboardPanel.type, @@ -38,13 +36,14 @@ export function convertSavedDashboardPanelToPanelState( } export function convertPanelStateToSavedDashboardPanel( - panelState: DashboardPanelState + panelState: DashboardPanelState, + version: string ): SavedDashboardPanel { const customTitle: string | undefined = panelState.explicitInput.title ? (panelState.explicitInput.title as string) : undefined; return { - version: chrome.getKibanaVersion(), + version, type: panelState.type, gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.test.ts index 10c27226300a5..4aa2461bb6593 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.test.ts @@ -43,7 +43,7 @@ test('migrate app state from 6.0', async () => { getQueryParamName: () => 'a', save: mockSave, }; - migrateAppState(appState); + migrateAppState(appState, '8.0'); expect(appState.uiState).toBeUndefined(); const newPanel = (appState.panels[0] as unknown) as SavedDashboardPanel; @@ -58,6 +58,7 @@ test('migrate app state from 6.0', async () => { }); test('migrate sort from 6.1', async () => { + const TARGET_VERSION = '8.0'; const mockSave = jest.fn(); const appState = { uiState: { @@ -80,7 +81,7 @@ test('migrate sort from 6.1', async () => { save: mockSave, useMargins: false, }; - migrateAppState(appState); + migrateAppState(appState, TARGET_VERSION); expect(appState.uiState).toBeUndefined(); const newPanel = (appState.panels[0] as unknown) as SavedDashboardPanel; @@ -112,7 +113,7 @@ test('migrates 6.0 even when uiState does not exist', async () => { getQueryParamName: () => 'a', save: mockSave, }; - migrateAppState(appState); + migrateAppState(appState, '8.0'); expect((appState as any).uiState).toBeUndefined(); const newPanel = (appState.panels[0] as unknown) as SavedDashboardPanel; @@ -147,7 +148,7 @@ test('6.2 migration adjusts w & h without margins', async () => { save: mockSave, useMargins: false, }; - migrateAppState(appState); + migrateAppState(appState, '8.0'); expect((appState as any).uiState).toBeUndefined(); const newPanel = (appState.panels[0] as unknown) as SavedDashboardPanel; @@ -184,7 +185,7 @@ test('6.2 migration adjusts w & h with margins', async () => { save: mockSave, useMargins: true, }; - migrateAppState(appState); + migrateAppState(appState, '8.0'); expect((appState as any).uiState).toBeUndefined(); const newPanel = (appState.panels[0] as unknown) as SavedDashboardPanel; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.ts index 9bd93029f06d8..c4ad754548459 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/migrate_app_state.ts @@ -18,7 +18,6 @@ */ import semver from 'semver'; -import chrome from 'ui/chrome'; import { i18n } from '@kbn/i18n'; import { createUiStatsReporter, METRIC_TYPE } from '../../../../ui_metric/public'; import { @@ -37,7 +36,10 @@ import { migratePanelsTo730 } from '../migrations/migrate_to_730_panels'; * * Once we hit a major version, we can remove support for older style URLs and get rid of this logic. */ -export function migrateAppState(appState: { [key: string]: unknown } | DashboardAppState) { +export function migrateAppState( + appState: { [key: string]: unknown } | DashboardAppState, + kibanaVersion: string +) { if (!appState.panels) { throw new Error( i18n.translate('kbn.dashboard.panel.invalidData', { @@ -73,7 +75,7 @@ export function migrateAppState(appState: { [key: string]: unknown } | Dashboard | SavedDashboardPanel630 | SavedDashboardPanel640To720 >, - chrome.getKibanaVersion(), + kibanaVersion, appState.useMargins, appState.uiState ); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/save_dashboard.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/save_dashboard.ts index 168f320b5ea7e..e0d82373d3ad9 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/save_dashboard.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/save_dashboard.ts @@ -17,8 +17,8 @@ * under the License. */ -import { SaveOptions } from 'ui/saved_objects/saved_object'; -import { Timefilter } from 'ui/timefilter'; +import { TimefilterContract } from 'src/plugins/data/public'; +import { SaveOptions } from '../legacy_imports'; import { updateSavedDashboard } from './update_saved_dashboard'; import { DashboardStateManager } from '../dashboard_state_manager'; @@ -32,7 +32,7 @@ import { DashboardStateManager } from '../dashboard_state_manager'; */ export function saveDashboard( toJson: (obj: any) => string, - timeFilter: Timefilter, + timeFilter: TimefilterContract, dashboardStateManager: DashboardStateManager, saveOptions: SaveOptions ): Promise { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.ts index 707b5a0f5f5f5..ce9096b3a56f0 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.ts @@ -18,16 +18,15 @@ */ import _ from 'lodash'; -import { AppState } from 'ui/state_management/app_state'; -import { Timefilter } from 'ui/timefilter'; -import { RefreshInterval } from 'src/plugins/data/public'; +import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; +import { AppState } from '../legacy_imports'; import { FilterUtils } from './filter_utils'; import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; export function updateSavedDashboard( savedDashboard: SavedObjectDashboard, appState: AppState, - timeFilter: Timefilter, + timeFilter: TimefilterContract, toJson: (object: T) => string ) { savedDashboard.title = appState.title; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap index 1ed05035f5f4c..b2f004568841a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap @@ -1,533 +1,545 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`after fetch hideWriteControls 1`] = ` - - - - - } - /> -
- } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, + + + + + + } + /> +
+ } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; exports[`after fetch initialFilter 1`] = ` - - - - - } - body={ - -

+ + + -

-

- - - , + + } + body={ + +

+ +

+

+ + + , + } } - } + /> +

+
+ } + iconType="dashboardApp" + title={ +

+ -

- - } - iconType="dashboardApp" - title={ -

- -

- } - /> - - } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, +

+ } + /> + + } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; exports[`after fetch renders call to action when no dashboards exist 1`] = ` - - - - - } - body={ - -

+ + + -

-

- - - , + + } + body={ + +

+ +

+

+ + + , + } } - } + /> +

+
+ } + iconType="dashboardApp" + title={ +

+ -

- - } - iconType="dashboardApp" - title={ -

- -

- } - /> - - } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, +

+ } + /> + + } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; exports[`after fetch renders table rows 1`] = ` - - - - - } - body={ - -

+ + + -

-

- - - , + + } + body={ + +

+ +

+

+ + + , + } } - } + /> +

+
+ } + iconType="dashboardApp" + title={ +

+ -

- - } - iconType="dashboardApp" - title={ -

- -

- } - /> - - } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, +

+ } + /> + + } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` - - - - - } - body={ - -

+ + + -

-

- - - , + + } + body={ + +

+ +

+

+ + + , + } } - } + /> +

+
+ } + iconType="dashboardApp" + title={ +

+ -

- - } - iconType="dashboardApp" - title={ -

- -

- } - /> - - } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, +

+ } + /> + + } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` - - - - - } - body={ - -

+ + + -

-

- - - , + + } + body={ + +

+ +

+

+ + + , + } } - } + /> +

+
+ } + iconType="dashboardApp" + title={ +

+ -

- - } - iconType="dashboardApp" - title={ -

- -

- } - /> - - } - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, +

+ } + /> + + } + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "dataType": "string", + "field": "description", + "name": "Description", + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={Object {}} + uiSettings={ Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], + "get": [MockFunction], + } } - } -/> + /> + `; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js index c222fcd3c928c..98581223afa46 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js @@ -19,7 +19,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; @@ -41,27 +41,29 @@ export class DashboardListing extends React.Component { render() { return ( - + + + ); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts new file mode 100644 index 0000000000000..780fa6571e4e7 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -0,0 +1,151 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + App, + CoreSetup, + CoreStart, + LegacyCoreStart, + Plugin, + SavedObjectsClientContract, +} from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { RenderDeps } from './application'; +import { LocalApplicationService } from '../local_application_service'; +import { DataStart } from '../../../data/public'; +import { DataPublicPluginStart as NpDataStart } from '../../../../../plugins/data/public'; +import { EmbeddablePublicPlugin } from '../../../../../plugins/embeddable/public'; +import { Storage } from '../../../../../plugins/kibana_utils/public'; +import { NavigationStart } from '../../../navigation/public'; +import { DashboardConstants } from './dashboard_constants'; +import { + FeatureCatalogueCategory, + HomePublicPluginSetup, +} from '../../../../../plugins/home/public'; +import { SharePluginStart } from '../../../../../plugins/share/public'; + +export interface LegacyAngularInjectedDependencies { + dashboardConfig: any; + savedObjectRegistry: any; + savedDashboards: any; +} + +export interface DashboardPluginStartDependencies { + data: DataStart; + npData: NpDataStart; + embeddables: ReturnType; + navigation: NavigationStart; + share: SharePluginStart; +} + +export interface DashboardPluginSetupDependencies { + __LEGACY: { + getAngularDependencies: () => Promise; + localApplicationService: LocalApplicationService; + }; + home: HomePublicPluginSetup; +} + +export class DashboardPlugin implements Plugin { + private startDependencies: { + dataStart: DataStart; + npDataStart: NpDataStart; + savedObjectsClient: SavedObjectsClientContract; + embeddables: ReturnType; + navigation: NavigationStart; + share: SharePluginStart; + } | null = null; + + public setup( + core: CoreSetup, + { + __LEGACY: { localApplicationService, getAngularDependencies, ...legacyServices }, + home, + }: DashboardPluginSetupDependencies + ) { + const app: App = { + id: '', + title: 'Dashboards', + mount: async ({ core: contextCore }, params) => { + if (this.startDependencies === null) { + throw new Error('not started yet'); + } + const { + dataStart, + savedObjectsClient, + embeddables, + navigation, + share, + npDataStart, + } = this.startDependencies; + const angularDependencies = await getAngularDependencies(); + const deps: RenderDeps = { + core: contextCore as LegacyCoreStart, + ...legacyServices, + ...angularDependencies, + navigation, + dataStart, + share, + npDataStart, + indexPatterns: dataStart.indexPatterns.indexPatterns, + savedObjectsClient, + chrome: contextCore.chrome, + addBasePath: contextCore.http.basePath.prepend, + uiSettings: contextCore.uiSettings, + savedQueryService: dataStart.search.services.savedQueryService, + embeddables, + dashboardCapabilities: contextCore.application.capabilities.dashboard, + localStorage: new Storage(localStorage), + }; + const { renderApp } = await import('./application'); + return renderApp(params.element, params.appBasePath, deps); + }, + }; + localApplicationService.register({ ...app, id: 'dashboard' }); + localApplicationService.register({ ...app, id: 'dashboards' }); + + home.featureCatalogue.register({ + id: 'dashboard', + title: i18n.translate('kbn.dashboard.featureCatalogue.dashboardTitle', { + defaultMessage: 'Dashboard', + }), + description: i18n.translate('kbn.dashboard.featureCatalogue.dashboardDescription', { + defaultMessage: 'Display and share a collection of visualizations and saved searches.', + }), + icon: 'dashboardApp', + path: `/app/kibana#${DashboardConstants.LANDING_PAGE_PATH}`, + showOnHomePage: true, + category: FeatureCatalogueCategory.DATA, + }); + } + + start( + { savedObjects: { client: savedObjectsClient } }: CoreStart, + { data: dataStart, embeddables, navigation, npData, share }: DashboardPluginStartDependencies + ) { + this.startDependencies = { + dataStart, + npDataStart: npData, + savedObjectsClient, + embeddables, + navigation, + share, + }; + } +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.test.js b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.test.js index 153a049276cee..aa7e219d75963 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.test.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.test.js @@ -17,9 +17,16 @@ * under the License. */ + import React from 'react'; import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +jest.mock('../legacy_imports', () => ({ + SavedObjectSaveModal: () => null +})); + +jest.mock('ui/new_platform'); + import { DashboardSaveModal } from './save_modal'; test('renders DashboardSaveModal', () => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.tsx b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.tsx index 47455f04ba809..0640b2be431be 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/save_modal.tsx @@ -19,10 +19,10 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal'; import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui'; +import { SavedObjectSaveModal } from '../legacy_imports'; + interface SaveOptions { newTitle: string; newDescription: string; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.tsx b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.tsx index c3cd5621b2c88..af1020e01e0c5 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_clone_modal.tsx @@ -17,10 +17,10 @@ * under the License. */ -import { I18nContext } from 'ui/i18n'; import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; import { DashboardCloneModal } from './clone_modal'; export function showCloneModal( @@ -54,7 +54,7 @@ export function showCloneModal( }; document.body.appendChild(container); const element = ( - + - + ); ReactDOM.render(element, container); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.tsx b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.tsx index 8640d7dbc6bdc..7c23e4808fbea 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.tsx @@ -19,9 +19,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { I18nContext } from 'ui/i18n'; - +import { I18nProvider } from '@kbn/i18n/react'; import { EuiWrappingPopover } from '@elastic/eui'; + import { OptionsMenu } from './options'; let isOpen = false; @@ -55,7 +55,7 @@ export function showOptionsPopover({ document.body.appendChild(container); const element = ( - + - + ); ReactDOM.render(element, container); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/types.ts b/src/legacy/core_plugins/kibana/public/dashboard/types.ts index 3c2c87a502da4..371274401739e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/types.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/types.ts @@ -17,9 +17,8 @@ * under the License. */ -import { AppState } from 'ui/state_management/app_state'; -import { AppState as TAppState } from 'ui/state_management/app_state'; import { ViewMode } from 'src/plugins/embeddable/public'; +import { AppState } from './legacy_imports'; import { RawSavedDashboardPanelTo60, RawSavedDashboardPanel610, @@ -153,5 +152,5 @@ export type AddFilterFn = ( operator: string; index: string; }, - appState: TAppState + appState: AppState ) => void; diff --git a/src/legacy/core_plugins/kibana/public/discover/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/angular/discover.js index 20a05e17d16d6..ba74ea069c4ab 100644 --- a/src/legacy/core_plugins/kibana/public/discover/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/angular/discover.js @@ -57,9 +57,11 @@ import { vislibSeriesResponseHandlerProvider, Vis, SavedObjectSaveModal, + ensureDefaultIndexPattern, } from '../kibana_services'; const { + core, chrome, docTitle, FilterBarQueryFilterProvider, @@ -72,7 +74,6 @@ const { } = getServices(); import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../breadcrumbs'; -import { extractTimeFilter, changeTimeFilter } from '../../../../data/public'; import { start as data } from '../../../../data/public/legacy'; import { generateFilters } from '../../../../../../plugins/data/public'; @@ -91,7 +92,6 @@ const app = uiModules.get('apps/discover', [ uiRoutes .defaults(/^\/discover(\/|$)/, { - requireDefaultIndex: true, requireUICapability: 'discover.show', k7Breadcrumbs: ($route, $injector) => $injector.invoke( @@ -119,50 +119,53 @@ uiRoutes template: indexTemplate, reloadOnSearch: false, resolve: { - ip: function (Promise, indexPatterns, config, Private) { + savedObjects: function (Promise, indexPatterns, config, Private, $rootScope, kbnUrl, redirectWhenMissing, savedSearches, $route) { const State = Private(StateProvider); - return indexPatterns.getCache().then((savedObjects)=> { - /** - * In making the indexPattern modifiable it was placed in appState. Unfortunately, - * the load order of AppState conflicts with the load order of many other things - * so in order to get the name of the index we should use, and to switch to the - * default if necessary, we parse the appState with a temporary State object and - * then destroy it immediatly after we're done - * - * @type {State} - */ - const state = new State('_a', {}); - - const specified = !!state.index; - const exists = _.findIndex(savedObjects, o => o.id === state.index) > -1; - const id = exists ? state.index : config.get('defaultIndex'); - state.destroy(); + const savedSearchId = $route.current.params.id; + return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl).then(() => { return Promise.props({ - list: savedObjects, - loaded: indexPatterns.get(id), - stateVal: state.index, - stateValFound: specified && exists + ip: indexPatterns.getCache().then((savedObjects) => { + /** + * In making the indexPattern modifiable it was placed in appState. Unfortunately, + * the load order of AppState conflicts with the load order of many other things + * so in order to get the name of the index we should use, and to switch to the + * default if necessary, we parse the appState with a temporary State object and + * then destroy it immediatly after we're done + * + * @type {State} + */ + const state = new State('_a', {}); + + const specified = !!state.index; + const exists = _.findIndex(savedObjects, o => o.id === state.index) > -1; + const id = exists ? state.index : config.get('defaultIndex'); + state.destroy(); + + return Promise.props({ + list: savedObjects, + loaded: indexPatterns.get(id), + stateVal: state.index, + stateValFound: specified && exists + }); + }), + savedSearch: savedSearches.get(savedSearchId) + .then((savedSearch) => { + if (savedSearchId) { + chrome.recentlyAccessed.add( + savedSearch.getFullPath(), + savedSearch.title, + savedSearchId); + } + return savedSearch; + }) + .catch(redirectWhenMissing({ + 'search': '/discover', + 'index-pattern': '/management/kibana/objects/savedSearches/' + $route.current.params.id + })) }); }); }, - savedSearch: function (redirectWhenMissing, savedSearches, $route) { - const savedSearchId = $route.current.params.id; - return savedSearches.get(savedSearchId) - .then((savedSearch) => { - if (savedSearchId) { - chrome.recentlyAccessed.add( - savedSearch.getFullPath(), - savedSearch.title, - savedSearchId); - } - return savedSearch; - }) - .catch(redirectWhenMissing({ - 'search': '/discover', - 'index-pattern': '/management/kibana/objects/savedSearches/' + $route.current.params.id - })); - } } }); @@ -224,7 +227,7 @@ function discoverController( }; // the saved savedSearch - const savedSearch = $route.current.locals.savedSearch; + const savedSearch = $route.current.locals.savedObjects.savedSearch; let abortController; $scope.$on('$destroy', () => { @@ -417,20 +420,6 @@ function discoverController( queryFilter.setFilters(filters); }; - $scope.applyFilters = filters => { - const { timeRangeFilter, restOfFilters } = extractTimeFilter($scope.indexPattern.timeFieldName, filters); - queryFilter.addFilters(restOfFilters); - if (timeRangeFilter) changeTimeFilter(timefilter, timeRangeFilter); - - $scope.state.$newFilters = []; - }; - - $scope.$watch('state.$newFilters', (filters = []) => { - if (filters.length === 1) { - $scope.applyFilters(filters); - } - }); - const getFieldCounts = async () => { // the field counts aren't set until we have the data back, // so we wait for the fetch to be done before proceeding @@ -539,7 +528,7 @@ function discoverController( sampleSize: config.get('discover:sampleSize'), timefield: isDefaultTypeIndexPattern($scope.indexPattern) && $scope.indexPattern.timeFieldName, savedSearch: savedSearch, - indexPatternList: $route.current.locals.ip.list, + indexPatternList: $route.current.locals.savedObjects.ip.list, }; const shouldSearchOnPageLoad = () => { @@ -1055,7 +1044,7 @@ function discoverController( loaded: loadedIndexPattern, stateVal, stateValFound, - } = $route.current.locals.ip; + } = $route.current.locals.savedObjects.ip; const ownIndexPattern = $scope.searchSource.getOwnField('index'); @@ -1103,12 +1092,12 @@ function discoverController( // Block the UI from loading if the user has loaded a rollup index pattern but it isn't // supported. $scope.isUnsupportedIndexPattern = ( - !isDefaultTypeIndexPattern($route.current.locals.ip.loaded) - && !hasSearchStategyForIndexPattern($route.current.locals.ip.loaded) + !isDefaultTypeIndexPattern($route.current.locals.savedObjects.ip.loaded) + && !hasSearchStategyForIndexPattern($route.current.locals.savedObjects.ip.loaded) ); if ($scope.isUnsupportedIndexPattern) { - $scope.unsupportedIndexPatternType = $route.current.locals.ip.loaded.type; + $scope.unsupportedIndexPatternType = $route.current.locals.savedObjects.ip.loaded.type; return; } diff --git a/src/legacy/core_plugins/kibana/public/discover/breadcrumbs.ts b/src/legacy/core_plugins/kibana/public/discover/breadcrumbs.ts index 51e0dcba1cad0..6c3856932c96c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/breadcrumbs.ts +++ b/src/legacy/core_plugins/kibana/public/discover/breadcrumbs.ts @@ -34,7 +34,7 @@ export function getSavedSearchBreadcrumbs($route: any) { return [ ...getRootBreadcrumbs(), { - text: $route.current.locals.savedSearch.id, + text: $route.current.locals.savedObjects.savedSearch.id, }, ]; } diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index 61d7933464e7f..02b08d7fa4b61 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -46,6 +46,7 @@ import * as docViewsRegistry from 'ui/registry/doc_views'; const services = { // new plattform + core: npStart.core, addBasePath: npStart.core.http.basePath.prepend, capabilities: npStart.core.application.capabilities, chrome: npStart.core.chrome, @@ -108,6 +109,7 @@ export { getUnhashableStatesProvider } from 'ui/state_management/state_hashing'; export { tabifyAggResponse } from 'ui/agg_response/tabify'; // @ts-ignore export { vislibSeriesResponseHandlerProvider } from 'ui/vis/response_handlers/vislib'; +export { ensureDefaultIndexPattern } from 'ui/legacy_compat'; export { unhashUrl } from 'ui/state_management/state_hashing'; // EXPORT types diff --git a/src/legacy/core_plugins/kibana/public/home/index.ts b/src/legacy/core_plugins/kibana/public/home/index.ts index 4ebf719b86233..b1c03507c9a2d 100644 --- a/src/legacy/core_plugins/kibana/public/home/index.ts +++ b/src/legacy/core_plugins/kibana/public/home/index.ts @@ -64,11 +64,11 @@ let copiedLegacyCatalogue = false; const Private = injector.get('Private'); // Merge legacy registry with new registry (Private(FeatureCatalogueRegistryProvider as any) as any).inTitleOrder.map( - npSetup.plugins.feature_catalogue.register + npSetup.plugins.home.featureCatalogue.register ); copiedLegacyCatalogue = true; } - return npStart.plugins.feature_catalogue.get(); + return npStart.plugins.home.featureCatalogue.get(); }, getAngularDependencies, localApplicationService, diff --git a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts index 6189204ee4cfc..5ef6e019db042 100644 --- a/src/legacy/core_plugins/kibana/public/home/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/home/kibana_services.ts @@ -29,7 +29,7 @@ import { UiSettingsState, } from 'kibana/public'; import { UiStatsMetricType } from '@kbn/analytics'; -import { FeatureCatalogueEntry } from '../../../../../plugins/feature_catalogue/public'; +import { FeatureCatalogueEntry } from '../../../../../plugins/home/public'; export interface HomeKibanaServices { indexPatternService: any; diff --git a/src/legacy/core_plugins/kibana/public/home/plugin.ts b/src/legacy/core_plugins/kibana/public/home/plugin.ts index 2a2ea371d7f3b..18e101fc58d51 100644 --- a/src/legacy/core_plugins/kibana/public/home/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/home/plugin.ts @@ -23,7 +23,7 @@ import { UiStatsMetricType } from '@kbn/analytics'; import { DataStart } from '../../../data/public'; import { LocalApplicationService } from '../local_application_service'; import { setServices } from './kibana_services'; -import { FeatureCatalogueEntry } from '../../../../../plugins/feature_catalogue/public'; +import { FeatureCatalogueEntry } from '../../../../../plugins/home/public'; export interface LegacyAngularInjectedDependencies { telemetryOptInProvider: any; diff --git a/src/legacy/core_plugins/kibana/public/management/index.js b/src/legacy/core_plugins/kibana/public/management/index.js index c0949318e9253..83fc8e4db9b55 100644 --- a/src/legacy/core_plugins/kibana/public/management/index.js +++ b/src/legacy/core_plugins/kibana/public/management/index.js @@ -28,7 +28,6 @@ import { I18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; import appTemplate from './app.html'; import landingTemplate from './landing.html'; -import { capabilities } from 'ui/capabilities'; import { management, SidebarNav, MANAGEMENT_BREADCRUMB } from 'ui/management'; import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; import { timefilter } from 'ui/timefilter'; @@ -50,13 +49,6 @@ uiRoutes redirectTo: '/management' }); -require('./route_setup/load_default')({ - whenMissingRedirectTo: () => { - const canManageIndexPatterns = capabilities.get().management.kibana.index_patterns; - return canManageIndexPatterns ? '/management/kibana/index_pattern' : '/home'; - } -}); - export function updateLandingPage(version) { const node = document.getElementById(LANDING_ID); if (!node) { diff --git a/src/legacy/core_plugins/kibana/public/management/route_setup/load_default.js b/src/legacy/core_plugins/kibana/public/management/route_setup/load_default.js deleted file mode 100644 index f797acbe8888e..0000000000000 --- a/src/legacy/core_plugins/kibana/public/management/route_setup/load_default.js +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import React from 'react'; -import { banners } from 'ui/notify'; -import { NoDefaultIndexPattern } from 'ui/index_patterns'; -import uiRoutes from 'ui/routes'; -import { - EuiCallOut, -} from '@elastic/eui'; -import { clearTimeout } from 'timers'; -import { i18n } from '@kbn/i18n'; - -let bannerId; -let timeoutId; - -function displayBanner() { - clearTimeout(timeoutId); - - // Avoid being hostile to new users who don't have an index pattern setup yet - // give them a friendly info message instead of a terse error message - bannerId = banners.set({ - id: bannerId, // initially undefined, but reused after first set - component: ( - - ) - }); - - // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around - timeoutId = setTimeout(() => { - banners.remove(bannerId); - timeoutId = undefined; - }, 15000); -} - -// eslint-disable-next-line import/no-default-export -export default function (opts) { - opts = opts || {}; - const whenMissingRedirectTo = opts.whenMissingRedirectTo || null; - - uiRoutes - .addSetupWork(function loadDefaultIndexPattern(Promise, $route, config, indexPatterns) { - const route = _.get($route, 'current.$$route'); - - if (!route.requireDefaultIndex) { - return; - } - - return indexPatterns.getIds() - .then(function (patterns) { - let defaultId = config.get('defaultIndex'); - let defined = !!defaultId; - const exists = _.contains(patterns, defaultId); - - if (defined && !exists) { - config.remove('defaultIndex'); - defaultId = defined = false; - } - - if (!defined) { - // If there is any index pattern created, set the first as default - if (patterns.length >= 1) { - defaultId = patterns[0]; - config.set('defaultIndex', defaultId); - } else { - throw new NoDefaultIndexPattern(); - } - } - }); - }) - .afterWork( - // success - null, - - // failure - function (err, kbnUrl) { - const hasDefault = !(err instanceof NoDefaultIndexPattern); - if (hasDefault || !whenMissingRedirectTo) throw err; // rethrow - - kbnUrl.change(whenMissingRedirectTo()); - - displayBanner(); - } - ); -} diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js index 619903e93c127..821883655766b 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js @@ -24,6 +24,7 @@ import '../saved_visualizations/saved_visualizations'; import './visualization_editor'; import './visualization'; +import { ensureDefaultIndexPattern } from 'ui/legacy_compat'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { migrateAppState } from './lib'; @@ -49,6 +50,7 @@ import { } from '../kibana_services'; const { + core, capabilities, chrome, chromeLegacy, @@ -71,7 +73,7 @@ uiRoutes template: editorTemplate, k7Breadcrumbs: getCreateBreadcrumbs, resolve: { - savedVis: function (savedVisualizations, redirectWhenMissing, $route) { + savedVis: function (savedVisualizations, redirectWhenMissing, $route, $rootScope, kbnUrl) { const visTypes = visualizations.types.all(); const visType = _.find(visTypes, { name: $route.current.params.type }); const shouldHaveIndex = visType.requiresSearch && visType.options.showIndexSelection; @@ -84,7 +86,7 @@ uiRoutes ); } - return savedVisualizations.get($route.current.params) + return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl).then(() => savedVisualizations.get($route.current.params)) .then(savedVis => { if (savedVis.vis.type.setup) { return savedVis.vis.type.setup(savedVis) @@ -102,28 +104,33 @@ uiRoutes template: editorTemplate, k7Breadcrumbs: getEditBreadcrumbs, resolve: { - savedVis: function (savedVisualizations, redirectWhenMissing, $route) { - return savedVisualizations.get($route.current.params.id) + savedVis: function (savedVisualizations, redirectWhenMissing, $route, $rootScope, kbnUrl) { + return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl) + .then(() => savedVisualizations.get($route.current.params.id)) .then((savedVis) => { chrome.recentlyAccessed.add( savedVis.getFullPath(), savedVis.title, - savedVis.id); + savedVis.id + ); return savedVis; }) .then(savedVis => { if (savedVis.vis.type.setup) { - return savedVis.vis.type.setup(savedVis) - .catch(() => savedVis); + return savedVis.vis.type.setup(savedVis).catch(() => savedVis); } return savedVis; }) - .catch(redirectWhenMissing({ - 'visualization': '/visualize', - 'search': '/management/kibana/objects/savedVisualizations/' + $route.current.params.id, - 'index-pattern': '/management/kibana/objects/savedVisualizations/' + $route.current.params.id, - 'index-pattern-field': '/management/kibana/objects/savedVisualizations/' + $route.current.params.id - })); + .catch( + redirectWhenMissing({ + visualization: '/visualize', + search: '/management/kibana/objects/savedVisualizations/' + $route.current.params.id, + 'index-pattern': + '/management/kibana/objects/savedVisualizations/' + $route.current.params.id, + 'index-pattern-field': + '/management/kibana/objects/savedVisualizations/' + $route.current.params.id, + }) + ); } } }); diff --git a/src/legacy/core_plugins/kibana/public/visualize/index.js b/src/legacy/core_plugins/kibana/public/visualize/index.js index 592a355a71b0d..57707f6321376 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/index.js +++ b/src/legacy/core_plugins/kibana/public/visualize/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { ensureDefaultIndexPattern } from 'ui/legacy_compat'; import './editor/editor'; import { i18n } from '@kbn/i18n'; import './saved_visualizations/_saved_vis'; @@ -32,7 +33,6 @@ const { FeatureCatalogueRegistryProvider, uiRoutes } = getServices(); uiRoutes .defaults(/visualize/, { - requireDefaultIndex: true, requireUICapability: 'visualize.show', badge: uiCapabilities => { if (uiCapabilities.visualize.save) { @@ -57,6 +57,7 @@ uiRoutes controllerAs: 'listingController', resolve: { createNewVis: () => false, + hasDefaultIndex: ($rootScope, kbnUrl) => ensureDefaultIndexPattern(getServices().core, getServices().data, $rootScope, kbnUrl) }, }) .when(VisualizeConstants.WIZARD_STEP_1_PAGE_PATH, { @@ -66,6 +67,7 @@ uiRoutes controllerAs: 'listingController', resolve: { createNewVis: () => true, + hasDefaultIndex: ($rootScope, kbnUrl) => ensureDefaultIndexPattern(getServices().core, getServices().data, $rootScope, kbnUrl) }, }); diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts index 3be49971cf4c9..e2201cdca9a57 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts @@ -60,6 +60,7 @@ const services = { savedObjectsClient: npStart.core.savedObjects.client, toastNotifications: npStart.core.notifications.toasts, uiSettings: npStart.core.uiSettings, + core: npStart.core, share: npStart.plugins.share, data, diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts index a6ca444de6d4c..af142973a535d 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts +++ b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts @@ -25,7 +25,7 @@ describe('getNotifyUserAboutOptInDefault: get a flag that describes if the user getNotifyUserAboutOptInDefault({ allowChangingOptInStatus: true, telemetrySavedObject: { userHasSeenNotice: false }, - telemetryOptedIn: null, + telemetryOptedIn: true, configTelemetryOptIn: true, }) ).toBe(true); @@ -40,50 +40,37 @@ describe('getNotifyUserAboutOptInDefault: get a flag that describes if the user configTelemetryOptIn: false, }) ).toBe(false); - }); - it('should return false if user has seen notice', () => { expect( getNotifyUserAboutOptInDefault({ - allowChangingOptInStatus: true, - telemetrySavedObject: { userHasSeenNotice: true }, - telemetryOptedIn: false, + allowChangingOptInStatus: false, + telemetrySavedObject: null, + telemetryOptedIn: true, configTelemetryOptIn: true, }) ).toBe(false); + }); + it('should return false if user has seen notice', () => { expect( getNotifyUserAboutOptInDefault({ allowChangingOptInStatus: true, telemetrySavedObject: { userHasSeenNotice: true }, - telemetryOptedIn: true, - configTelemetryOptIn: true, + telemetryOptedIn: false, + configTelemetryOptIn: false, }) ).toBe(false); - }); - it('not show notice for users already opted in and has not seen notice yet', () => { expect( getNotifyUserAboutOptInDefault({ allowChangingOptInStatus: true, - telemetrySavedObject: { userHasSeenNotice: false }, + telemetrySavedObject: { userHasSeenNotice: true }, telemetryOptedIn: true, configTelemetryOptIn: true, }) ).toBe(false); }); - it('should see notice if they are merely opted in by default and have not yet seen the notice', () => { - expect( - getNotifyUserAboutOptInDefault({ - allowChangingOptInStatus: true, - telemetrySavedObject: { userHasSeenNotice: false }, - telemetryOptedIn: null, - configTelemetryOptIn: true, - }) - ).toBe(true); - }); - it('should return false if user is opted out', () => { expect( getNotifyUserAboutOptInDefault({ diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts index eb95aff6392e0..8ef3bd8388ecb 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts +++ b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts @@ -41,9 +41,5 @@ export function getNotifyUserAboutOptInDefault({ return false; } - if (telemetryOptedIn !== null) { - return false; // they were not defaulted in - } - - return configTelemetryOptIn; + return telemetryOptedIn === true && configTelemetryOptIn === true; } diff --git a/src/legacy/server/config/transform_deprecations.js b/src/legacy/server/config/transform_deprecations.js index 7cac17a88fe64..b23a1de2c0773 100644 --- a/src/legacy/server/config/transform_deprecations.js +++ b/src/legacy/server/config/transform_deprecations.js @@ -102,6 +102,10 @@ const deprecations = [ rename('optimize.lazyHost', 'optimize.watchHost'), rename('optimize.lazyPrebuild', 'optimize.watchPrebuild'), rename('optimize.lazyProxyTimeout', 'optimize.watchProxyTimeout'), + rename('xpack.telemetry.enabled', 'telemetry.enabled'), + rename('xpack.telemetry.config', 'telemetry.config'), + rename('xpack.telemetry.banner', 'telemetry.banner'), + rename('xpack.telemetry.url', 'telemetry.url'), savedObjectsIndexCheckTimeout, rewriteBasePath, configPath, diff --git a/src/legacy/ui/public/chrome/api/angular.js b/src/legacy/ui/public/chrome/api/angular.js index e6457fec93633..73d50a83e11a5 100644 --- a/src/legacy/ui/public/chrome/api/angular.js +++ b/src/legacy/ui/public/chrome/api/angular.js @@ -21,13 +21,15 @@ import { uiModules } from '../../modules'; import { directivesProvider } from '../directives'; import { registerSubUrlHooks } from './sub_url_hooks'; +import { start as data } from '../../../../core_plugins/data/public/legacy'; import { configureAppAngularModule } from 'ui/legacy_compat'; +import { npStart } from '../../new_platform/new_platform'; export function initAngularApi(chrome, internals) { chrome.setupAngular = function () { const kibana = uiModules.get('kibana'); - configureAppAngularModule(kibana); + configureAppAngularModule(kibana, npStart.core, data, false); kibana.value('chrome', chrome); diff --git a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js index 9c4cee6b05db0..a1d48caf3f489 100644 --- a/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js +++ b/src/legacy/ui/public/kbn_top_nav/kbn_top_nav.js @@ -75,8 +75,7 @@ export function createTopNavDirective() { module.directive('kbnTopNav', createTopNavDirective); -export function createTopNavHelper(reactDirective) { - const { TopNavMenu } = navigation.ui; +export const createTopNavHelper = ({ TopNavMenu }) => (reactDirective) => { return reactDirective( wrapInI18nContext(TopNavMenu), [ @@ -116,6 +115,6 @@ export function createTopNavHelper(reactDirective) { 'showAutoRefreshOnly', ], ); -} +}; -module.directive('kbnTopNavHelper', createTopNavHelper); +module.directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); diff --git a/src/legacy/ui/public/legacy_compat/angular_config.tsx b/src/legacy/ui/public/legacy_compat/angular_config.tsx index 788718e848430..6e9f5c85aa1b2 100644 --- a/src/legacy/ui/public/legacy_compat/angular_config.tsx +++ b/src/legacy/ui/public/legacy_compat/angular_config.tsx @@ -28,7 +28,7 @@ import { IRootScopeService, } from 'angular'; import $ from 'jquery'; -import { cloneDeep, forOwn, set } from 'lodash'; +import _, { cloneDeep, forOwn, get, set } from 'lodash'; import React, { Fragment } from 'react'; import * as Rx from 'rxjs'; @@ -37,27 +37,43 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, LegacyCoreStart } from 'kibana/public'; import { fatalError } from 'ui/notify'; -import { capabilities } from 'ui/capabilities'; +import { RouteConfiguration } from 'ui/routes/route_manager'; // @ts-ignore import { modifyUrl } from 'ui/url'; import { toMountPoint } from '../../../../plugins/kibana_react/public'; // @ts-ignore import { UrlOverflowService } from '../error_url_overflow'; -import { npStart } from '../new_platform'; -import { toastNotifications } from '../notify'; // @ts-ignore import { isSystemApiRequest } from '../system_api'; const URL_LIMIT_WARN_WITHIN = 1000; -function isDummyWrapperRoute($route: any) { +/** + * Detects whether a given angular route is a dummy route that doesn't + * require any action. There are two ways this can happen: + * If `outerAngularWrapperRoute` is set on the route config object, + * it means the local application service set up this route on the outer angular + * and the internal routes will handle the hooks. + * + * If angular did not detect a route and it is the local angular, we are currently + * navigating away from a URL controlled by a local angular router and the + * application will get unmounted. In this case the outer router will handle + * the hooks. + * @param $route Injected $route dependency + * @param isLocalAngular Flag whether this is the local angular router + */ +function isDummyRoute($route: any, isLocalAngular: boolean) { return ( - $route.current && $route.current.$$route && $route.current.$$route.outerAngularWrapperRoute + ($route.current && $route.current.$$route && $route.current.$$route.outerAngularWrapperRoute) || + (!$route.current && isLocalAngular) ); } -export const configureAppAngularModule = (angularModule: IModule) => { - const newPlatform = npStart.core; +export const configureAppAngularModule = ( + angularModule: IModule, + newPlatform: LegacyCoreStart, + isLocalAngular: boolean +) => { const legacyMetadata = newPlatform.injectedMetadata.getLegacyMetadata(); forOwn(newPlatform.injectedMetadata.getInjectedVars(), (val, name) => { @@ -73,15 +89,16 @@ export const configureAppAngularModule = (angularModule: IModule) => { .value('buildSha', legacyMetadata.buildSha) .value('serverName', legacyMetadata.serverName) .value('esUrl', getEsUrl(newPlatform)) - .value('uiCapabilities', capabilities.get()) + .value('uiCapabilities', newPlatform.application.capabilities) .config(setupCompileProvider(newPlatform)) .config(setupLocationProvider(newPlatform)) .config($setupXsrfRequestInterceptor(newPlatform)) .run(capture$httpLoadingCount(newPlatform)) - .run($setupBreadcrumbsAutoClear(newPlatform)) - .run($setupBadgeAutoClear(newPlatform)) - .run($setupHelpExtensionAutoClear(newPlatform)) - .run($setupUrlOverflowHandling(newPlatform)); + .run($setupBreadcrumbsAutoClear(newPlatform, isLocalAngular)) + .run($setupBadgeAutoClear(newPlatform, isLocalAngular)) + .run($setupHelpExtensionAutoClear(newPlatform, isLocalAngular)) + .run($setupUrlOverflowHandling(newPlatform, isLocalAngular)) + .run($setupUICapabilityRedirect(newPlatform)); }; const getEsUrl = (newPlatform: CoreStart) => { @@ -168,12 +185,42 @@ const capture$httpLoadingCount = (newPlatform: CoreStart) => ( ); }; +/** + * integrates with angular to automatically redirect to home if required + * capability is not met + */ +const $setupUICapabilityRedirect = (newPlatform: CoreStart) => ( + $rootScope: IRootScopeService, + $injector: any +) => { + const isKibanaAppRoute = window.location.pathname.endsWith('/app/kibana'); + // this feature only works within kibana app for now after everything is + // switched to the application service, this can be changed to handle all + // apps. + if (!isKibanaAppRoute) { + return; + } + $rootScope.$on( + '$routeChangeStart', + (event, { $$route: route }: { $$route?: RouteConfiguration } = {}) => { + if (!route || !route.requireUICapability) { + return; + } + + if (!get(newPlatform.application.capabilities, route.requireUICapability)) { + $injector.get('kbnUrl').change('/home'); + event.preventDefault(); + } + } + ); +}; + /** * internal angular run function that will be called when angular bootstraps and * lets us integrate with the angular router so that we can automatically clear * the breadcrumbs if we switch to a Kibana app that does not use breadcrumbs correctly */ -const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart) => ( +const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -195,7 +242,7 @@ const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart) => ( }); $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyWrapperRoute($route)) { + if (isDummyRoute($route, isLocalAngular)) { return; } const current = $route.current || {}; @@ -223,7 +270,7 @@ const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart) => ( * lets us integrate with the angular router so that we can automatically clear * the badge if we switch to a Kibana app that does not use the badge correctly */ -const $setupBadgeAutoClear = (newPlatform: CoreStart) => ( +const $setupBadgeAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -237,7 +284,7 @@ const $setupBadgeAutoClear = (newPlatform: CoreStart) => ( }); $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyWrapperRoute($route)) { + if (isDummyRoute($route, isLocalAngular)) { return; } const current = $route.current || {}; @@ -266,7 +313,7 @@ const $setupBadgeAutoClear = (newPlatform: CoreStart) => ( * the helpExtension if we switch to a Kibana app that does not set its own * helpExtension */ -const $setupHelpExtensionAutoClear = (newPlatform: CoreStart) => ( +const $setupHelpExtensionAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( $rootScope: IRootScopeService, $injector: any ) => { @@ -284,14 +331,14 @@ const $setupHelpExtensionAutoClear = (newPlatform: CoreStart) => ( const $route = $injector.has('$route') ? $injector.get('$route') : {}; $rootScope.$on('$routeChangeStart', () => { - if (isDummyWrapperRoute($route)) { + if (isDummyRoute($route, isLocalAngular)) { return; } helpExtensionSetSinceRouteChange = false; }); $rootScope.$on('$routeChangeSuccess', () => { - if (isDummyWrapperRoute($route)) { + if (isDummyRoute($route, isLocalAngular)) { return; } const current = $route.current || {}; @@ -304,7 +351,7 @@ const $setupHelpExtensionAutoClear = (newPlatform: CoreStart) => ( }); }; -const $setupUrlOverflowHandling = (newPlatform: CoreStart) => ( +const $setupUrlOverflowHandling = (newPlatform: CoreStart, isLocalAngular: boolean) => ( $location: ILocationService, $rootScope: IRootScopeService, $injector: auto.IInjectorService @@ -312,7 +359,7 @@ const $setupUrlOverflowHandling = (newPlatform: CoreStart) => ( const $route = $injector.has('$route') ? $injector.get('$route') : {}; const urlOverflow = new UrlOverflowService(); const check = () => { - if (isDummyWrapperRoute($route)) { + if (isDummyRoute($route, isLocalAngular)) { return; } // disable long url checks when storing state in session storage @@ -326,7 +373,7 @@ const $setupUrlOverflowHandling = (newPlatform: CoreStart) => ( try { if (urlOverflow.check($location.absUrl()) <= URL_LIMIT_WARN_WITHIN) { - toastNotifications.addWarning({ + newPlatform.notifications.toasts.addWarning({ title: i18n.translate('common.ui.chrome.bigUrlWarningNotificationTitle', { defaultMessage: 'The URL is big and Kibana might stop working', }), diff --git a/src/legacy/ui/public/legacy_compat/ensure_default_index_pattern.tsx b/src/legacy/ui/public/legacy_compat/ensure_default_index_pattern.tsx new file mode 100644 index 0000000000000..98e95865d7325 --- /dev/null +++ b/src/legacy/ui/public/legacy_compat/ensure_default_index_pattern.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { contains } from 'lodash'; +import { IRootScopeService } from 'angular'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import { DataStart } from '../../../core_plugins/data/public'; + +let bannerId: string; +let timeoutId: NodeJS.Timeout | undefined; + +/** + * Checks whether a default index pattern is set and exists and defines + * one otherwise. + * + * If there are no index patterns, redirect to management page and show + * banner. In this case the promise returned from this function will never + * resolve to wait for the URL change to happen. + */ +export async function ensureDefaultIndexPattern( + newPlatform: CoreStart, + data: DataStart, + $rootScope: IRootScopeService, + kbnUrl: any +) { + const patterns = await data.indexPatterns.indexPatterns.getIds(); + let defaultId = newPlatform.uiSettings.get('defaultIndex'); + let defined = !!defaultId; + const exists = contains(patterns, defaultId); + + if (defined && !exists) { + newPlatform.uiSettings.remove('defaultIndex'); + defaultId = defined = false; + } + + if (defined) { + return; + } + + // If there is any index pattern created, set the first as default + if (patterns.length >= 1) { + defaultId = patterns[0]; + newPlatform.uiSettings.set('defaultIndex', defaultId); + } else { + const canManageIndexPatterns = + newPlatform.application.capabilities.management.kibana.index_patterns; + const redirectTarget = canManageIndexPatterns ? '/management/kibana/index_pattern' : '/home'; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Avoid being hostile to new users who don't have an index pattern setup yet + // give them a friendly info message instead of a terse error message + bannerId = newPlatform.overlays.banners.replace(bannerId, (element: HTMLElement) => { + ReactDOM.render( + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }); + + // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around + timeoutId = setTimeout(() => { + newPlatform.overlays.banners.remove(bannerId); + timeoutId = undefined; + }, 15000); + + kbnUrl.change(redirectTarget); + $rootScope.$digest(); + + // return never-resolving promise to stop resolving and wait for the url change + return new Promise(() => {}); + } +} diff --git a/src/legacy/ui/public/legacy_compat/index.ts b/src/legacy/ui/public/legacy_compat/index.ts index b29056954051b..ea8932114118e 100644 --- a/src/legacy/ui/public/legacy_compat/index.ts +++ b/src/legacy/ui/public/legacy_compat/index.ts @@ -18,3 +18,4 @@ */ export { configureAppAngularModule } from './angular_config'; +export { ensureDefaultIndexPattern } from './ensure_default_index_pattern'; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index 6e71d36877895..1db360749c714 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -29,17 +29,14 @@ import { } from '../../../../plugins/inspector/public'; import { EuiUtilsStart } from '../../../../plugins/eui_utils/public'; import { DevToolsSetup, DevToolsStart } from '../../../../plugins/dev_tools/public'; -import { - FeatureCatalogueSetup, - FeatureCatalogueStart, -} from '../../../../plugins/feature_catalogue/public'; +import { HomePublicPluginSetup, HomePublicPluginStart } from '../../../../plugins/home/public'; import { SharePluginSetup, SharePluginStart } from '../../../../plugins/share/public'; export interface PluginsSetup { data: ReturnType; embeddable: EmbeddableSetup; expressions: ReturnType; - feature_catalogue: FeatureCatalogueSetup; + home: HomePublicPluginSetup; inspector: InspectorSetup; uiActions: IUiActionsSetup; share: SharePluginSetup; @@ -51,7 +48,7 @@ export interface PluginsStart { embeddable: EmbeddableStart; eui_utils: EuiUtilsStart; expressions: ReturnType; - feature_catalogue: FeatureCatalogueStart; + home: HomePublicPluginStart; inspector: InspectorStart; uiActions: IUiActionsStart; share: SharePluginStart; diff --git a/src/legacy/ui/public/registry/feature_catalogue.js b/src/legacy/ui/public/registry/feature_catalogue.js index 8905a15106953..475705ff39e48 100644 --- a/src/legacy/ui/public/registry/feature_catalogue.js +++ b/src/legacy/ui/public/registry/feature_catalogue.js @@ -19,7 +19,7 @@ import { uiRegistry } from './_registry'; import { capabilities } from '../capabilities'; -export { FeatureCatalogueCategory } from '../../../../plugins/feature_catalogue/public'; +export { FeatureCatalogueCategory } from '../../../../plugins/home/public'; export const FeatureCatalogueRegistryProvider = uiRegistry({ name: 'featureCatalogue', diff --git a/src/legacy/ui/public/routes/__tests__/_route_manager.js b/src/legacy/ui/public/routes/__tests__/_route_manager.js index d6d4c869b4b7e..450bb51f0b0c6 100644 --- a/src/legacy/ui/public/routes/__tests__/_route_manager.js +++ b/src/legacy/ui/public/routes/__tests__/_route_manager.js @@ -119,18 +119,6 @@ describe('routes/route_manager', function () { expect($rp.when.secondCall.args[1]).to.have.property('reloadOnSearch', false); expect($rp.when.lastCall.args[1]).to.have.property('reloadOnSearch', true); }); - - it('sets route.requireDefaultIndex to false by default', function () { - routes.when('/nothing-set'); - routes.when('/no-index-required', { requireDefaultIndex: false }); - routes.when('/index-required', { requireDefaultIndex: true }); - routes.config($rp); - - expect($rp.when.callCount).to.be(3); - expect($rp.when.firstCall.args[1]).to.have.property('requireDefaultIndex', false); - expect($rp.when.secondCall.args[1]).to.have.property('requireDefaultIndex', false); - expect($rp.when.lastCall.args[1]).to.have.property('requireDefaultIndex', true); - }); }); describe('#defaults()', () => { diff --git a/src/legacy/ui/public/routes/route_manager.d.ts b/src/legacy/ui/public/routes/route_manager.d.ts index 56203354f3c20..a5261a7c8ee3a 100644 --- a/src/legacy/ui/public/routes/route_manager.d.ts +++ b/src/legacy/ui/public/routes/route_manager.d.ts @@ -23,7 +23,7 @@ import { ChromeBreadcrumb } from '../../../../core/public'; -interface RouteConfiguration { +export interface RouteConfiguration { controller?: string | ((...args: any[]) => void); redirectTo?: string; resolveRedirectTo?: (...args: any[]) => void; diff --git a/src/legacy/ui/public/routes/route_manager.js b/src/legacy/ui/public/routes/route_manager.js index ba48984bb45b9..6444ef66fbe47 100644 --- a/src/legacy/ui/public/routes/route_manager.js +++ b/src/legacy/ui/public/routes/route_manager.js @@ -46,10 +46,6 @@ export default function RouteManager() { route.reloadOnSearch = false; } - if (route.requireDefaultIndex == null) { - route.requireDefaultIndex = false; - } - wrapRouteWithPrep(route, setup); $routeProvider.when(path, route); }); diff --git a/src/legacy/ui/public/state_management/state.js b/src/legacy/ui/public/state_management/state.js index b7623ab0fc5a5..8d55a6929a617 100644 --- a/src/legacy/ui/public/state_management/state.js +++ b/src/legacy/ui/public/state_management/state.js @@ -42,9 +42,14 @@ import { isStateHash, } from './state_storage'; -export function StateProvider(Private, $rootScope, $location, stateManagementConfig, config, kbnUrl) { +export function StateProvider(Private, $rootScope, $location, stateManagementConfig, config, kbnUrl, $injector) { const Events = Private(EventsProvider); + const isDummyRoute = () => + $injector.has('$route') && + $injector.get('$route').current && + $injector.get('$route').current.outerAngularWrapperRoute; + createLegacyClass(State).inherits(Events); function State( urlParam, @@ -137,7 +142,7 @@ export function StateProvider(Private, $rootScope, $location, stateManagementCon let stash = this._readFromURL(); - // nothing to read from the url? save if ordered to persist + // nothing to read from the url? save if ordered to persist, but only if it's not on a wrapper route if (stash === null) { if (this._persistAcrossApps) { return this.save(); @@ -150,7 +155,7 @@ export function StateProvider(Private, $rootScope, $location, stateManagementCon // apply diff to state from stash, will change state in place via side effect const diffResults = applyDiff(this, stash); - if (diffResults.keys.length) { + if (!isDummyRoute() && diffResults.keys.length) { this.emit('fetch_with_changes', diffResults.keys); } }; @@ -164,6 +169,10 @@ export function StateProvider(Private, $rootScope, $location, stateManagementCon return; } + if (isDummyRoute()) { + return; + } + let stash = this._readFromURL(); const state = this.toObject(); replace = replace || false; diff --git a/src/legacy/ui/public/timefilter/setup_router.test.js b/src/legacy/ui/public/timefilter/setup_router.test.js index 4bc797e5eff00..f229937c3b435 100644 --- a/src/legacy/ui/public/timefilter/setup_router.test.js +++ b/src/legacy/ui/public/timefilter/setup_router.test.js @@ -42,9 +42,14 @@ describe('registerTimefilterWithGlobalState()', () => { } }; + const rootScope = { + $on: jest.fn() + }; + registerTimefilterWithGlobalState( timefilter, - globalState + globalState, + rootScope, ); expect(setTime.mock.calls.length).toBe(2); diff --git a/src/legacy/ui/public/timefilter/setup_router.ts b/src/legacy/ui/public/timefilter/setup_router.ts index 0a73378f99cd7..64105b016fb44 100644 --- a/src/legacy/ui/public/timefilter/setup_router.ts +++ b/src/legacy/ui/public/timefilter/setup_router.ts @@ -23,6 +23,7 @@ import moment from 'moment'; import { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; import chrome from 'ui/chrome'; import { RefreshInterval, TimeRange, TimefilterContract } from 'src/plugins/data/public'; +import { Subscription } from 'rxjs'; // TODO // remove everything underneath once globalState is no longer an angular service @@ -40,49 +41,62 @@ export function getTimefilterConfig() { }; } -// Currently some parts of Kibana (index patterns, timefilter) rely on addSetupWork in the uiRouter -// and require it to be executed to properly function. -// This function is exposed for applications that do not use uiRoutes like APM -// Kibana issue https://github.com/elastic/kibana/issues/19110 tracks the removal of this dependency on uiRouter -export const registerTimefilterWithGlobalState = _.once( - (timefilter: TimefilterContract, globalState: any, $rootScope: IScope) => { - // settings have to be re-fetched here, to make sure that settings changed by overrideLocalDefault are taken into account. - const config = getTimefilterConfig(); - timefilter.setTime(_.defaults(globalState.time || {}, config.timeDefaults)); - timefilter.setRefreshInterval( - _.defaults(globalState.refreshInterval || {}, config.refreshIntervalDefaults) - ); +export const registerTimefilterWithGlobalStateFactory = ( + timefilter: TimefilterContract, + globalState: any, + $rootScope: IScope +) => { + // settings have to be re-fetched here, to make sure that settings changed by overrideLocalDefault are taken into account. + const config = getTimefilterConfig(); + timefilter.setTime(_.defaults(globalState.time || {}, config.timeDefaults)); + timefilter.setRefreshInterval( + _.defaults(globalState.refreshInterval || {}, config.refreshIntervalDefaults) + ); - globalState.on('fetch_with_changes', () => { - // clone and default to {} in one - const newTime: TimeRange = _.defaults({}, globalState.time, config.timeDefaults); - const newRefreshInterval: RefreshInterval = _.defaults( - {}, - globalState.refreshInterval, - config.refreshIntervalDefaults - ); + globalState.on('fetch_with_changes', () => { + // clone and default to {} in one + const newTime: TimeRange = _.defaults({}, globalState.time, config.timeDefaults); + const newRefreshInterval: RefreshInterval = _.defaults( + {}, + globalState.refreshInterval, + config.refreshIntervalDefaults + ); - if (newTime) { - if (newTime.to) newTime.to = convertISO8601(newTime.to); - if (newTime.from) newTime.from = convertISO8601(newTime.from); - } + if (newTime) { + if (newTime.to) newTime.to = convertISO8601(newTime.to); + if (newTime.from) newTime.from = convertISO8601(newTime.from); + } - timefilter.setTime(newTime); - timefilter.setRefreshInterval(newRefreshInterval); - }); + timefilter.setTime(newTime); + timefilter.setRefreshInterval(newRefreshInterval); + }); - const updateGlobalStateWithTime = () => { - globalState.time = timefilter.getTime(); - globalState.refreshInterval = timefilter.getRefreshInterval(); - globalState.save(); - }; + const updateGlobalStateWithTime = () => { + globalState.time = timefilter.getTime(); + globalState.refreshInterval = timefilter.getRefreshInterval(); + globalState.save(); + }; + const subscriptions = new Subscription(); + subscriptions.add( subscribeWithScope($rootScope, timefilter.getRefreshIntervalUpdate$(), { next: updateGlobalStateWithTime, - }); + }) + ); + subscriptions.add( subscribeWithScope($rootScope, timefilter.getTimeUpdate$(), { next: updateGlobalStateWithTime, - }); - } -); + }) + ); + + $rootScope.$on('$destroy', () => { + subscriptions.unsubscribe(); + }); +}; + +// Currently some parts of Kibana (index patterns, timefilter) rely on addSetupWork in the uiRouter +// and require it to be executed to properly function. +// This function is exposed for applications that do not use uiRoutes like APM +// Kibana issue https://github.com/elastic/kibana/issues/19110 tracks the removal of this dependency on uiRouter +export const registerTimefilterWithGlobalState = _.once(registerTimefilterWithGlobalStateFactory); diff --git a/src/legacy/ui/public/vis/vis_filters/vis_filters.js b/src/legacy/ui/public/vis/vis_filters/vis_filters.js index e879d040125f1..18d633e1b5fb2 100644 --- a/src/legacy/ui/public/vis/vis_filters/vis_filters.js +++ b/src/legacy/ui/public/vis/vis_filters/vis_filters.js @@ -115,7 +115,6 @@ const VisFiltersProvider = (getAppState, $timeout) => { } }; - return { pushFilters, }; diff --git a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss index 7cbe135115877..9575908146d1d 100644 --- a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss @@ -1,8 +1,10 @@ .dshDashboardViewport { + height: 100%; width: 100%; background-color: $euiColorEmptyShade; } .dshDashboardViewport-withMargins { width: 100%; + height: 100%; } diff --git a/src/plugins/data/common/es_query/filters/phrase_filter.ts b/src/plugins/data/common/es_query/filters/phrase_filter.ts index 8b8c5f8915269..167bb3f840350 100644 --- a/src/plugins/data/common/es_query/filters/phrase_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrase_filter.ts @@ -25,6 +25,12 @@ export type PhraseFilterMeta = FilterMeta & { params?: { query: string; // The unformatted value }; + field?: any; + index?: any; +}; + +export type PhraseFilter = Filter & { + meta: PhraseFilterMeta; script?: { script: { source?: any; @@ -32,12 +38,6 @@ export type PhraseFilterMeta = FilterMeta & { params: any; }; }; - field?: any; - index?: any; -}; - -export type PhraseFilter = Filter & { - meta: PhraseFilterMeta; }; type PhraseFilterValue = string | number | boolean; @@ -79,7 +79,7 @@ export const buildPhraseFilter = ( return { meta: { index: indexPattern.id, field: field.name } as PhraseFilterMeta, script: getPhraseScript(field, value), - } as PhraseFilter; + }; } else { return { meta: { index: indexPattern.id }, diff --git a/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts b/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts index 42607843df3ba..f2fd55af4f418 100644 --- a/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts +++ b/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts @@ -42,7 +42,7 @@ function getExistingFilter( } if (esFilters.isScriptedPhraseFilter(filter)) { - return filter.meta.field === fieldName && filter.meta.script!.script.params.value === value; + return filter.meta.field === fieldName && filter.script!.script.params.value === value; } }); } diff --git a/src/plugins/feature_catalogue/kibana.json b/src/plugins/feature_catalogue/kibana.json deleted file mode 100644 index 3f39c9361f047..0000000000000 --- a/src/plugins/feature_catalogue/kibana.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "feature_catalogue", - "version": "kibana", - "server": false, - "ui": true -} diff --git a/src/plugins/feature_catalogue/README.md b/src/plugins/home/README.md similarity index 52% rename from src/plugins/feature_catalogue/README.md rename to src/plugins/home/README.md index 68584e7ed2ce1..74e12a799b1b7 100644 --- a/src/plugins/feature_catalogue/README.md +++ b/src/plugins/home/README.md @@ -1,4 +1,7 @@ -# Feature catalogue plugin +# home plugin +Moves the legacy `ui/registry/feature_catalogue` module for registering "features" that should be shown in the home page's feature catalogue to a service within a "home" plugin. The feature catalogue refered to here should not be confused with the "feature" plugin for registering features used to derive UI capabilities for feature controls. + +# Feature catalogue (public service) Replaces the legacy `ui/registry/feature_catalogue` module for registering "features" that should be showed in the home page's feature catalogue. This should not be confused with the "feature" plugin for registering features used to derive @@ -9,15 +12,15 @@ UI capabilities for feature controls. ```ts // For legacy plugins import { npSetup } from 'ui/new_platform'; -npSetup.plugins.feature_catalogue.register(/* same details here */); +npSetup.plugins.home.featureCatalogue.register(/* same details here */); -// For new plugins: first add 'feature_catalogue` to the list of `optionalPlugins` +// For new plugins: first add 'home` to the list of `optionalPlugins` // in your kibana.json file. Then access the plugin directly in `setup`: class MyPlugin { setup(core, plugins) { - if (plugins.feature_catalogue) { - plugins.feature_catalogue.register(/* same details here. */); + if (plugins.home) { + plugins.home.featureCatalgoue.register(/* same details here. */); } } } diff --git a/src/plugins/home/kibana.json b/src/plugins/home/kibana.json index 8d2d79560f854..a5c65e3efa597 100644 --- a/src/plugins/home/kibana.json +++ b/src/plugins/home/kibana.json @@ -2,5 +2,5 @@ "id": "home", "version": "kibana", "server": true, - "ui": false + "ui": true } diff --git a/src/plugins/feature_catalogue/public/index.ts b/src/plugins/home/public/index.ts similarity index 79% rename from src/plugins/feature_catalogue/public/index.ts rename to src/plugins/home/public/index.ts index dd241a317c4a6..25e94c20c347b 100644 --- a/src/plugins/feature_catalogue/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -17,8 +17,13 @@ * under the License. */ -export { FeatureCatalogueSetup, FeatureCatalogueStart } from './plugin'; +export { + FeatureCatalogueSetup, + FeatureCatalogueStart, + HomePublicPluginSetup, + HomePublicPluginStart, +} from './plugin'; export { FeatureCatalogueEntry, FeatureCatalogueCategory } from './services'; -import { FeatureCataloguePlugin } from './plugin'; +import { HomePublicPlugin } from './plugin'; -export const plugin = () => new FeatureCataloguePlugin(); +export const plugin = () => new HomePublicPlugin(); diff --git a/src/plugins/feature_catalogue/public/plugin.test.mocks.ts b/src/plugins/home/public/plugin.test.mocks.ts similarity index 95% rename from src/plugins/feature_catalogue/public/plugin.test.mocks.ts rename to src/plugins/home/public/plugin.test.mocks.ts index c0da6a179204b..a48ea8f795136 100644 --- a/src/plugins/feature_catalogue/public/plugin.test.mocks.ts +++ b/src/plugins/home/public/plugin.test.mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { featureCatalogueRegistryMock } from './services/feature_catalogue_registry.mock'; +import { featureCatalogueRegistryMock } from './services/feature_catalogue/feature_catalogue_registry.mock'; export const registryMock = featureCatalogueRegistryMock.create(); jest.doMock('./services', () => ({ diff --git a/src/plugins/feature_catalogue/public/plugin.test.ts b/src/plugins/home/public/plugin.test.ts similarity index 79% rename from src/plugins/feature_catalogue/public/plugin.test.ts rename to src/plugins/home/public/plugin.test.ts index 8bbbb973b459e..fad6e8cf47bfe 100644 --- a/src/plugins/feature_catalogue/public/plugin.test.ts +++ b/src/plugins/home/public/plugin.test.ts @@ -18,9 +18,9 @@ */ import { registryMock } from './plugin.test.mocks'; -import { FeatureCataloguePlugin } from './plugin'; +import { HomePublicPlugin } from './plugin'; -describe('FeatureCataloguePlugin', () => { +describe('HomePublicPlugin', () => { beforeEach(() => { registryMock.setup.mockClear(); registryMock.start.mockClear(); @@ -28,22 +28,22 @@ describe('FeatureCataloguePlugin', () => { describe('setup', () => { test('wires up and returns registry', async () => { - const setup = await new FeatureCataloguePlugin().setup(); - expect(registryMock.setup).toHaveBeenCalledWith(); - expect(setup.register).toBeDefined(); + const setup = await new HomePublicPlugin().setup(); + expect(setup).toHaveProperty('featureCatalogue'); + expect(setup.featureCatalogue).toHaveProperty('register'); }); }); describe('start', () => { test('wires up and returns registry', async () => { - const service = new FeatureCataloguePlugin(); + const service = new HomePublicPlugin(); await service.setup(); const core = { application: { capabilities: { catalogue: {} } } } as any; const start = await service.start(core); expect(registryMock.start).toHaveBeenCalledWith({ capabilities: core.application.capabilities, }); - expect(start.get).toBeDefined(); + expect(start.featureCatalogue.get).toBeDefined(); }); }); }); diff --git a/src/plugins/feature_catalogue/public/plugin.ts b/src/plugins/home/public/plugin.ts similarity index 71% rename from src/plugins/feature_catalogue/public/plugin.ts rename to src/plugins/home/public/plugin.ts index 46a70baff488a..40f2047ef0016 100644 --- a/src/plugins/feature_catalogue/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -24,21 +24,22 @@ import { FeatureCatalogueRegistryStart, } from './services'; -export class FeatureCataloguePlugin - implements Plugin { +export class HomePublicPlugin implements Plugin { private readonly featuresCatalogueRegistry = new FeatureCatalogueRegistry(); public async setup() { return { - ...this.featuresCatalogueRegistry.setup(), + featureCatalogue: { ...this.featuresCatalogueRegistry.setup() }, }; } public async start(core: CoreStart) { return { - ...this.featuresCatalogueRegistry.start({ - capabilities: core.application.capabilities, - }), + featureCatalogue: { + ...this.featuresCatalogueRegistry.start({ + capabilities: core.application.capabilities, + }), + }, }; } } @@ -48,3 +49,13 @@ export type FeatureCatalogueSetup = FeatureCatalogueRegistrySetup; /** @public */ export type FeatureCatalogueStart = FeatureCatalogueRegistryStart; + +/** @public */ +export interface HomePublicPluginSetup { + featureCatalogue: FeatureCatalogueSetup; +} + +/** @public */ +export interface HomePublicPluginStart { + featureCatalogue: FeatureCatalogueStart; +} diff --git a/src/plugins/feature_catalogue/public/services/feature_catalogue_registry.mock.ts b/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.mock.ts similarity index 100% rename from src/plugins/feature_catalogue/public/services/feature_catalogue_registry.mock.ts rename to src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.mock.ts diff --git a/src/plugins/feature_catalogue/public/services/feature_catalogue_registry.test.ts b/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.test.ts similarity index 100% rename from src/plugins/feature_catalogue/public/services/feature_catalogue_registry.test.ts rename to src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.test.ts diff --git a/src/plugins/feature_catalogue/public/services/feature_catalogue_registry.ts b/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts similarity index 100% rename from src/plugins/feature_catalogue/public/services/feature_catalogue_registry.ts rename to src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts diff --git a/src/legacy/ui/public/capabilities/route_setup.ts b/src/plugins/home/public/services/feature_catalogue/index.ts similarity index 58% rename from src/legacy/ui/public/capabilities/route_setup.ts rename to src/plugins/home/public/services/feature_catalogue/index.ts index c7817b8cc5748..eae01271e8559 100644 --- a/src/legacy/ui/public/capabilities/route_setup.ts +++ b/src/plugins/home/public/services/feature_catalogue/index.ts @@ -17,22 +17,10 @@ * under the License. */ -import { get } from 'lodash'; -import chrome from 'ui/chrome'; -import uiRoutes from 'ui/routes'; -import { UICapabilities } from '.'; - -uiRoutes.addSetupWork( - (uiCapabilities: UICapabilities, kbnBaseUrl: string, $route: any, kbnUrl: any) => { - const route = get($route, 'current.$$route') as any; - if (!route.requireUICapability) { - return; - } - - if (!get(uiCapabilities, route.requireUICapability)) { - const url = chrome.addBasePath(`${kbnBaseUrl}#/home`); - kbnUrl.redirect(url); - throw uiRoutes.WAIT_FOR_URL_CHANGE_TOKEN; - } - } -); +export { + FeatureCatalogueCategory, + FeatureCatalogueEntry, + FeatureCatalogueRegistry, + FeatureCatalogueRegistrySetup, + FeatureCatalogueRegistryStart, +} from './feature_catalogue_registry'; diff --git a/src/plugins/feature_catalogue/public/services/index.ts b/src/plugins/home/public/services/index.ts similarity index 94% rename from src/plugins/feature_catalogue/public/services/index.ts rename to src/plugins/home/public/services/index.ts index 17433264f5a42..3621b0912393a 100644 --- a/src/plugins/feature_catalogue/public/services/index.ts +++ b/src/plugins/home/public/services/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './feature_catalogue_registry'; +export * from './feature_catalogue'; diff --git a/src/plugins/home/server/index.ts b/src/plugins/home/server/index.ts index 178a77dc85ca9..be4e20ab63d3c 100644 --- a/src/plugins/home/server/index.ts +++ b/src/plugins/home/server/index.ts @@ -17,8 +17,8 @@ * under the License. */ -export { HomePluginSetup, HomePluginStart } from './plugin'; +export { HomeServerPluginSetup, HomeServerPluginStart } from './plugin'; export { TutorialProvider } from './services'; -import { HomePlugin } from './plugin'; +import { HomeServerPlugin } from './plugin'; -export const plugin = () => new HomePlugin(); +export const plugin = () => new HomeServerPlugin(); diff --git a/src/plugins/home/server/plugin.test.mocks.ts b/src/plugins/home/server/plugin.test.mocks.ts index df63b467d8656..a5640de579b15 100644 --- a/src/plugins/home/server/plugin.test.mocks.ts +++ b/src/plugins/home/server/plugin.test.mocks.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { tutorialsRegistryMock } from './services/tutorials_registry.mock'; +import { tutorialsRegistryMock } from './services/tutorials/tutorials_registry.mock'; export const registryMock = tutorialsRegistryMock.create(); jest.doMock('./services', () => ({ diff --git a/src/plugins/home/server/plugin.test.ts b/src/plugins/home/server/plugin.test.ts index e86a2d807109f..eec6501436bf4 100644 --- a/src/plugins/home/server/plugin.test.ts +++ b/src/plugins/home/server/plugin.test.ts @@ -18,13 +18,13 @@ */ import { registryMock } from './plugin.test.mocks'; -import { HomePlugin } from './plugin'; +import { HomeServerPlugin } from './plugin'; import { coreMock } from '../../../core/server/mocks'; import { CoreSetup } from '../../../core/server'; type MockedKeys = { [P in keyof T]: jest.Mocked }; -describe('HomePlugin', () => { +describe('HomeServerPlugin', () => { beforeEach(() => { registryMock.setup.mockClear(); registryMock.start.mockClear(); @@ -34,7 +34,7 @@ describe('HomePlugin', () => { const mockCoreSetup: MockedKeys = coreMock.createSetup(); test('wires up and returns registerTutorial and addScopedTutorialContextFactory', () => { - const setup = new HomePlugin().setup(mockCoreSetup); + const setup = new HomeServerPlugin().setup(mockCoreSetup); expect(setup).toHaveProperty('tutorials'); expect(setup.tutorials).toHaveProperty('registerTutorial'); expect(setup.tutorials).toHaveProperty('addScopedTutorialContextFactory'); @@ -43,7 +43,7 @@ describe('HomePlugin', () => { describe('start', () => { test('is defined', () => { - const start = new HomePlugin().start(); + const start = new HomeServerPlugin().start(); expect(start).toBeDefined(); expect(start).toHaveProperty('tutorials'); }); diff --git a/src/plugins/home/server/plugin.ts b/src/plugins/home/server/plugin.ts index d5a3f235f8490..89dda8205ce02 100644 --- a/src/plugins/home/server/plugin.ts +++ b/src/plugins/home/server/plugin.ts @@ -19,7 +19,7 @@ import { CoreSetup, Plugin } from 'src/core/server'; import { TutorialsRegistry, TutorialsRegistrySetup, TutorialsRegistryStart } from './services'; -export class HomePlugin implements Plugin { +export class HomeServerPlugin implements Plugin { private readonly tutorialsRegistry = new TutorialsRegistry(); public setup(core: CoreSetup) { @@ -36,11 +36,11 @@ export class HomePlugin implements Plugin { } /** @public */ -export interface HomePluginSetup { +export interface HomeServerPluginSetup { tutorials: TutorialsRegistrySetup; } /** @public */ -export interface HomePluginStart { +export interface HomeServerPluginStart { tutorials: TutorialsRegistryStart; } diff --git a/src/plugins/home/server/services/index.ts b/src/plugins/home/server/services/index.ts index 5fe5cb0ba4760..9bfbe4079c6be 100644 --- a/src/plugins/home/server/services/index.ts +++ b/src/plugins/home/server/services/index.ts @@ -19,9 +19,17 @@ // provided to other plugins as APIs // should model the plugin lifecycle +export { TutorialsRegistry, TutorialsRegistrySetup, TutorialsRegistryStart } from './tutorials'; export { - TutorialsRegistry, - TutorialsRegistrySetup, - TutorialsRegistryStart, -} from './tutorials_registry'; -export * from '../lib/tutorials_registry_types'; + TutorialsCategory, + ParamTypes, + InstructionSetSchema, + ParamsSchema, + InstructionsSchema, + DashboardSchema, + ArtifactsSchema, + TutorialSchema, + TutorialProvider, + TutorialContextFactory, + ScopedTutorialContextFactory, +} from './tutorials'; diff --git a/src/plugins/home/server/services/tutorials/index.ts b/src/plugins/home/server/services/tutorials/index.ts new file mode 100644 index 0000000000000..d481a94516163 --- /dev/null +++ b/src/plugins/home/server/services/tutorials/index.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { + TutorialsRegistry, + TutorialsRegistrySetup, + TutorialsRegistryStart, +} from './tutorials_registry'; +export { + TutorialsCategory, + ParamTypes, + InstructionSetSchema, + ParamsSchema, + InstructionsSchema, + DashboardSchema, + ArtifactsSchema, + TutorialSchema, + TutorialProvider, + TutorialContextFactory, + ScopedTutorialContextFactory, +} from './lib/tutorials_registry_types'; diff --git a/src/plugins/home/server/lib/tutorial_schema.ts b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts similarity index 100% rename from src/plugins/home/server/lib/tutorial_schema.ts rename to src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts diff --git a/src/plugins/home/server/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts similarity index 100% rename from src/plugins/home/server/lib/tutorials_registry_types.ts rename to src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts diff --git a/src/plugins/home/server/services/tutorials_registry.mock.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.mock.ts similarity index 100% rename from src/plugins/home/server/services/tutorials_registry.mock.ts rename to src/plugins/home/server/services/tutorials/tutorials_registry.mock.ts diff --git a/src/plugins/home/server/services/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts similarity index 95% rename from src/plugins/home/server/services/tutorials_registry.test.ts rename to src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index 04c26bab1f065..8144fef2d92e4 100644 --- a/src/plugins/home/server/services/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -18,16 +18,16 @@ */ import { TutorialsRegistry } from './tutorials_registry'; -import { coreMock } from '../../../../core/server/mocks'; -import { CoreSetup } from '../../../../core/server'; -import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { coreMock } from '../../../../../core/server/mocks'; +import { CoreSetup } from '../../../../../core/server'; +import { httpServerMock } from '../../../../../core/server/mocks'; import { TutorialProvider, TutorialSchema, TutorialsCategory, ScopedTutorialContextFactory, -} from '../lib/tutorials_registry_types'; +} from './lib/tutorials_registry_types'; const INVALID_TUTORIAL: TutorialSchema = { id: 'test', diff --git a/src/plugins/home/server/services/tutorials_registry.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.ts similarity index 96% rename from src/plugins/home/server/services/tutorials_registry.ts rename to src/plugins/home/server/services/tutorials/tutorials_registry.ts index 40692d8558656..be0302cbd8188 100644 --- a/src/plugins/home/server/services/tutorials_registry.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.ts @@ -23,8 +23,8 @@ import { TutorialProvider, TutorialContextFactory, ScopedTutorialContextFactory, -} from '../lib/tutorials_registry_types'; -import { tutorialSchema } from '../lib/tutorial_schema'; +} from './lib/tutorials_registry_types'; +import { tutorialSchema } from './lib/tutorial_schema'; export class TutorialsRegistry { private readonly tutorialProviders: TutorialProvider[] = []; // pre-register all the tutorials we know we want in here diff --git a/test/functional/apps/dashboard/embed_mode.js b/test/functional/apps/dashboard/embed_mode.js index 7122d9ff8ca25..9eb5b2c9352d8 100644 --- a/test/functional/apps/dashboard/embed_mode.js +++ b/test/functional/apps/dashboard/embed_mode.js @@ -25,6 +25,7 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['dashboard', 'common']); const browser = getService('browser'); + const globalNav = getService('globalNav'); describe('embed mode', () => { before(async () => { @@ -38,8 +39,8 @@ export default function ({ getService, getPageObjects }) { }); it('hides the chrome', async () => { - const isChromeVisible = await PageObjects.common.isChromeVisible(); - expect(isChromeVisible).to.be(true); + const globalNavShown = await globalNav.exists(); + expect(globalNavShown).to.be(true); const currentUrl = await browser.getCurrentUrl(); const newUrl = currentUrl + '&embed=true'; @@ -48,8 +49,8 @@ export default function ({ getService, getPageObjects }) { await browser.get(newUrl.toString(), useTimeStamp); await retry.try(async () => { - const isChromeHidden = await PageObjects.common.isChromeHidden(); - expect(isChromeHidden).to.be(true); + const globalNavHidden = !(await globalNav.exists()); + expect(globalNavHidden).to.be(true); }); }); diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index b30a0e50886d1..380c33e93ad90 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -38,6 +38,17 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { const coveragePrefix = 'coveragejson:'; const coverageDir = resolve(__dirname, '../../../../target/kibana-coverage/functional'); let logSubscription: undefined | Rx.Subscription; + type BrowserStorage = 'sessionStorage' | 'localStorage'; + + const clearBrowserStorage = async (storageType: BrowserStorage) => { + try { + await driver.executeScript(`window.${storageType}.clear();`); + } catch (error) { + if (!error.message.includes(`Failed to read the '${storageType}' property from 'Window'`)) { + throw error; + } + } + }; const { driver, By, until, consoleLog$ } = await initWebDriver( log, @@ -128,8 +139,8 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { .manage() .window() .setRect({ width, height }); - await driver.executeScript('window.sessionStorage.clear();'); - await driver.executeScript('window.localStorage.clear();'); + await clearBrowserStorage('sessionStorage'); + await clearBrowserStorage('localStorage'); }); lifecycle.on('cleanup', async () => { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index fbdf496ebaec4..c260a754e4594 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -32,7 +32,7 @@ interface ConstructorOptions { createAPIKey: () => Promise; } -interface FindOptions { +export interface FindOptions { options?: { perPage?: number; page?: number; @@ -40,6 +40,7 @@ interface FindOptions { defaultSearchOperator?: 'AND' | 'OR'; searchFields?: string[]; sortField?: string; + sortOrder?: string; hasReference?: { type: string; id: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js index dd95a0a3db12d..0e44bafa0c2a6 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js @@ -158,13 +158,9 @@ class StaticPlot extends PureComponent { }; render() { - const { width, series, tickFormatY, plotValues, noHits } = this.props; + const { series, tickFormatY, plotValues, noHits } = this.props; const { xTickValues, yTickValues } = plotValues; - // approximate number of x-axis ticks based on the width of the plot. There should by approx 1 tick per 100px - // d3 will determine the exact number of ticks based on the selected range - const xTickTotal = Math.floor(width / 100); - const tickFormatX = this.props.tickFormatX || this.tickFormatXTime; return ( @@ -172,7 +168,6 @@ class StaticPlot extends PureComponent { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts index dc815145db4ad..d99cb5cb9f1f1 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts @@ -8,7 +8,7 @@ import { getTimezoneOffsetInMs } from './getTimezoneOffsetInMs'; import moment from 'moment-timezone'; // FAILING: https://github.com/elastic/kibana/issues/50005 -describe.skip('getTimezoneOffsetInMs', () => { +describe('getTimezoneOffsetInMs', () => { describe('when no default timezone is set', () => { it('guesses the timezone', () => { const guess = jest.fn(() => 'Etc/UTC'); @@ -34,7 +34,11 @@ describe.skip('getTimezoneOffsetInMs', () => { }); it('returns the time in milliseconds', () => { - expect(getTimezoneOffsetInMs(Date.now())).toEqual(21600000); + const now = Date.now(); + // get the expected offset from moment to prevent any issues with DST + const expectedOffset = + moment.tz.zone('America/Denver')!.parse(now) * 60000; + expect(getTimezoneOffsetInMs(Date.now())).toEqual(expectedOffset); }); }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.js b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.js index 7923ae25c22a3..4186f6c899750 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.js +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.js @@ -75,11 +75,15 @@ export function getPlotValues( const yMaxNice = yScale.domain()[1]; const yTickValues = [0, yMaxNice / 2, yMaxNice]; + // approximate number of x-axis ticks based on the width of the plot. There should by approx 1 tick per 100px + // d3 will determine the exact number of ticks based on the selected range + const xTickTotal = Math.floor(width / 100); + const xTickValues = d3.time.scale .utc() .domain([xMinZone, xMaxZone]) .range([0, width]) - .ticks() + .ticks(xTickTotal) .map(x => { const time = x.getTime(); return new Date(time + getTimezoneOffsetInMs(time)); diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/__tests__/app.test.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/__tests__/app.test.tsx index 9cf2ddc3a22e3..2ec3cfde8bd68 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/__tests__/app.test.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/__tests__/app.test.tsx @@ -19,8 +19,6 @@ import { getScrubber as scrubber, getScrubberSlideContainer as scrubberContainer, getPageControlsCenter as center, - getSettingsTrigger as trigger, - getContextMenuItems as menuItems, // getAutoplayTextField as autoplayText, // getAutoplayCheckbox as autoplayCheck, // getAutoplaySubmit as autoplaySubmit, @@ -30,6 +28,7 @@ import { getPageControlsPrevious as previous, getPageControlsNext as next, } from '../../test/selectors'; +import { openSettings, selectMenuItem } from '../../test/interactions'; // Mock the renderers jest.mock('../../supported_renderers'); @@ -102,13 +101,9 @@ describe('', () => { test('autohide footer functions on mouseEnter + Leave', async () => { const wrapper = getWrapper(); - trigger(wrapper).simulate('click'); - await tick(20); - menuItems(wrapper) - .at(1) - .simulate('click'); - await tick(20); - wrapper.update(); + await openSettings(wrapper); + await selectMenuItem(wrapper, 1); + expect(footer(wrapper).prop('isHidden')).toEqual(false); expect(footer(wrapper).prop('isAutohide')).toEqual(false); toolbarCheck(wrapper).simulate('click'); @@ -125,13 +120,9 @@ describe('', () => { expect(scrubber(wrapper).prop('isScrubberVisible')).toEqual(true); // Open the menu and activate toolbar hiding. - trigger(wrapper).simulate('click'); - await tick(20); - menuItems(wrapper) - .at(1) - .simulate('click'); - await tick(20); - wrapper.update(); + await openSettings(wrapper); + await selectMenuItem(wrapper, 1); + toolbarCheck(wrapper).simulate('click'); await tick(20); diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/__snapshots__/settings.test.tsx.snap b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/__snapshots__/settings.test.tsx.snap index 670f31f018f71..367041dd1d5db 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/__snapshots__/settings.test.tsx.snap +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/__snapshots__/settings.test.tsx.snap @@ -56,26 +56,35 @@ exports[` can navigate Autoplay Settings 1`] = ` class="euiContextMenu__itemLayout" > + > + + Auto Play + > + + @@ -498,26 +516,35 @@ exports[` can navigate Toolbar Settings, closes when activated 1`] = class="euiContextMenu__itemLayout" > + > + + Auto Play + > + + @@ -566,6 +602,257 @@ exports[` can navigate Toolbar Settings, closes when activated 1`] = `; -exports[` can navigate Toolbar Settings, closes when activated 2`] = `"
Settings

Hide Toolbar

Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 2`] = ` +
+
+
+
+
+ +
+
+
+`; exports[` can navigate Toolbar Settings, closes when activated 3`] = `"
Settings

Hide Toolbar

Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/settings.test.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/settings.test.tsx index 0667674b6a7dd..66515eb3421d5 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/settings.test.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/__tests__/settings.test.tsx @@ -7,7 +7,8 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { JestContext } from '../../../../test/context_jest'; -import { takeMountedSnapshot, tick } from '../../../../test'; +import { takeMountedSnapshot } from '../../../../test'; +import { openSettings, selectMenuItem } from '../../../../test/interactions'; import { getSettingsTrigger as trigger, getPopover as popover, @@ -60,36 +61,26 @@ describe('', () => { expect(popover(wrapper).prop('isOpen')).toEqual(false); }); - test.skip('can navigate Autoplay Settings', async () => { - trigger(wrapper).simulate('click'); + test('can navigate Autoplay Settings', async () => { + await openSettings(wrapper); expect(takeMountedSnapshot(portal(wrapper))).toMatchSnapshot(); - await tick(20); - menuItems(wrapper) - .at(0) - .simulate('click'); - await tick(20); + + await selectMenuItem(wrapper, 0); expect(takeMountedSnapshot(portal(wrapper))).toMatchSnapshot(); }); - test.skip('can navigate Toolbar Settings, closes when activated', async () => { - trigger(wrapper).simulate('click'); + test('can navigate Toolbar Settings, closes when activated', async () => { + await openSettings(wrapper); expect(takeMountedSnapshot(portal(wrapper))).toMatchSnapshot(); - menuItems(wrapper) - .at(1) - .simulate('click'); - // Wait for the animation and DOM update - await tick(40); - portal(wrapper).update(); - expect(portal(wrapper).html()).toMatchSnapshot(); + await selectMenuItem(wrapper, 1); + expect(takeMountedSnapshot(portal(wrapper))).toMatchSnapshot(); // Click the Hide Toolbar switch portal(wrapper) .find('button[data-test-subj="hideToolbarSwitch"]') .simulate('click'); - // Wait for the animation and DOM update - await tick(20); portal(wrapper).update(); // The Portal should not be open. diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/test/interactions.ts b/x-pack/legacy/plugins/canvas/shareable_runtime/test/interactions.ts new file mode 100644 index 0000000000000..1c5b78929aaa5 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/test/interactions.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; +import { getSettingsTrigger, getPortal, getContextMenuItems } from './selectors'; +import { waitFor } from './utils'; + +export const openSettings = async function(wrapper: ReactWrapper) { + getSettingsTrigger(wrapper).simulate('click'); + + try { + // Wait for EuiPanel to be visible + await waitFor(() => { + wrapper.update(); + + return getPortal(wrapper) + .find('EuiPanel') + .exists(); + }); + } catch (e) { + throw new Error('Settings Panel did not open in given time'); + } +}; + +export const selectMenuItem = async function(wrapper: ReactWrapper, menuItemIndex: number) { + getContextMenuItems(wrapper) + .at(menuItemIndex) + .simulate('click'); + + try { + // When the menu item is clicked, wait for all of the context menus to be there + await waitFor(() => { + wrapper.update(); + return getPortal(wrapper).find('EuiContextMenuPanel').length === 2; + }); + } catch (e) { + throw new Error('Context menu did not transition'); + } +}; diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/test/utils.ts b/x-pack/legacy/plugins/canvas/shareable_runtime/test/utils.ts index 2e7bc4b262b52..4e18f2af1b06a 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/test/utils.ts +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/test/utils.ts @@ -6,7 +6,6 @@ import { ReactWrapper } from 'enzyme'; import { Component } from 'react'; -import { setTimeout } from 'timers'; export const tick = (ms = 0) => new Promise(resolve => { @@ -19,3 +18,25 @@ export const takeMountedSnapshot = (mountedComponent: ReactWrapper<{}, {}, Compo template.innerHTML = html; return template.content.firstChild; }; + +export const waitFor = (fn: () => boolean, stepMs = 100, failAfterMs = 1000) => { + return new Promise((resolve, reject) => { + let waitForTimeout: NodeJS.Timeout; + + const tryCondition = () => { + if (fn()) { + clearTimeout(failTimeout); + resolve(); + } else { + waitForTimeout = setTimeout(tryCondition, stepMs); + } + }; + + const failTimeout = setTimeout(() => { + clearTimeout(waitForTimeout); + reject('wait for condition was never met'); + }, failAfterMs); + + tryCondition(); + }); +}; diff --git a/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js b/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js index 5f7ac218e1b98..4fbba4a5ffd31 100644 --- a/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js +++ b/x-pack/legacy/plugins/dashboard_mode/public/dashboard_viewer.js @@ -38,6 +38,8 @@ import 'ui/agg_response'; import 'ui/agg_types'; import 'leaflet'; import { npStart } from 'ui/new_platform'; +import { localApplicationService } from 'plugins/kibana/local_application_service'; + import { showAppRedirectNotification } from 'ui/notify'; import { DashboardConstants, createDashboardEditUrl } from 'plugins/kibana/dashboard/dashboard_constants'; @@ -45,6 +47,8 @@ import { DashboardConstants, createDashboardEditUrl } from 'plugins/kibana/dashb uiModules.get('kibana') .config(dashboardConfigProvider => dashboardConfigProvider.turnHideWriteControlsOn()); +localApplicationService.attachToAngular(routes); + routes.enable(); routes.otherwise({ redirectTo: defaultUrl() }); diff --git a/x-pack/legacy/plugins/graph/public/index.ts b/x-pack/legacy/plugins/graph/public/index.ts index 48420d403653f..833134abff0b6 100644 --- a/x-pack/legacy/plugins/graph/public/index.ts +++ b/x-pack/legacy/plugins/graph/public/index.ts @@ -20,6 +20,7 @@ import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_regis import { npSetup, npStart } from 'ui/new_platform'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { start as data } from '../../../../../src/legacy/core_plugins/data/public/legacy'; +import { start as navigation } from '../../../../../src/legacy/core_plugins/navigation/public/legacy'; import { GraphPlugin } from './plugin'; // @ts-ignore @@ -53,6 +54,7 @@ async function getAngularInjectedDependencies(): Promise; + navigation: NavigationStart; } export interface GraphPluginSetupDependencies { @@ -30,6 +32,7 @@ export interface GraphPluginStartDependencies { export class GraphPlugin implements Plugin { private dataStart: DataStart | null = null; + private navigationStart: NavigationStart | null = null; private npDataStart: ReturnType | null = null; private savedObjectsClient: SavedObjectsClientContract | null = null; private angularDependencies: LegacyAngularInjectedDependencies | null = null; @@ -42,6 +45,7 @@ export class GraphPlugin implements Plugin { const { renderApp } = await import('./render_app'); return renderApp({ ...params, + navigation: this.navigationStart!, npData: this.npDataStart!, savedObjectsClient: this.savedObjectsClient!, xpackInfo, @@ -66,9 +70,9 @@ export class GraphPlugin implements Plugin { start( core: CoreStart, - { data, npData, __LEGACY: { angularDependencies } }: GraphPluginStartDependencies + { data, npData, navigation, __LEGACY: { angularDependencies } }: GraphPluginStartDependencies ) { - // TODO is this really the right way? I though the app context would give us those + this.navigationStart = navigation; this.dataStart = data; this.npDataStart = npData; this.angularDependencies = angularDependencies; diff --git a/x-pack/legacy/plugins/graph/public/render_app.ts b/x-pack/legacy/plugins/graph/public/render_app.ts index a8a86f4d1f850..18cdf0ddd81b2 100644 --- a/x-pack/legacy/plugins/graph/public/render_app.ts +++ b/x-pack/legacy/plugins/graph/public/render_app.ts @@ -25,6 +25,7 @@ import { DataStart } from 'src/legacy/core_plugins/data/public'; import { AppMountContext, ChromeStart, + LegacyCoreStart, SavedObjectsClientContract, ToastsStart, UiSettingsClientContract, @@ -32,6 +33,7 @@ import { // @ts-ignore import { initGraphApp } from './app'; import { Plugin as DataPlugin } from '../../../../../src/plugins/data/public'; +import { NavigationStart } from '../../../../../src/legacy/core_plugins/navigation/public'; /** * These are dependencies of the Graph app besides the base dependencies @@ -44,6 +46,7 @@ export interface GraphDependencies extends LegacyAngularInjectedDependencies { appBasePath: string; capabilities: Record>; coreStart: AppMountContext['core']; + navigation: NavigationStart; chrome: ChromeStart; config: UiSettingsClientContract; toastNotifications: ToastsStart; @@ -75,8 +78,8 @@ export interface LegacyAngularInjectedDependencies { } export const renderApp = ({ appBasePath, element, ...deps }: GraphDependencies) => { - const graphAngularModule = createLocalAngularModule(deps.coreStart); - configureAppAngularModule(graphAngularModule); + const graphAngularModule = createLocalAngularModule(deps.navigation); + configureAppAngularModule(graphAngularModule, deps.coreStart as LegacyCoreStart, true); initGraphApp(graphAngularModule, deps); const $injector = mountGraphApp(appBasePath, element); return () => $injector.get('$rootScope').$destroy(); @@ -104,9 +107,9 @@ function mountGraphApp(appBasePath: string, element: HTMLElement) { return $injector; } -function createLocalAngularModule(core: AppMountContext['core']) { +function createLocalAngularModule(navigation: NavigationStart) { createLocalI18nModule(); - createLocalTopNavModule(); + createLocalTopNavModule(navigation); createLocalConfirmModalModule(); const graphAngularModule = angular.module(moduleName, [ @@ -125,11 +128,11 @@ function createLocalConfirmModalModule() { .directive('confirmModal', reactDirective => reactDirective(EuiConfirmModal)); } -function createLocalTopNavModule() { +function createLocalTopNavModule(navigation: NavigationStart) { angular .module('graphTopNav', ['react']) .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper); + .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); } function createLocalI18nModule() { diff --git a/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.test.tsx index 1bd30bad818d1..988bb13841fa5 100644 --- a/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.test.tsx @@ -23,10 +23,7 @@ describe('Scroll to top', () => { Object.defineProperty(globalNode.window, 'scroll', { value: spyScroll }); mount( useScrollToTop()} />); - expect(spyScroll).toHaveBeenCalledWith({ - top: 0, - left: 0, - }); + expect(spyScroll).toHaveBeenCalledWith(0, 0); }); test('scrollTo have been called', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.tsx b/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.tsx index 59f9c99d6ab8e..8d4548516fc16 100644 --- a/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/scroll_to_top/index.tsx @@ -10,10 +10,7 @@ export const useScrollToTop = () => { useEffect(() => { // trying to use new API - https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo if (window.scroll) { - window.scroll({ - top: 0, - left: 0, - }); + window.scroll(0, 0); } else { // just a fallback for older browsers window.scrollTo(0, 0); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx index 56cb30e7e262f..6d76c277711d7 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/query_bar/index.tsx @@ -157,15 +157,24 @@ export const QueryBarTimeline = memo( let isSubscribed = true; async function setSavedQueryByServices() { if (savedQueryId != null && savedQueryServices != null) { - const mySavedQuery = await savedQueryServices.getSavedQuery(savedQueryId); - if (isSubscribed) { - setSavedQuery({ - ...mySavedQuery, - attributes: { - ...mySavedQuery.attributes, - filters: filters.filter(f => f.meta.controlledBy !== timelineFilterDropArea), - }, - }); + try { + // The getSavedQuery function will throw a promise rejection in + // src/legacy/core_plugins/data/public/search/search_bar/lib/saved_query_service.ts + // if the savedObjectsClient is undefined. This is happening in a test + // so I wrapped this in a try catch to keep the unhandled promise rejection + // warning from appearing in tests. + const mySavedQuery = await savedQueryServices.getSavedQuery(savedQueryId); + if (isSubscribed && mySavedQuery != null) { + setSavedQuery({ + ...mySavedQuery, + attributes: { + ...mySavedQuery.attributes, + filters: filters.filter(f => f.meta.controlledBy !== timelineFilterDropArea), + }, + }); + } + } catch (exc) { + setSavedQuery(null); } } else if (isSubscribed) { setSavedQuery(null); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.test.ts new file mode 100644 index 0000000000000..7873781fb05c4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFilter } from './find_signals'; +import { SIGNALS_ID } from '../../../../common/constants'; + +describe('find_signals', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${SIGNALS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${SIGNALS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts index 23f4e38a95eea..63e6a069c0cfe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts @@ -7,12 +7,31 @@ import { SIGNALS_ID } from '../../../../common/constants'; import { FindSignalParams } from './types'; -export const findSignals = async ({ alertsClient, perPage, page, fields }: FindSignalParams) => - alertsClient.find({ +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${SIGNALS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${SIGNALS_ID} AND ${filter}`; + } +}; + +export const findSignals = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindSignalParams) => { + return alertsClient.find({ options: { fields, page, perPage, - filter: `alert.attributes.alertTypeId: ${SIGNALS_ID}`, + filter: getFilter(filter), + sortOrder, + sortField, }, }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts index a6cb56ada8df1..723e2aad7fe6a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts @@ -70,7 +70,9 @@ export interface FindParamsRest { per_page: number; page: number; sort_field: string; + sort_order: 'asc' | 'desc'; fields: string[]; + filter: string; } export interface Clients { @@ -95,7 +97,9 @@ export interface FindSignalsRequest extends Omit { page: number; search?: string; sort_field?: string; + filter?: string; fields?: string[]; + sort_order?: 'asc' | 'desc'; }; } @@ -104,7 +108,9 @@ export interface FindSignalParams { perPage?: number; page?: number; sortField?: string; + filter?: string; fields?: string[]; + sortOrder?: 'asc' | 'desc'; } export interface ReadSignalParams { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.test.ts index a5e6d03a3378b..d8ba455445c0c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.test.ts @@ -293,6 +293,28 @@ describe('utils', () => { ); expect(result).toEqual(true); }); + test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => { + const sampleParams = sampleSignalAlertParams(10); + mockService.callCluster + .mockReturnValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + ], + }) + .mockReturnValueOnce(sampleEmptyDocSearchResults); + const result = await searchAfterAndBulkIndex( + repeatedSearchResultsWithSortId(4), + sampleParams, + mockService, + mockLogger, + sampleSignalId + ); + expect(result).toEqual(true); + }); test('if logs error when iteration is unsuccessful when bulk index results in a failure', async () => { const sampleParams = sampleSignalAlertParams(5); mockService.callCluster diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.ts index 2967f41ffb697..80530f9c2245f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/utils.ts @@ -157,7 +157,9 @@ export const searchAfterAndBulkIndex = async ( service, logger ); - sortIds = searchAfterResult.hits.hits[0].sort; + if (searchAfterResult.hits.hits.length === 0) { + return true; + } hitsSize += searchAfterResult.hits.hits.length; logger.debug(`size adjusted: ${hitsSize}`); sortIds = searchAfterResult.hits.hits[0].sort; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts index 18252c4f27fb0..120b71fab7d3a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts @@ -39,6 +39,8 @@ export const createFindSignalRoute: Hapi.ServerRoute = { perPage: query.per_page, page: query.page, sortField: query.sort_field, + sortOrder: query.sort_order, + filter: query.filter, }); return transformFindAlertsOrError(signals); }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.test.ts index ecb42399932f6..352d8d57cdb83 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.test.ts @@ -1446,6 +1446,8 @@ describe('schemas', () => { page: 1, sort_field: 'some field', fields: ['field 1', 'field 2'], + filter: 'some filter', + sort_order: 'asc', }).error ).toBeFalsy(); }); @@ -1505,6 +1507,68 @@ describe('schemas', () => { test('page has a default of 1', () => { expect(findSignalsSchema.validate>({}).value.page).toEqual(1); }); + + test('filter works with a string', () => { + expect( + findSignalsSchema.validate>({ + filter: 'some value 1', + }).error + ).toBeFalsy(); + }); + + test('filter does not work with a number', () => { + expect( + findSignalsSchema.validate> & { filter: number }>({ + filter: 5, + }).error + ).toBeTruthy(); + }); + + test('sort_order requires sort_field to work', () => { + expect( + findSignalsSchema.validate>({ + sort_order: 'asc', + }).error + ).toBeTruthy(); + }); + + // TODO: Delete this if not used + test.skip('sort_field requires sort_order to work', () => { + expect( + findSignalsSchema.validate>({ + sort_field: 'some field', + }).error + ).toBeTruthy(); + }); + + test('sort_order and sort_field validate together', () => { + expect( + findSignalsSchema.validate>({ + sort_order: 'asc', + sort_field: 'some field', + }).error + ).toBeFalsy(); + }); + + test('sort_order validates with desc and sort_field', () => { + expect( + findSignalsSchema.validate>({ + sort_order: 'desc', + sort_field: 'some field', + }).error + ).toBeFalsy(); + }); + + test('sort_order does not validate with a string other than asc and desc', () => { + expect( + findSignalsSchema.validate< + Partial> & { sort_order: string } + >({ + sort_order: 'some other string', + sort_field: 'some field', + }).error + ).toBeTruthy(); + }); }); describe('querySignalSchema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.ts index 596850b4a11e4..446fa7cb305b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas.ts @@ -28,6 +28,7 @@ const name = Joi.string(); const severity = Joi.string(); const to = Joi.string(); const type = Joi.string().valid('filter', 'query', 'saved_query'); +const queryFilter = Joi.string(); const references = Joi.array() .items(Joi.string()) .single(); @@ -38,6 +39,7 @@ const page = Joi.number() .min(1) .default(1); const sort_field = Joi.string(); +const sort_order = Joi.string().valid('asc', 'desc'); const tags = Joi.array().items(Joi.string()); const fields = Joi.array() .items(Joi.string()) @@ -113,8 +115,14 @@ export const querySignalSchema = Joi.object({ }).xor('id', 'rule_id'); export const findSignalsSchema = Joi.object({ + fields, + filter: queryFilter, per_page, page, - sort_field, - fields, + sort_field: Joi.when(Joi.ref('sort_order'), { + is: Joi.exist(), + then: sort_field.required(), + otherwise: sort_field.optional(), + }), + sort_order, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index 69f25e84d995c..fc6aefc72e33d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -25,6 +25,7 @@ describe('utils', () => { false_positives: [], from: 'now-6m', id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -51,6 +52,7 @@ describe('utils', () => { enabled: true, false_positives: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -78,6 +80,7 @@ describe('utils', () => { false_positives: [], from: 'now-6m', id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -105,6 +108,7 @@ describe('utils', () => { false_positives: [], from: 'now-6m', id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -131,6 +135,7 @@ describe('utils', () => { description: 'Detecting root and admin users', false_positives: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -145,6 +150,64 @@ describe('utils', () => { type: 'query', }); }); + + test('should return enabled is equal to false', () => { + const fullSignal = getResult(); + fullSignal.enabled = false; + const signalWithEnabledFalse = transformAlertToSignal(fullSignal); + expect(signalWithEnabledFalse).toEqual({ + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: false, + from: 'now-6m', + false_positives: [], + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + rule_id: 'rule-1', + max_signals: 100, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + severity: 'high', + size: 1, + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + }); + }); + + test('should return immutable is equal to false', () => { + const fullSignal = getResult(); + fullSignal.alertTypeParams.immutable = false; + const signalWithEnabledFalse = transformAlertToSignal(fullSignal); + expect(signalWithEnabledFalse).toEqual({ + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + from: 'now-6m', + false_positives: [], + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + rule_id: 'rule-1', + max_signals: 100, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + severity: 'high', + size: 1, + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + }); + }); }); describe('getIdError', () => { @@ -208,6 +271,7 @@ describe('utils', () => { false_positives: [], from: 'now-6m', id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', @@ -243,6 +307,7 @@ describe('utils', () => { false_positives: [], from: 'now-6m', id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], interval: '5m', rule_id: 'rule-1', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index 4d653210b2bff..fac30abd6992d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { pickBy, identity } from 'lodash/fp'; +import { pickBy } from 'lodash/fp'; import { SignalAlertType, isAlertType, OutputSignalAlertRest, isAlertTypes } from '../alerts/types'; export const getIdError = ({ @@ -27,7 +27,7 @@ export const getIdError = ({ // Transforms the data but will remove any null or undefined it encounters and not include // those on the export export const transformAlertToSignal = (signal: SignalAlertType): Partial => { - return pickBy(identity, { + return pickBy((value: unknown) => value != null, { created_by: signal.createdBy, description: signal.alertTypeParams.description, enabled: signal.enabled, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signal_by_filter.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signal_by_filter.sh new file mode 100755 index 0000000000000..6136f66025f3d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signal_by_filter.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +FILTER=${1:-'alert.attributes.enabled:%20true'} + +# Example: ./find_signal_by_filter.sh "alert.attributes.enabled:%20true" +# Example: ./find_signal_by_filter.sh "alert.attributes.name:%20Detect*" +# The %20 is just an encoded space that is typical of URL's. +# Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}/api/detection_engine/rules/_find?filter=$FILTER | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals_sort.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals_sort.sh new file mode 100755 index 0000000000000..3f8bab28544e3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals_sort.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +SORT=${1:-'enabled'} +ORDER=${2:-'asc'} + +# Example: ./find_signals_sort.sh enabled asc +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}/api/detection_engine/rules/_find?sort_field=$SORT&sort_order=$ORDER" \ + | jq . diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.test.ts b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts new file mode 100644 index 0000000000000..08657826a5567 --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; +import { PublicLicense } from './types'; +import { hasLicenseInfoChanged } from './has_license_info_changed'; + +function license({ error, ...customLicense }: { error?: string; [key: string]: any } = {}) { + const defaultLicense: PublicLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiryDateInMillis: 1000, + }; + + return new License({ + error, + license: Object.assign(defaultLicense, customLicense), + signature: 'aaaaaaa', + }); +} + +// Each test should ensure that left-to-right and right-to-left comparisons are captured. +describe('has license info changed', () => { + describe('License', () => { + test('undefined <-> License', async () => { + expect(hasLicenseInfoChanged(undefined, license())).toBe(true); + }); + + test('the same License', async () => { + const licenseInstance = license(); + expect(hasLicenseInfoChanged(licenseInstance, licenseInstance)).toBe(false); + }); + + test('type License <-> type License | mismatched type', async () => { + expect(hasLicenseInfoChanged(license({ type: 'basic' }), license({ type: 'gold' }))).toBe( + true + ); + expect(hasLicenseInfoChanged(license({ type: 'gold' }), license({ type: 'basic' }))).toBe( + true + ); + }); + + test('status License <-> status License | mismatched status', async () => { + expect( + hasLicenseInfoChanged(license({ status: 'active' }), license({ status: 'inactive' })) + ).toBe(true); + expect( + hasLicenseInfoChanged(license({ status: 'inactive' }), license({ status: 'active' })) + ).toBe(true); + }); + + test('expiry License <-> expiry License | mismatched expiry', async () => { + expect( + hasLicenseInfoChanged( + license({ expiryDateInMillis: 100 }), + license({ expiryDateInMillis: 200 }) + ) + ).toBe(true); + expect( + hasLicenseInfoChanged( + license({ expiryDateInMillis: 200 }), + license({ expiryDateInMillis: 100 }) + ) + ).toBe(true); + }); + }); + + describe('error License', () => { + test('License <-> error License', async () => { + expect(hasLicenseInfoChanged(license({ error: 'reason' }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ error: 'reason' }))).toBe(true); + }); + + test('error License <-> error License | matched messages', async () => { + expect( + hasLicenseInfoChanged(license({ error: 'reason-1' }), license({ error: 'reason-1' })) + ).toBe(false); + }); + + test('error License <-> error License | mismatched messages', async () => { + expect( + hasLicenseInfoChanged(license({ error: 'reason-1' }), license({ error: 'reason-2' })) + ).toBe(true); + expect( + hasLicenseInfoChanged(license({ error: 'reason-2' }), license({ error: 'reason-1' })) + ).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.ts b/x-pack/plugins/licensing/common/has_license_info_changed.ts new file mode 100644 index 0000000000000..2b64aa41f16bc --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from './types'; + +/** + * Check if 2 potential license instances have changes between them + * @internal + */ +export function hasLicenseInfoChanged(currentLicense: ILicense | undefined, newLicense: ILicense) { + if (currentLicense === newLicense) return false; + if (!currentLicense) return true; + + return ( + newLicense.error !== currentLicense.error || + newLicense.type !== currentLicense.type || + newLicense.status !== currentLicense.status || + newLicense.expiryDateInMillis !== currentLicense.expiryDateInMillis || + newLicense.isAvailable !== currentLicense.isAvailable + ); +} diff --git a/x-pack/plugins/licensing/common/license.mock.ts b/x-pack/plugins/licensing/common/license.mock.ts new file mode 100644 index 0000000000000..f04ebeec81bdf --- /dev/null +++ b/x-pack/plugins/licensing/common/license.mock.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PublicLicense, PublicFeatures } from './types'; +import { License } from './license'; + +function createLicense({ + license = {}, + features = {}, + signature = 'xxxxxxxxx', +}: { + license?: Partial; + features?: PublicFeatures; + signature?: string; +} = {}) { + const defaultLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiryDateInMillis: 5000, + }; + + const defaultFeatures = { + ccr: { + isEnabled: true, + isAvailable: true, + }, + ml: { + isEnabled: false, + isAvailable: true, + }, + }; + return new License({ + license: Object.assign(defaultLicense, license), + features: Object.assign(defaultFeatures, features), + signature, + }); +} + +export const licenseMock = { + create: createLicense, +}; diff --git a/x-pack/plugins/licensing/common/license.test.ts b/x-pack/plugins/licensing/common/license.test.ts new file mode 100644 index 0000000000000..6dbf009deabb7 --- /dev/null +++ b/x-pack/plugins/licensing/common/license.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; +import { LICENSE_CHECK_STATE } from './types'; +import { licenseMock } from './license.mock'; + +describe('License', () => { + const basicLicense = licenseMock.create(); + const basicExpiredLicense = licenseMock.create({ license: { status: 'expired' } }); + const goldLicense = licenseMock.create({ license: { type: 'gold' } }); + + const errorMessage = 'unavailable'; + const errorLicense = new License({ error: errorMessage, signature: '' }); + const unavailableLicense = new License({ signature: '' }); + + it('uid', () => { + expect(basicLicense.uid).toBe('uid-000000001234'); + expect(errorLicense.uid).toBeUndefined(); + expect(unavailableLicense.uid).toBeUndefined(); + }); + + it('status', () => { + expect(basicLicense.status).toBe('active'); + expect(errorLicense.status).toBeUndefined(); + expect(unavailableLicense.status).toBeUndefined(); + }); + + it('expiryDateInMillis', () => { + expect(basicLicense.expiryDateInMillis).toBe(5000); + expect(errorLicense.expiryDateInMillis).toBeUndefined(); + expect(unavailableLicense.expiryDateInMillis).toBeUndefined(); + }); + + it('type', () => { + expect(basicLicense.type).toBe('basic'); + expect(goldLicense.type).toBe('gold'); + expect(errorLicense.type).toBeUndefined(); + expect(unavailableLicense.type).toBeUndefined(); + }); + + it('isActive', () => { + expect(basicLicense.isActive).toBe(true); + expect(basicExpiredLicense.isActive).toBe(false); + expect(errorLicense.isActive).toBe(false); + expect(unavailableLicense.isActive).toBe(false); + }); + + it('isBasic', () => { + expect(basicLicense.isBasic).toBe(true); + expect(goldLicense.isBasic).toBe(false); + expect(errorLicense.isBasic).toBe(false); + expect(unavailableLicense.isBasic).toBe(false); + }); + + it('isNotBasic', () => { + expect(basicLicense.isNotBasic).toBe(false); + expect(goldLicense.isNotBasic).toBe(true); + expect(errorLicense.isNotBasic).toBe(false); + expect(unavailableLicense.isNotBasic).toBe(false); + }); + + it('isOneOf', () => { + expect(basicLicense.isOneOf('platinum')).toBe(false); + expect(basicLicense.isOneOf(['platinum'])).toBe(false); + expect(basicLicense.isOneOf(['gold', 'platinum'])).toBe(false); + expect(basicLicense.isOneOf(['platinum', 'gold'])).toBe(false); + expect(basicLicense.isOneOf(['basic', 'gold'])).toBe(true); + expect(basicLicense.isOneOf(['basic'])).toBe(true); + expect(basicLicense.isOneOf('basic')).toBe(true); + + expect(errorLicense.isOneOf(['basic', 'gold', 'platinum'])).toBe(false); + + expect(unavailableLicense.isOneOf(['basic', 'gold', 'platinum'])).toBe(false); + }); + + it('getUnavailableReason', () => { + expect(basicLicense.getUnavailableReason()).toBe(undefined); + expect(errorLicense.getUnavailableReason()).toBe(errorMessage); + expect(unavailableLicense.getUnavailableReason()).toBe( + 'X-Pack plugin is not installed on the Elasticsearch cluster.' + ); + }); + + it('getFeature provides feature info', () => { + expect(basicLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: true }); + expect(basicLicense.getFeature('unknown')).toEqual({ isEnabled: false, isAvailable: false }); + expect(errorLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: false }); + expect(unavailableLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: false }); + }); + + describe('check', () => { + it('provides availability status', () => { + expect(basicLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Invalid); + + expect(goldLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Valid); + expect(goldLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Valid); + + expect(basicExpiredLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Expired); + + expect(errorLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Unavailable); + expect(errorLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Unavailable); + + expect(unavailableLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Unavailable); + expect(unavailableLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Unavailable); + }); + + it('throws in case of unknown license type', () => { + expect( + () => basicLicense.check('ccr', 'any' as any).state + ).toThrowErrorMatchingInlineSnapshot(`"\\"any\\" is not a valid license type"`); + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/license.ts b/x-pack/plugins/licensing/common/license.ts new file mode 100644 index 0000000000000..b8327ac554107 --- /dev/null +++ b/x-pack/plugins/licensing/common/license.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + LicenseType, + ILicense, + LicenseStatus, + LICENSE_CHECK_STATE, + LICENSE_TYPE, + PublicLicenseJSON, + PublicLicense, + PublicFeatures, +} from './types'; + +/** + * @public + */ +export class License implements ILicense { + private readonly license?: PublicLicense; + private readonly features?: PublicFeatures; + + public readonly error?: string; + public readonly isActive: boolean; + public readonly isAvailable: boolean; + public readonly isBasic: boolean; + public readonly isNotBasic: boolean; + + public readonly uid?: string; + public readonly status?: LicenseStatus; + public readonly expiryDateInMillis?: number; + public readonly type?: LicenseType; + public readonly signature: string; + + /** + * @internal + * Generate a License instance from json representation. + */ + static fromJSON(json: PublicLicenseJSON) { + return new License(json); + } + + constructor({ + license, + features, + error, + signature, + }: { + license?: PublicLicense; + features?: PublicFeatures; + error?: string; + signature: string; + }) { + this.isAvailable = Boolean(license); + this.license = license; + this.features = features; + this.error = error; + this.signature = signature; + + if (license) { + this.uid = license.uid; + this.status = license.status; + this.expiryDateInMillis = license.expiryDateInMillis; + this.type = license.type; + } + + this.isActive = this.status === 'active'; + this.isBasic = this.isActive && this.type === 'basic'; + this.isNotBasic = this.isActive && this.type !== 'basic'; + } + + toJSON() { + return { + license: this.license, + features: this.features, + signature: this.signature, + }; + } + + getUnavailableReason() { + if (this.error) return this.error; + if (!this.isAvailable) { + return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; + } + } + + isOneOf(candidateLicenses: LicenseType | LicenseType[]) { + if (!this.type) { + return false; + } + + if (!Array.isArray(candidateLicenses)) { + candidateLicenses = [candidateLicenses]; + } + + return candidateLicenses.includes(this.type); + } + + check(pluginName: string, minimumLicenseRequired: LicenseType) { + if (!(minimumLicenseRequired in LICENSE_TYPE)) { + throw new Error(`"${minimumLicenseRequired}" is not a valid license type`); + } + + if (!this.isAvailable) { + return { + state: LICENSE_CHECK_STATE.Unavailable, + message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { + defaultMessage: + 'You cannot use {pluginName} because license information is not available at this time.', + values: { pluginName }, + }), + }; + } + + const type = this.type!; + + if (!this.isActive) { + return { + state: LICENSE_CHECK_STATE.Expired, + message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { + defaultMessage: + 'You cannot use {pluginName} because your {licenseType} license has expired.', + values: { licenseType: type, pluginName }, + }), + }; + } + + if (LICENSE_TYPE[type] < LICENSE_TYPE[minimumLicenseRequired]) { + return { + state: LICENSE_CHECK_STATE.Invalid, + message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { + defaultMessage: + 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', + values: { licenseType: type, pluginName }, + }), + }; + } + + return { state: LICENSE_CHECK_STATE.Valid }; + } + + getFeature(name: string) { + if (this.isAvailable && this.features && this.features.hasOwnProperty(name)) { + return { ...this.features[name] }; + } + + return { + isAvailable: false, + isEnabled: false, + }; + } +} diff --git a/x-pack/plugins/licensing/common/license_update.test.ts b/x-pack/plugins/licensing/common/license_update.test.ts new file mode 100644 index 0000000000000..345085d3e3a8f --- /dev/null +++ b/x-pack/plugins/licensing/common/license_update.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subject } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; + +import { ILicense, LicenseType } from './types'; +import { createLicenseUpdate } from './license_update'; +import { licenseMock } from './license.mock'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('licensing update', () => { + it('loads updates when triggered', async () => { + const types: LicenseType[] = ['basic', 'gold']; + + const trigger$ = new Subject(); + const fetcher = jest + .fn() + .mockImplementation(() => + Promise.resolve(licenseMock.create({ license: { type: types.shift() } })) + ); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + + expect(fetcher).toHaveBeenCalledTimes(0); + + trigger$.next(); + const first = await update$.pipe(take(1)).toPromise(); + expect(first.type).toBe('basic'); + + trigger$.next(); + const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + expect(second.type).toBe('gold'); + }); + + it('starts with initial value if presents', async () => { + const initialLicense = licenseMock.create({ license: { type: 'platinum' } }); + const fetchedLicense = licenseMock.create({ license: { type: 'gold' } }); + const trigger$ = new Subject(); + + const fetcher = jest.fn().mockResolvedValue(fetchedLicense); + const { update$ } = createLicenseUpdate(trigger$, fetcher, initialLicense); + trigger$.next(); + const [first, second] = await update$.pipe(take(2), toArray()).toPromise(); + + expect(first.type).toBe('platinum'); + expect(second.type).toBe('gold'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('does not emit if license has not changed', async () => { + const trigger$ = new Subject(); + + let i = 0; + const fetcher = jest + .fn() + .mockImplementation(() => + Promise.resolve( + ++i < 3 ? licenseMock.create() : licenseMock.create({ license: { type: 'gold' } }) + ) + ); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + trigger$.next(); + + const [first] = await update$.pipe(take(1), toArray()).toPromise(); + + expect(first.type).toBe('basic'); + + trigger$.next(); + trigger$.next(); + + const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + + expect(second.type).toBe('gold'); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('new subscriptions does not force re-fetch', async () => { + const trigger$ = new Subject(); + + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + + update$.subscribe(() => {}); + update$.subscribe(() => {}); + update$.subscribe(() => {}); + trigger$.next(); + + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('handles fetcher race condition', async () => { + const delayMs = 100; + let firstCall = true; + const fetcher = jest.fn().mockImplementation( + () => + new Promise(resolve => { + if (firstCall) { + firstCall = false; + setTimeout(() => resolve(licenseMock.create()), delayMs); + } else { + resolve(licenseMock.create({ license: { type: 'gold' } })); + } + }) + ); + const trigger$ = new Subject(); + const { update$ } = createLicenseUpdate(trigger$, fetcher); + const values: ILicense[] = []; + update$.subscribe(license => values.push(license)); + + trigger$.next(); + trigger$.next(); + + await delay(delayMs * 2); + + await expect(fetcher).toHaveBeenCalledTimes(2); + await expect(values).toHaveLength(1); + await expect(values[0].type).toBe('gold'); + }); + + it('completes update$ stream when trigger is completed', () => { + const trigger$ = new Subject(); + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + let completed = false; + update$.subscribe({ complete: () => (completed = true) }); + + trigger$.complete(); + expect(completed).toBe(true); + }); + + it('stops fetching when fetch subscription unsubscribed', () => { + const trigger$ = new Subject(); + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$, fetchSubscription } = createLicenseUpdate(trigger$, fetcher); + const values: ILicense[] = []; + update$.subscribe(license => values.push(license)); + + fetchSubscription.unsubscribe(); + trigger$.next(); + + expect(fetcher).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts new file mode 100644 index 0000000000000..254ea680460ee --- /dev/null +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ConnectableObservable, Observable, from, merge } from 'rxjs'; + +import { filter, map, pairwise, switchMap, publishReplay } from 'rxjs/operators'; +import { hasLicenseInfoChanged } from './has_license_info_changed'; +import { ILicense } from './types'; + +export function createLicenseUpdate( + trigger$: Observable, + fetcher: () => Promise, + initialValues?: ILicense +) { + const fetched$ = trigger$.pipe( + switchMap(fetcher), + publishReplay(1) + // have to cast manually as pipe operator cannot return ConnectableObservable + // https://github.com/ReactiveX/rxjs/issues/2972 + ) as ConnectableObservable; + + const fetchSubscription = fetched$.connect(); + + const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]); + + const update$: Observable = merge(initialValues$, fetched$).pipe( + pairwise(), + filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)), + map(([, next]) => next!) + ); + + return { + update$, + fetchSubscription, + }; +} diff --git a/x-pack/plugins/licensing/common/types.ts b/x-pack/plugins/licensing/common/types.ts new file mode 100644 index 0000000000000..c8edd8fd0cca8 --- /dev/null +++ b/x-pack/plugins/licensing/common/types.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable } from 'rxjs'; + +export enum LICENSE_CHECK_STATE { + Unavailable = 'UNAVAILABLE', + Invalid = 'INVALID', + Expired = 'EXPIRED', + Valid = 'VALID', +} + +export enum LICENSE_TYPE { + basic = 10, + standard = 20, + gold = 30, + platinum = 40, + trial = 50, +} + +/** @public */ +export type LicenseType = keyof typeof LICENSE_TYPE; + +/** @public */ +export type LicenseStatus = 'active' | 'invalid' | 'expired'; + +/** @public */ +export interface LicenseFeature { + isAvailable: boolean; + isEnabled: boolean; +} + +/** + * Subset of license data considered as non-sensitive information. + * Can be passed to the client. + * @public + * */ +export interface PublicLicense { + /** + * UID for license. + */ + uid: string; + + /** + * The validity status of the license. + */ + status: LicenseStatus; + + /** + * Unix epoch of the expiration date of the license. + */ + expiryDateInMillis: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type: LicenseType; +} + +/** + * Provides information about feature availability for the current license. + * @public + * */ +export type PublicFeatures = Record; + +/** + * Subset of license & features data considered as non-sensitive information. + * Structured as json to be passed to the client. + * @public + * */ +export interface PublicLicenseJSON { + license?: PublicLicense; + features?: PublicFeatures; + signature: string; +} + +/** + * @public + * Results from checking if a particular license type meets the minimum + * requirements of the license type. + */ +export interface LicenseCheck { + /** + * The state of checking the results of a license type meeting the license minimum. + */ + state: LICENSE_CHECK_STATE; + /** + * A message containing the reason for a license type not being valid. + */ + message?: string; +} + +/** @public */ +export interface ILicense { + /** + * UID for license. + */ + uid?: string; + + /** + * The validity status of the license. + */ + status?: LicenseStatus; + + /** + * Determine if the status of the license is active. + */ + isActive: boolean; + + /** + * Unix epoch of the expiration date of the license. + */ + expiryDateInMillis?: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type?: LicenseType; + + /** + * Signature of the license content. + */ + signature: string; + + /** + * Determine if the license container has information. + */ + isAvailable: boolean; + + /** + * Determine if the type of the license is basic, and also active. + */ + isBasic: boolean; + + /** + * Determine if the type of the license is not basic, and also active. + */ + isNotBasic: boolean; + + /** + * Returns + */ + toJSON: () => PublicLicenseJSON; + + /** + * A potential error denoting the failure of the license from being retrieved. + */ + error?: string; + + /** + * If the license is not available, provides a string or Error containing the reason. + */ + getUnavailableReason: () => string | undefined; + + /** + * Determine if the provided license types match against the license type. + * @param candidateLicenses license types to intersect against the license. + */ + isOneOf(candidateLicenses: LicenseType | LicenseType[]): boolean; + + /** + * For a given plugin and license type, receive information about the status of the license. + * @param pluginName the name of the plugin + * @param minimumLicenseRequired the minimum valid license for operating the given plugin + */ + check(pluginName: string, minimumLicenseRequired: LicenseType): LicenseCheck; + + /** + * A specific API for interacting with the specific features of the license. + * @param name the name of the feature to interact with + */ + getFeature(name: string): LicenseFeature; +} + +/** @public */ +export interface LicensingPluginSetup { + /** + * Steam of licensing information {@link ILicense}. + */ + license$: Observable; + /** + * Triggers licensing information re-fetch. + */ + refresh(): void; +} diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json index a76ce1ef6a23c..9edaa726c6ba9 100644 --- a/x-pack/plugins/licensing/kibana.json +++ b/x-pack/plugins/licensing/kibana.json @@ -2,7 +2,7 @@ "id": "licensing", "version": "0.0.1", "kibanaVersion": "kibana", - "configPath": ["x-pack", "licensing"], + "configPath": ["xpack", "licensing"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/licensing/public/index.ts b/x-pack/plugins/licensing/public/index.ts new file mode 100644 index 0000000000000..32e911bb2cdd2 --- /dev/null +++ b/x-pack/plugins/licensing/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { LicensingPlugin } from './plugin'; + +export * from '../common/types'; +export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); diff --git a/x-pack/plugins/licensing/public/plugin.test.ts b/x-pack/plugins/licensing/public/plugin.test.ts new file mode 100644 index 0000000000000..8ede881cad47e --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.test.ts @@ -0,0 +1,295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { take } from 'rxjs/operators'; + +import { LicenseType } from '../common/types'; +import { LicensingPlugin, licensingSessionStorageKey } from './plugin'; + +import { License } from '../common/license'; +import { licenseMock } from '../common/license.mock'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { HttpInterceptor } from 'src/core/public'; + +describe('licensing plugin', () => { + let plugin: LicensingPlugin; + + afterEach(async () => { + await plugin.stop(); + }); + + describe('#setup', () => { + describe('#refresh', () => { + it('forces data re-fetch', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + const fetchedLicense = licenseMock.create({ license: { uid: 'fetched' } }); + coreSetup.http.get.mockResolvedValue(fetchedLicense); + + const { license$, refresh } = await plugin.setup(coreSetup); + + refresh(); + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.uid).toBe('fetched'); + }); + }); + + describe('#license$', () => { + it('starts with license saved in sessionStorage if available', async () => { + const sessionStorage = coreMock.createStorage(); + const savedLicense = licenseMock.create({ license: { uid: 'saved' } }); + sessionStorage.getItem.mockReturnValue(JSON.stringify(savedLicense)); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + expect(license.uid).toBe('saved'); + + expect(sessionStorage.getItem).toBeCalledTimes(1); + expect(sessionStorage.getItem).toHaveBeenCalledWith(licensingSessionStorageKey); + }); + + it('observable receives updated licenses', async done => { + const types: LicenseType[] = ['gold', 'platinum']; + + const sessionStorage = coreMock.createStorage(); + sessionStorage.getItem.mockReturnValue(JSON.stringify(licenseMock.create())); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockImplementation(() => + Promise.resolve(licenseMock.create({ license: { type: types.shift() } })) + ); + const { license$, refresh } = await plugin.setup(coreSetup); + + let i = 0; + license$.subscribe(value => { + i++; + if (i === 1) { + expect(value.type).toBe('basic'); + refresh(); + } else if (i === 2) { + expect(value.type).toBe('gold'); + refresh(); + } else if (i === 3) { + expect(value.type).toBe('platinum'); + done(); + } else { + throw new Error('unreachable'); + } + }); + }); + + it('saved fetched license & signature in session storage', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + const fetchedLicense = licenseMock.create({ license: { uid: 'fresh' } }); + coreSetup.http.get.mockResolvedValue(fetchedLicense); + + const { license$, refresh } = await plugin.setup(coreSetup); + + refresh(); + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.uid).toBe('fresh'); + + expect(sessionStorage.setItem).toBeCalledTimes(1); + + expect(sessionStorage.setItem.mock.calls[0][0]).toBe(licensingSessionStorageKey); + expect(sessionStorage.setItem.mock.calls[0][1]).toMatchInlineSnapshot( + `"{\\"license\\":{\\"uid\\":\\"fresh\\",\\"status\\":\\"active\\",\\"type\\":\\"basic\\",\\"expiryDateInMillis\\":5000},\\"features\\":{\\"ccr\\":{\\"isEnabled\\":true,\\"isAvailable\\":true},\\"ml\\":{\\"isEnabled\\":false,\\"isAvailable\\":true}},\\"signature\\":\\"xxxxxxxxx\\"}"` + ); + + const saved = JSON.parse(sessionStorage.setItem.mock.calls[0][1]); + expect(License.fromJSON(saved).toJSON()).toEqual(fetchedLicense.toJSON()); + }); + + it('returns a license with error when request fails', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockRejectedValue(new Error('reason')); + + const { license$, refresh } = await plugin.setup(coreSetup); + refresh(); + + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.isAvailable).toBe(false); + expect(license.error).toBe('reason'); + }); + + it('remove license saved in session storage when request failed', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockRejectedValue(new Error('sorry')); + + const { license$, refresh } = await plugin.setup(coreSetup); + expect(sessionStorage.removeItem).toHaveBeenCalledTimes(0); + + refresh(); + await license$.pipe(take(1)).toPromise(); + + expect(sessionStorage.removeItem).toHaveBeenCalledTimes(1); + expect(sessionStorage.removeItem).toHaveBeenCalledWith(licensingSessionStorageKey); + }); + }); + }); + describe('interceptor', () => { + it('register http interceptor checking signature header', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + await plugin.setup(coreSetup); + expect(coreSetup.http.intercept).toHaveBeenCalledTimes(1); + }); + + it('http interceptor triggers re-fetch if signature header has changed', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + coreSetup.http.get.mockResolvedValue(licenseMock.create({ signature: 'signature-1' })); + + let registeredInterceptor: HttpInterceptor; + coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => { + registeredInterceptor = interceptor; + return () => undefined; + }); + + const { license$ } = await plugin.setup(coreSetup); + expect(registeredInterceptor!.response).toBeDefined(); + + const httpResponse = { + response: { + headers: { + get(name: string) { + if (name === 'kbn-xpack-sig') { + return 'signature-1'; + } + throw new Error('unexpected header'); + }, + }, + }, + request: { + url: 'http://10.10.10.10:5601/api/hello', + }, + }; + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(1); + + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(1); + }); + + it('http interceptor does not trigger re-fetch if requested x-pack/info endpoint', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + let registeredInterceptor: HttpInterceptor; + coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => { + registeredInterceptor = interceptor; + return () => undefined; + }); + + const { license$ } = await plugin.setup(coreSetup); + + let updated = false; + license$.subscribe(() => (updated = true)); + + expect(registeredInterceptor!.response).toBeDefined(); + + const httpResponse = { + response: { + headers: { + get(name: string) { + if (name === 'kbn-xpack-sig') { + return 'signature-1'; + } + throw new Error('unexpected header'); + }, + }, + }, + request: { + url: 'http://10.10.10.10:5601/api/xpack/v1/info', + }, + }; + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + expect(updated).toBe(false); + }); + }); + describe('#stop', () => { + it('stops polling', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + let completed = false; + license$.subscribe({ complete: () => (completed = true) }); + + await plugin.stop(); + expect(completed).toBe(true); + }); + + it('refresh does not trigger data re-fetch', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); + const { refresh } = await plugin.setup(coreSetup); + + await plugin.stop(); + + refresh(); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + }); + + it('removes http interceptor', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + const removeInterceptorMock = jest.fn(); + coreSetup.http.intercept.mockReturnValue(removeInterceptorMock); + + await plugin.setup(coreSetup); + await plugin.stop(); + + expect(removeInterceptorMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts new file mode 100644 index 0000000000000..c1b13418aa3e7 --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subject, Subscription, merge } from 'rxjs'; +import { takeUntil, tap } from 'rxjs/operators'; + +import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; + +import { ILicense, LicensingPluginSetup } from '../common/types'; +import { createLicenseUpdate } from '../common/license_update'; +import { License } from '../common/license'; + +export const licensingSessionStorageKey = 'xpack.licensing'; + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ +export class LicensingPlugin implements Plugin { + /** + * Used as a flag to halt all other plugin observables. + */ + private stop$ = new Subject(); + + /** + * A function to execute once the plugin's HTTP interceptor needs to stop listening. + */ + private removeInterceptor?: () => void; + private licenseFetchSubscription?: Subscription; + + private infoEndpoint = '/api/xpack/v1/info'; + private prevSignature?: string; + + constructor( + context: PluginInitializerContext, + private readonly storage: Storage = sessionStorage + ) {} + + /** + * Fetch the objectified license and signature from storage. + */ + private getSaved(): ILicense | undefined { + const raw = this.storage.getItem(licensingSessionStorageKey); + if (!raw) return; + return License.fromJSON(JSON.parse(raw)); + } + + /** + * Store the given license and signature in storage. + */ + private save(license: ILicense) { + this.storage.setItem(licensingSessionStorageKey, JSON.stringify(license)); + } + + /** + * Clear license and signature information from storage. + */ + private removeSaved() { + this.storage.removeItem(licensingSessionStorageKey); + } + + public setup(core: CoreSetup) { + const manualRefresh$ = new Subject(); + const signatureUpdated$ = new Subject(); + const refresh$ = merge(signatureUpdated$, manualRefresh$).pipe(takeUntil(this.stop$)); + + const savedLicense = this.getSaved(); + const { update$, fetchSubscription } = createLicenseUpdate( + refresh$, + () => this.fetchLicense(core), + savedLicense + ); + this.licenseFetchSubscription = fetchSubscription; + + const license$ = update$.pipe( + tap(license => { + if (license.error) { + this.prevSignature = undefined; + // Prevent reusing stale license if the fetch operation fails + this.removeSaved(); + } else { + this.prevSignature = license.signature; + this.save(license); + } + }) + ); + + this.removeInterceptor = core.http.intercept({ + response: async httpResponse => { + if (httpResponse.response) { + const signatureHeader = httpResponse.response.headers.get('kbn-xpack-sig'); + if (this.prevSignature !== signatureHeader) { + if (!httpResponse.request!.url.includes(this.infoEndpoint)) { + signatureUpdated$.next(); + } + } + } + return httpResponse; + }, + }); + + return { + refresh: () => { + manualRefresh$.next(); + }, + license$, + }; + } + + public async start() {} + + public stop() { + this.stop$.next(); + this.stop$.complete(); + + if (this.removeInterceptor !== undefined) { + this.removeInterceptor(); + } + if (this.licenseFetchSubscription !== undefined) { + this.licenseFetchSubscription.unsubscribe(); + this.licenseFetchSubscription = undefined; + } + } + + private fetchLicense = async (core: CoreSetup): Promise => { + try { + const response = await core.http.get(this.infoEndpoint); + return new License({ + license: response.license, + features: response.features, + signature: response.signature, + }); + } catch (error) { + return new License({ error: error.message, signature: '' }); + } + }; +} diff --git a/x-pack/plugins/licensing/server/__fixtures__/setup.ts b/x-pack/plugins/licensing/server/__fixtures__/setup.ts deleted file mode 100644 index 02574d0851ba0..0000000000000 --- a/x-pack/plugins/licensing/server/__fixtures__/setup.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { take, skip } from 'rxjs/operators'; -import { merge } from 'lodash'; -import { ClusterClient } from 'src/core/server'; -import { coreMock } from '../../../../../src/core/server/mocks'; -import { Plugin } from '../plugin'; -import { schema } from '../schema'; - -export async function licenseMerge(xpackInfo = {}) { - return merge( - { - license: { - uid: '00000000-0000-0000-0000-000000000000', - type: 'basic', - mode: 'basic', - status: 'active', - }, - features: { - ccr: { - available: false, - enabled: true, - }, - data_frame: { - available: true, - enabled: true, - }, - graph: { - available: false, - enabled: true, - }, - ilm: { - available: true, - enabled: true, - }, - logstash: { - available: false, - enabled: true, - }, - ml: { - available: false, - enabled: true, - }, - monitoring: { - available: true, - enabled: true, - }, - rollup: { - available: true, - enabled: true, - }, - security: { - available: true, - enabled: true, - }, - sql: { - available: true, - enabled: true, - }, - vectors: { - available: true, - enabled: true, - }, - voting_only: { - available: true, - enabled: true, - }, - watcher: { - available: false, - enabled: true, - }, - }, - }, - xpackInfo - ); -} - -export async function setupOnly(pluginInitializerContext: any = {}) { - const coreSetup = coreMock.createSetup(); - const clusterClient = ((await coreSetup.elasticsearch.dataClient$ - .pipe(take(1)) - .toPromise()) as unknown) as jest.Mocked>; - const plugin = new Plugin( - coreMock.createPluginInitializerContext({ - config: schema.validate(pluginInitializerContext.config || {}), - }) - ); - - return { coreSetup, plugin, clusterClient }; -} - -export async function setup(xpackInfo = {}, pluginInitializerContext: any = {}) { - const { coreSetup, clusterClient, plugin } = await setupOnly(pluginInitializerContext); - - clusterClient.callAsInternalUser.mockResolvedValueOnce(licenseMerge(xpackInfo)); - - const { license$ } = await plugin.setup(coreSetup); - const license = await license$.pipe(skip(1), take(1)).toPromise(); - - return { - plugin, - license$, - license, - clusterClient, - }; -} diff --git a/x-pack/plugins/licensing/server/constants.ts b/x-pack/plugins/licensing/server/constants.ts deleted file mode 100644 index f2823ea00933c..0000000000000 --- a/x-pack/plugins/licensing/server/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const SERVICE_NAME = 'licensing'; -export const DEFAULT_POLLING_FREQUENCY = 30001; // 30 seconds -export enum LICENSE_STATUS { - Unavailable = 'UNAVAILABLE', - Invalid = 'INVALID', - Expired = 'EXPIRED', - Valid = 'VALID', -} -export enum LICENSE_TYPE { - basic = 10, - standard = 20, - gold = 30, - platinum = 40, - trial = 50, -} diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index 49415b63bc3b7..fff9ccc296ce3 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -5,9 +5,9 @@ */ import { PluginInitializerContext } from 'src/core/server'; -import { schema } from './schema'; -import { Plugin } from './plugin'; +import { LicensingPlugin } from './plugin'; -export * from './types'; -export const config = { schema }; -export const plugin = (context: PluginInitializerContext) => new Plugin(context); +export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); + +export * from '../common/types'; +export { config } from './licensing_config'; diff --git a/x-pack/plugins/licensing/server/license.test.ts b/x-pack/plugins/licensing/server/license.test.ts deleted file mode 100644 index 1c308a6280449..0000000000000 --- a/x-pack/plugins/licensing/server/license.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { LICENSE_STATUS } from './constants'; -import { LicenseFeature } from './license_feature'; -import { setup } from './__fixtures__/setup'; - -describe('license', () => { - let plugin: Plugin; - let license: ILicense; - - afterEach(async () => { - await plugin.stop(); - }); - - test('uid returns a UID field', async () => { - ({ plugin, license } = await setup()); - - expect(license.uid).toBe('00000000-0000-0000-0000-000000000000'); - }); - - test('isActive returns true if status is active', async () => { - ({ plugin, license } = await setup()); - - expect(license.isActive).toBe(true); - }); - - test('isActive returns false if status is not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'aCtIvE', // needs to match exactly - }, - })); - - expect(license.isActive).toBe(false); - }); - - test('expiryDateInMillis returns expiry_date_in_millis', async () => { - const expiry = Date.now(); - - ({ plugin, license } = await setup({ - license: { - expiry_date_in_millis: expiry, - }, - })); - - expect(license.expiryDateInMillis).toBe(expiry); - }); - - test('isOneOf returns true if the type includes one of the license types', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'platinum', - }, - })); - - expect(license.isOneOf('platinum')).toBe(true); - expect(license.isOneOf(['platinum'])).toBe(true); - expect(license.isOneOf(['gold', 'platinum'])).toBe(true); - expect(license.isOneOf(['platinum', 'gold'])).toBe(true); - expect(license.isOneOf(['basic', 'gold'])).toBe(false); - expect(license.isOneOf(['basic'])).toBe(false); - }); - - test('type returns the license type', async () => { - ({ plugin, license } = await setup()); - - expect(license.type).toBe('basic'); - }); - - test('returns feature API with getFeature', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - const fake = license.getFeature('fake'); - - expect(security).toBeInstanceOf(LicenseFeature); - expect(fake).toBeInstanceOf(LicenseFeature); - }); - - describe('isActive', () => { - test('should return Valid if active and check matches', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.check('test', 'basic').check).toBe(LICENSE_STATUS.Valid); - expect(license.check('test', 'gold').check).toBe(LICENSE_STATUS.Valid); - }); - - test('should return Invalid if active and check does not match', async () => { - ({ plugin, license } = await setup()); - - const { check } = license.check('test', 'gold'); - - expect(check).toBe(LICENSE_STATUS.Invalid); - }); - - test('should return Unavailable if missing license', async () => { - ({ plugin, license } = await setup({ license: null })); - - const { check } = license.check('test', 'gold'); - - expect(check).toBe(LICENSE_STATUS.Unavailable); - }); - - test('should return Expired if not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - const { check } = license.check('test', 'basic'); - - expect(check).toBe(LICENSE_STATUS.Expired); - }); - }); - - describe('basic', () => { - test('isBasic is true if active and basic', async () => { - ({ plugin, license } = await setup()); - - expect(license.isBasic).toBe(true); - }); - - test('isBasic is false if active and not basic', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.isBasic).toBe(false); - }); - - test('isBasic is false if not active and basic', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - expect(license.isBasic).toBe(false); - }); - - test('isNotBasic is false if not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - expect(license.isNotBasic).toBe(false); - }); - - test('isNotBasic is true if active and not basic', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.isNotBasic).toBe(true); - }); - - test('isNotBasic is false if active and basic', async () => { - ({ plugin, license } = await setup()); - - expect(license.isNotBasic).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/licensing/server/license.ts b/x-pack/plugins/licensing/server/license.ts deleted file mode 100644 index 4d2d1d3fb41ba..0000000000000 --- a/x-pack/plugins/licensing/server/license.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { createHash } from 'crypto'; -import { LicenseFeature } from './license_feature'; -import { LICENSE_STATUS, LICENSE_TYPE } from './constants'; -import { LicenseType, ILicense } from './types'; - -function toLicenseType(minimumLicenseRequired: LICENSE_TYPE | string) { - if (typeof minimumLicenseRequired !== 'string') { - return minimumLicenseRequired; - } - - if (!(minimumLicenseRequired in LICENSE_TYPE)) { - throw new Error(`${minimumLicenseRequired} is not a valid license type`); - } - - return LICENSE_TYPE[minimumLicenseRequired as LicenseType]; -} - -export class License implements ILicense { - private readonly hasLicense: boolean; - private readonly license: any; - private readonly features: any; - private _signature!: string; - private objectified!: any; - private readonly featuresMap: Map; - - constructor( - license: any, - features: any, - private error: Error | null, - private clusterSource: string - ) { - this.hasLicense = Boolean(license); - this.license = license || {}; - this.features = features; - this.featuresMap = new Map(); - } - - public get uid() { - return this.license.uid; - } - - public get status() { - return this.license.status; - } - - public get isActive() { - return this.status === 'active'; - } - - public get expiryDateInMillis() { - return this.license.expiry_date_in_millis; - } - - public get type() { - return this.license.type; - } - - public get isAvailable() { - return this.hasLicense; - } - - public get isBasic() { - return this.isActive && this.type === 'basic'; - } - - public get isNotBasic() { - return this.isActive && this.type !== 'basic'; - } - - public get reasonUnavailable() { - if (!this.isAvailable) { - return `[${this.clusterSource}] Elasticsearch cluster did not respond with license information.`; - } - - if (this.error instanceof Error && (this.error as any).status === 400) { - return `X-Pack plugin is not installed on the [${this.clusterSource}] Elasticsearch cluster.`; - } - - return this.error; - } - - public get signature() { - if (this._signature !== undefined) { - return this._signature; - } - - this._signature = createHash('md5') - .update(JSON.stringify(this.toObject())) - .digest('hex'); - - return this._signature; - } - - isOneOf(candidateLicenses: string | string[]) { - if (!Array.isArray(candidateLicenses)) { - candidateLicenses = [candidateLicenses]; - } - - return candidateLicenses.includes(this.type); - } - - meetsMinimumOf(minimum: LICENSE_TYPE) { - return LICENSE_TYPE[this.type as LicenseType] >= minimum; - } - - check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string) { - const minimum = toLicenseType(minimumLicenseRequired); - - if (!this.isAvailable) { - return { - check: LICENSE_STATUS.Unavailable, - message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { - defaultMessage: - 'You cannot use {pluginName} because license information is not available at this time.', - values: { pluginName }, - }), - }; - } - - const { type: licenseType } = this.license; - - if (!this.meetsMinimumOf(minimum)) { - return { - check: LICENSE_STATUS.Invalid, - message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { - defaultMessage: - 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', - values: { licenseType, pluginName }, - }), - }; - } - - if (!this.isActive) { - return { - check: LICENSE_STATUS.Expired, - message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { - defaultMessage: - 'You cannot use {pluginName} because your {licenseType} license has expired.', - values: { licenseType, pluginName }, - }), - }; - } - - return { check: LICENSE_STATUS.Valid }; - } - - toObject() { - if (this.objectified) { - return this.objectified; - } - - this.objectified = { - license: { - type: this.type, - isActive: this.isActive, - expiryDateInMillis: this.expiryDateInMillis, - }, - features: [...this.featuresMap].map(([, feature]) => feature.toObject()), - }; - - return this.objectified; - } - - getFeature(name: string) { - if (!this.featuresMap.has(name)) { - this.featuresMap.set(name, new LicenseFeature(name, this.features[name], this)); - } - - return this.featuresMap.get(name); - } -} diff --git a/x-pack/plugins/licensing/server/license_feature.test.ts b/x-pack/plugins/licensing/server/license_feature.test.ts deleted file mode 100644 index d36fa2cca48ba..0000000000000 --- a/x-pack/plugins/licensing/server/license_feature.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { setup } from './__fixtures__/setup'; - -describe('licensing feature', () => { - let plugin: Plugin; - let license: ILicense; - - afterEach(async () => { - await plugin.stop(); - }); - - test('isAvailable', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.isAvailable).toBe(true); - }); - - test('isEnabled', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.isEnabled).toBe(true); - }); - - test('name', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.name).toBe('security'); - }); -}); diff --git a/x-pack/plugins/licensing/server/license_feature.ts b/x-pack/plugins/licensing/server/license_feature.ts deleted file mode 100644 index 58c5b81e7af74..0000000000000 --- a/x-pack/plugins/licensing/server/license_feature.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { License } from './license'; -import { LicenseFeatureSerializer } from './types'; - -export class LicenseFeature { - private serializable: LicenseFeatureSerializer = license => ({ - name: this.name, - isAvailable: this.isAvailable, - isEnabled: this.isEnabled, - }); - - constructor(public name: string, private feature: any = {}, private license: License) {} - - public get isAvailable() { - return !!this.feature.available; - } - - public get isEnabled() { - return !!this.feature.enabled; - } - - public onObject(serializable: LicenseFeatureSerializer) { - this.serializable = serializable; - } - - public toObject() { - return this.serializable(this.license); - } -} diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index a5fd3d0a7b046..7be19398828e9 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -4,20 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { LicensingConfigType } from './types'; +import { schema, TypeOf } from '@kbn/config-schema'; -export class LicensingConfig { - public isEnabled: boolean; - public clusterSource: string; - public pollingFrequency: number; +const SECOND = 1000; +export const config = { + schema: schema.object({ + pollingFrequency: schema.number({ defaultValue: 30 * SECOND }), + }), +}; - /** - * @internal - */ - constructor(rawConfig: LicensingConfigType, env: PluginInitializerContext['env']) { - this.isEnabled = rawConfig.isEnabled; - this.clusterSource = rawConfig.clusterSource; - this.pollingFrequency = rawConfig.pollingFrequency; - } -} +export type LicenseConfigType = TypeOf; diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts index 81ad9715da784..82af786482d58 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts @@ -5,32 +5,23 @@ */ import { BehaviorSubject } from 'rxjs'; -import { ILicense } from './types'; -import { setup } from './__fixtures__/setup'; -import { createRouteHandlerContext } from './licensing_route_handler_context'; - -describe('licensingRouteHandlerContext', () => { - it('provides the initial license value', async () => { - const { license$, license } = await setup(); - - const context = createRouteHandlerContext(license$); - - const { license: contextResult } = await context({}, {} as any, {} as any); +import { licenseMock } from '../common/license.mock'; - expect(contextResult).toBe(license); - }); - - it('provides the latest license value', async () => { - const { license } = await setup(); - const license$ = new BehaviorSubject(license); +import { createRouteHandlerContext } from './licensing_route_handler_context'; - const context = createRouteHandlerContext(license$); +describe('createRouteHandlerContext', () => { + it('returns a function providing the last license value', async () => { + const firstLicense = licenseMock.create(); + const secondLicense = licenseMock.create(); + const license$ = new BehaviorSubject(firstLicense); - const latestLicense = (Symbol() as unknown) as ILicense; - license$.next(latestLicense); + const routeHandler = createRouteHandlerContext(license$); - const { license: contextResult } = await context({}, {} as any, {} as any); + const firstCtx = await routeHandler({}, {} as any, {} as any); + license$.next(secondLicense); + const secondCtx = await routeHandler({}, {} as any, {} as any); - expect(contextResult).toBe(latestLicense); + expect(firstCtx.license).toBe(firstLicense); + expect(secondCtx.license).toBe(secondLicense); }); }); diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts index 8ee49e9aa084f..42cb0959fc373 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts @@ -7,13 +7,18 @@ import { IContextProvider, RequestHandler } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { ILicense } from './types'; +import { ILicense } from '../common/types'; + +/** + * Create a route handler context for access to Kibana license information. + * @param license$ An observable of a License instance. + * @public + */ export function createRouteHandlerContext( license$: Observable ): IContextProvider, 'licensing'> { return async function licensingRouteHandlerContext() { - const license = await license$.pipe(take(1)).toPromise(); - return { license }; + return { license: await license$.pipe(take(1)).toPromise() }; }; } diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index a85e1fb0e8f8f..2af3637a2aaf0 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -4,92 +4,296 @@ * you may not use this file except in compliance with the Elastic License. */ -import { take, skip } from 'rxjs/operators'; -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { License } from './license'; -import { setup, setupOnly, licenseMerge } from './__fixtures__/setup'; +import { BehaviorSubject } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; +import { LicenseType } from '../common/types'; +import { ElasticsearchError, RawLicense } from './types'; +import { LicensingPlugin } from './plugin'; +import { + coreMock, + elasticsearchServiceMock, + loggingServiceMock, +} from '../../../../src/core/server/mocks'; + +function buildRawLicense(options: Partial = {}): RawLicense { + const defaultRawLicense: RawLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiry_date_in_millis: 1000, + }; + return Object.assign(defaultRawLicense, options); +} +const pollingFrequency = 100; + +const flushPromises = (ms = 50) => new Promise(res => setTimeout(res, ms)); describe('licensing plugin', () => { - let plugin: Plugin; - let license: ILicense; + describe('#setup', () => { + describe('#license$', () => { + let plugin: LicensingPlugin; + let pluginInitContextMock: ReturnType; - afterEach(async () => { - await plugin.stop(); - }); + beforeEach(() => { + pluginInitContextMock = coreMock.createPluginInitializerContext({ + pollingFrequency, + }); + plugin = new LicensingPlugin(pluginInitContextMock); + }); - test('returns instance of licensing setup', async () => { - ({ plugin, license } = await setup()); - expect(license).toBeInstanceOf(License); - }); + afterEach(async () => { + await plugin.stop(); + }); - test('still returns instance of licensing setup when request fails', async () => { - const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly(); + it('returns license', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - plugin = _plugin; - clusterClient.callAsInternalUser.mockRejectedValue(new Error('test')); + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + }); - const { license$ } = await plugin.setup(coreSetup); - const finalLicense = await license$.pipe(skip(1), take(1)).toPromise(); + it('observable receives updated licenses', async () => { + const types: LicenseType[] = ['basic', 'gold', 'platinum']; - expect(finalLicense).toBeInstanceOf(License); - }); + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + license: buildRawLicense({ type: types.shift() }), + features: {}, + }) + ); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - test('observable receives updated licenses', async () => { - const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly({ - config: { - pollingFrequency: 100, - }, - }); - const types = ['basic', 'gold', 'platinum']; - let iterations = 0; - - plugin = _plugin; - clusterClient.callAsInternalUser.mockImplementation(() => { - return Promise.resolve( - licenseMerge({ - license: { - type: types[iterations++], - }, - }) - ); + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.type).toBe('basic'); + expect(second.type).toBe('gold'); + expect(third.type).toBe('platinum'); + }); + + it('returns a license with error when request fails', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockRejectedValue(new Error('test')); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(false); + expect(license.error).toBeDefined(); + }); + + it('generate error message when x-pack plugin was not installed', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + const error: ElasticsearchError = new Error('reason'); + error.status = 400; + dataClient.callAsInternalUser.mockRejectedValue(error); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(false); + expect(license.error).toBe('X-Pack plugin is not installed on the Elasticsearch cluster.'); + }); + + it('polling continues even if there are errors', async () => { + const error1 = new Error('reason-1'); + const error2 = new Error('reason-2'); + + const dataClient = elasticsearchServiceMock.createClusterClient(); + + dataClient.callAsInternalUser + .mockRejectedValueOnce(error1) + .mockRejectedValueOnce(error2) + .mockResolvedValue({ license: buildRawLicense(), features: {} }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.error).toBe(error1.message); + expect(second.error).toBe(error2.message); + expect(third.type).toBe('basic'); + }); + + it('fetch license immediately without subscriptions', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + await plugin.setup(coreSetup); + await flushPromises(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(1); + }); + + it('logs license details without subscriptions', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + await plugin.setup(coreSetup); + await flushPromises(); + + const loggedMessages = loggingServiceMock.collect(pluginInitContextMock.logger).debug; + + expect( + loggedMessages.some(([message]) => + message.startsWith( + 'Imported license information from Elasticsearch:type: basic | status: active | expiry date:' + ) + ) + ).toBe(true); + }); + + it('generates signature based on fetched license content', async () => { + const types: LicenseType[] = ['basic', 'gold', 'basic']; + + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + license: buildRawLicense({ type: types.shift() }), + features: {}, + }) + ); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.signature === third.signature).toBe(true); + expect(first.signature === second.signature).toBe(false); + }); }); - const { license$ } = await plugin.setup(coreSetup); - const licenseTypes: any[] = []; + describe('#refresh', () => { + let plugin: LicensingPlugin; + afterEach(async () => { + await plugin.stop(); + }); - await new Promise(resolve => { - const subscription = license$.subscribe(next => { - if (!next.type) { - return; - } + it('forces refresh immediately', async () => { + plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + // disable polling mechanism + pollingFrequency: 50000, + }) + ); + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + const { refresh } = await plugin.setup(coreSetup); - if (iterations > 3) { - subscription.unsubscribe(); - resolve(); - } else { - licenseTypes.push(next.type); - } + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0); + + refresh(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(1); + + refresh(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(2); }); }); - expect(licenseTypes).toEqual(['basic', 'gold', 'platinum']); + describe('extends core contexts', () => { + let plugin: LicensingPlugin; + + beforeEach(() => { + plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + }); + + afterEach(async () => { + await plugin.stop(); + }); + + it('provides a licensing context to http routes', async () => { + const coreSetup = coreMock.createSetup(); + + await plugin.setup(coreSetup); + + expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "licensing", + [Function], + ], + ] + `); + }); + }); }); - test('provides a licensing context to http routes', async () => { - const { coreSetup, plugin: _plugin } = await setupOnly(); + describe('#stop', () => { + it('stops polling', async () => { + const plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + let completed = false; + license$.subscribe({ complete: () => (completed = true) }); + + await plugin.stop(); + expect(completed).toBe(true); + }); + + it('refresh does not trigger data re-fetch', async () => { + const plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - plugin = _plugin; + const { refresh } = await plugin.setup(coreSetup); - await plugin.setup(coreSetup); + dataClient.callAsInternalUser.mockClear(); - expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "licensing", - [Function], - ], - ] - `); + await plugin.stop(); + refresh(); + + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 4cd40379b8592..3c93b55723787 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -4,145 +4,186 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { Observable, Subject, Subscription, merge, timer } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; import moment from 'moment'; +import { createHash } from 'crypto'; +import stringify from 'json-stable-stringify'; + import { CoreSetup, CoreStart, Logger, - Plugin as CorePlugin, + Plugin, PluginInitializerContext, + IClusterClient, } from 'src/core/server'; -import { Poller } from '../../../../src/core/utils/poller'; -import { LicensingConfigType, LicensingPluginSetup, ILicense } from './types'; -import { LicensingConfig } from './licensing_config'; -import { License } from './license'; + +import { ILicense, LicensingPluginSetup, PublicLicense, PublicFeatures } from '../common/types'; +import { License } from '../common/license'; +import { createLicenseUpdate } from '../common/license_update'; + +import { ElasticsearchError, RawLicense, RawFeatures } from './types'; +import { LicenseConfigType } from './licensing_config'; import { createRouteHandlerContext } from './licensing_route_handler_context'; -declare module 'src/core/server' { - interface RequestHandlerContext { - licensing: { - license: ILicense; +function normalizeServerLicense(license: RawLicense): PublicLicense { + return { + uid: license.uid, + type: license.type, + expiryDateInMillis: license.expiry_date_in_millis, + status: license.status, + }; +} + +function normalizeFeatures(rawFeatures: RawFeatures) { + const features: PublicFeatures = {}; + for (const [name, feature] of Object.entries(rawFeatures)) { + features[name] = { + isAvailable: feature.available, + isEnabled: feature.enabled, }; } + return features; } -export class Plugin implements CorePlugin { +function sign({ + license, + features, + error, +}: { + license?: PublicLicense; + features?: PublicFeatures; + error?: string; +}) { + return createHash('sha256') + .update( + stringify({ + license, + features, + error, + }) + ) + .digest('hex'); +} + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ +export class LicensingPlugin implements Plugin { + private stop$ = new Subject(); private readonly logger: Logger; - private readonly config$: Observable; - private poller!: Poller; + private readonly config$: Observable; + private licenseFetchSubscription?: Subscription; + private loggingSubscription?: Subscription; constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); - this.config$ = this.context.config - .create() - .pipe( - map(config => - 'config' in config - ? new LicensingConfig(config.config, this.context.env) - : new LicensingConfig(config, this.context.env) - ) - ); + this.config$ = this.context.config.create(); } - private hasLicenseInfoChanged(newLicense: any) { - const currentLicense = this.poller.subject$.getValue(); + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Licensing plugin'); + const config = await this.config$.pipe(take(1)).toPromise(); + const dataClient = await core.elasticsearch.dataClient$.pipe(take(1)).toPromise(); - if ((currentLicense && !newLicense) || (newLicense && !currentLicense)) { - return true; - } + const { refresh, license$ } = this.createLicensePoller(dataClient, config.pollingFrequency); - return ( - newLicense.type !== currentLicense.type || - newLicense.status !== currentLicense.status || - newLicense.expiry_date_in_millis !== currentLicense.expiryDateInMillis - ); + core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); + + return { + refresh, + license$, + }; } - private async fetchInfo(core: CoreSetup, clusterSource: string, pollingFrequency: number) { - this.logger.debug( - `Calling [${clusterSource}] Elasticsearch _xpack API. Polling frequency: ${pollingFrequency}` + private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: number) { + const manualRefresh$ = new Subject(); + const intervalRefresh$ = timer(0, pollingFrequency); + const refresh$ = merge(intervalRefresh$, manualRefresh$).pipe(takeUntil(this.stop$)); + + const { update$, fetchSubscription } = createLicenseUpdate(refresh$, () => + this.fetchLicense(clusterClient) + ); + + this.licenseFetchSubscription = fetchSubscription; + this.loggingSubscription = update$.subscribe(license => + this.logger.debug( + 'Imported license information from Elasticsearch:' + + [ + `type: ${license.type}`, + `status: ${license.status}`, + `expiry date: ${moment(license.expiryDateInMillis, 'x').format()}`, + ].join(' | ') + ) ); - const cluster = await core.elasticsearch.dataClient$.pipe(first()).toPromise(); + return { + refresh: () => { + this.logger.debug('Requesting Elasticsearch licensing API'); + manualRefresh$.next(); + }, + license$: update$, + }; + } + private fetchLicense = async (clusterClient: IClusterClient): Promise => { try { - const response = await cluster.callAsInternalUser('transport.request', { + const response = await clusterClient.callAsInternalUser('transport.request', { method: 'GET', path: '/_xpack', }); - const rawLicense = response && response.license; - const features = (response && response.features) || {}; - const licenseInfoChanged = this.hasLicenseInfoChanged(rawLicense); - - if (!licenseInfoChanged) { - return { license: false, error: null, features: null }; - } - - const currentLicense = this.poller.subject$.getValue(); - const licenseInfo = [ - 'type' in rawLicense && `type: ${rawLicense.type}`, - 'status' in rawLicense && `status: ${rawLicense.status}`, - 'expiry_date_in_millis' in rawLicense && - `expiry date: ${moment(rawLicense.expiry_date_in_millis, 'x').format()}`, - ] - .filter(Boolean) - .join(' | '); - - this.logger.info( - `Imported ${currentLicense ? 'changed ' : ''}license information` + - ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` - ); - return { license: rawLicense, error: null, features }; - } catch (err) { + const normalizedLicense = normalizeServerLicense(response.license); + const normalizedFeatures = normalizeFeatures(response.features); + const signature = sign({ + license: normalizedLicense, + features: normalizedFeatures, + error: '', + }); + + return new License({ + license: normalizedLicense, + features: normalizedFeatures, + signature, + }); + } catch (error) { this.logger.warn( - `License information could not be obtained from Elasticsearch` + - ` for the [${clusterSource}] cluster. ${err}` + `License information could not be obtained from Elasticsearch due to ${error} error` ); + const errorMessage = this.getErrorMessage(error); + const signature = sign({ error: errorMessage }); - return { license: null, error: err, features: {} }; + return new License({ + error: this.getErrorMessage(error), + signature, + }); } - } - - private create({ clusterSource, pollingFrequency }: LicensingConfig, core: CoreSetup) { - this.poller = new Poller( - pollingFrequency, - new License(null, {}, null, clusterSource), - async () => { - const { license, features, error } = await this.fetchInfo( - core, - clusterSource, - pollingFrequency - ); - - if (license !== false) { - return new License(license, features, error, clusterSource); - } - } - ); - - return this.poller; - } + }; - public async setup(core: CoreSetup) { - const config = await this.config$.pipe(first()).toPromise(); - const poller = this.create(config, core); - const license$ = poller.subject$.asObservable(); - - core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); - - return { - license$, - }; + private getErrorMessage(error: ElasticsearchError): string { + if (error.status === 400) { + return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; + } + return error.message; } public async start(core: CoreStart) {} public stop() { - if (this.poller) { - this.poller.unsubscribe(); + this.stop$.next(); + this.stop$.complete(); + + if (this.licenseFetchSubscription !== undefined) { + this.licenseFetchSubscription.unsubscribe(); + this.licenseFetchSubscription = undefined; + } + + if (this.loggingSubscription !== undefined) { + this.loggingSubscription.unsubscribe(); + this.loggingSubscription = undefined; } } } diff --git a/x-pack/plugins/licensing/server/schema.ts b/x-pack/plugins/licensing/server/schema.ts deleted file mode 100644 index cfc467677f7b6..0000000000000 --- a/x-pack/plugins/licensing/server/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema as Schema } from '@kbn/config-schema'; -import { DEFAULT_POLLING_FREQUENCY } from './constants'; - -export const schema = Schema.object({ - isEnabled: Schema.boolean({ defaultValue: true }), - clusterSource: Schema.string({ defaultValue: 'data' }), - pollingFrequency: Schema.number({ defaultValue: DEFAULT_POLLING_FREQUENCY }), -}); diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/server/types.ts index 27d3502b44779..d553f090fb648 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/server/types.ts @@ -3,129 +3,43 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { ILicense, LicenseStatus, LicenseType } from '../common/types'; -import { Observable } from 'rxjs'; -import { TypeOf } from '@kbn/config-schema'; -import { schema } from './schema'; -import { LICENSE_TYPE, LICENSE_STATUS } from './constants'; -import { LicenseFeature } from './license_feature'; - +export interface ElasticsearchError extends Error { + status?: number; +} /** - * @public - * Results from checking if a particular license type meets the minimum - * requirements of the license type. + * Result from remote request fetching raw feature set. + * @internal */ -export interface ILicenseCheck { - /** - * The status of checking the results of a license type meeting the license minimum. - */ - check: LICENSE_STATUS; - /** - * A message containing the reason for a license type not being valid. - */ - message?: string; +export interface RawFeature { + available: boolean; + enabled: boolean; } -/** @public */ -export interface ILicense { - /** - * UID for license. - */ - uid?: string; - - /** - * The validity status of the license. - */ - status?: string; - - /** - * Determine if the status of the license is active. - */ - isActive: boolean; - - /** - * Unix epoch of the expiration date of the license. - */ - expiryDateInMillis?: number; - - /** - * The license type, being usually one of basic, standard, gold, platinum, or trial. - */ - type?: string; - - /** - * Determine if the license container has information. - */ - isAvailable: boolean; - - /** - * Determine if the type of the license is basic, and also active. - */ - isBasic: boolean; - - /** - * Determine if the type of the license is not basic, and also active. - */ - isNotBasic: boolean; - - /** - * If the license is not available, provides a string or Error containing the reason. - */ - reasonUnavailable: string | Error | null; - - /** - * The MD5 hash of the serialized license. - */ - signature: string; - /** - * Determine if the provided license types match against the license type. - * @param candidateLicenses license types to intersect against the license. - */ - isOneOf(candidateLicenses: string | string[]): boolean; - - /** - * Determine if the provided license type is sufficient for the current license. - * @param minimum a license type to determine for sufficiency - */ - meetsMinimumOf(minimum: LICENSE_TYPE): boolean; - - /** - * For a given plugin and license type, receive information about the status of the license. - * @param pluginName the name of the plugin - * @param minimumLicenseRequired the minimum valid license for operating the given plugin - */ - check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string): ILicenseCheck; - - /** - * Receive a serialized plain object of the license. - */ - toObject(): any; - - /** - * A specific API for interacting with the specific features of the license. - * @param name the name of the feature to interact with - */ - getFeature(name: string): LicenseFeature | undefined; -} - -/** @public */ -export interface LicensingPluginSetup { - license$: Observable; +/** + * Results from remote request fetching raw feature sets. + * @internal + */ +export interface RawFeatures { + [key: string]: RawFeature; } -/** @public */ -export type LicensingConfigType = TypeOf; -/** @public */ -export type LicenseType = keyof typeof LICENSE_TYPE; -/** @public */ -export type LicenseFeatureSerializer = (licensing: ILicense) => any; -/** @public */ -export interface LicensingRequestContext { - license: ILicense; +/** + * Results from remote request fetching a raw license. + * @internal + */ +export interface RawLicense { + uid: string; + status: LicenseStatus; + expiry_date_in_millis: number; + type: LicenseType; } declare module 'src/core/server' { interface RequestHandlerContext { - licensing: LicensingRequestContext; + licensing: { + license: ILicense; + }; } } diff --git a/x-pack/plugins/security/server/licensing/license_service.test.ts b/x-pack/plugins/security/server/licensing/license_service.test.ts index 16d7599ca4b1a..d5d33c07985fd 100644 --- a/x-pack/plugins/security/server/licensing/license_service.test.ts +++ b/x-pack/plugins/security/server/licensing/license_service.test.ts @@ -48,7 +48,7 @@ describe('license features', function() { mockRawLicense.isOneOf.mockImplementation(licenses => Array.isArray(licenses) ? licenses.includes('basic') : licenses === 'basic' ); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); @@ -67,7 +67,7 @@ describe('license features', function() { it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { const mockRawLicense = getMockRawLicense({ isAvailable: true }); mockRawLicense.isOneOf.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); @@ -88,7 +88,7 @@ describe('license features', function() { const licenseArray = [licenses].flat(); return licenseArray.includes('trial') || licenseArray.includes('platinum'); }); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts index 73adaba551875..10fe0cdd67811 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts @@ -5,9 +5,7 @@ */ import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -// TODO, require from licensing plugin root once https://github.com/elastic/kibana/pull/44922 is merged. -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { RawKibanaPrivileges } from '../../../../common/model'; import { defineGetPrivilegesRoutes } from './get'; @@ -40,7 +38,7 @@ const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { }; interface TestOptions { - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; includeActions?: boolean; asserts: { statusCode: number; result: Record }; } @@ -48,7 +46,11 @@ interface TestOptions { describe('GET privileges', () => { const getPrivilegesTest = ( description: string, - { licenseCheckResult = { check: LICENSE_STATUS.Valid }, includeActions, asserts }: TestOptions + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + includeActions, + asserts, + }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -80,7 +82,7 @@ describe('GET privileges', () => { describe('failure', () => { getPrivilegesTest(`returns result of routePreCheckLicense`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts index 5699b100e3ffd..61c5747550d75 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts @@ -6,8 +6,7 @@ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineDeleteRolesRoutes } from './delete'; import { @@ -17,7 +16,7 @@ import { import { routeDefinitionParamsMock } from '../../index.mock'; interface TestOptions { - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; name: string; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; @@ -28,7 +27,7 @@ describe('DELETE role', () => { description: string, { name, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts, }: TestOptions @@ -76,7 +75,7 @@ describe('DELETE role', () => { describe('failure', () => { deleteRoleTest(`returns result of license checker`, { name: 'foo-role', - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 619e6e67f683b..1cfc1ae416ae4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -5,8 +5,7 @@ */ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineGetRolesRoutes } from './get'; import { @@ -20,7 +19,7 @@ const reservedPrivilegesApplicationWildcard = 'kibana-*'; interface TestOptions { name?: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; } @@ -30,7 +29,7 @@ describe('GET role', () => { description: string, { name, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts, }: TestOptions @@ -77,7 +76,7 @@ describe('GET role', () => { describe('failure', () => { getRoleTest(`returns result of license check`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index d04513592f027..76ce6a272e285 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -5,8 +5,7 @@ */ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineGetAllRolesRoutes } from './get_all'; import { @@ -20,7 +19,7 @@ const reservedPrivilegesApplicationWildcard = 'kibana-*'; interface TestOptions { name?: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; } @@ -28,7 +27,7 @@ interface TestOptions { describe('GET all roles', () => { const getRolesTest = ( description: string, - { licenseCheckResult = { check: LICENSE_STATUS.Valid }, apiResponse, asserts }: TestOptions + { licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -69,7 +68,7 @@ describe('GET all roles', () => { describe('failure', () => { getRolesTest(`returns result of license check`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index fa4f2350bb7dd..31963987c2efb 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -6,8 +6,7 @@ import { Type } from '@kbn/config-schema'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { GLOBAL_RESOURCE } from '../../../../common/constants'; import { definePutRolesRoutes } from './put'; @@ -45,7 +44,7 @@ const privilegeMap = { interface TestOptions { name: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponses?: Array<() => Promise>; payload?: Record; asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; @@ -56,7 +55,7 @@ const putRoleTest = ( { name, payload, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponses = [], asserts, }: TestOptions @@ -141,7 +140,7 @@ describe('PUT role', () => { describe('failure', () => { putRoleTest(`returns result of license checker`, { name: 'foo-role', - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); }); diff --git a/x-pack/plugins/security/server/routes/licensed_route_handler.ts b/x-pack/plugins/security/server/routes/licensed_route_handler.ts index de5b842c7d292..1194e3d0a83cc 100644 --- a/x-pack/plugins/security/server/routes/licensed_route_handler.ts +++ b/x-pack/plugins/security/server/routes/licensed_route_handler.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { ObjectType } from '@kbn/config-schema'; -import { LICENSE_STATUS } from '../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../licensing/server'; export const createLicensedRouteHandler = < P extends ObjectType, @@ -19,8 +19,8 @@ export const createLicensedRouteHandler = < const { license } = context.licensing; const licenseCheck = license.check('security', 'basic'); if ( - licenseCheck.check === LICENSE_STATUS.Unavailable || - licenseCheck.check === LICENSE_STATUS.Invalid + licenseCheck.state === LICENSE_CHECK_STATE.Unavailable || + licenseCheck.state === LICENSE_CHECK_STATE.Invalid ) { return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); } diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 21120ab37b06a..6511a5dc3f31b 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -31,7 +31,7 @@ import { ConfigType } from './config'; import { toggleUICapabilities } from './lib/toggle_ui_capabilities'; import { initSpacesRequestInterceptors } from './lib/request_interceptors'; import { initExternalSpacesApi } from './routes/api/external'; -import { HomePluginSetup } from '../../../../src/plugins/home/server'; +import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin * to function properly. @@ -62,7 +62,7 @@ export interface PluginsSetup { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; security?: SecurityPluginSetup; - home?: HomePluginSetup; + home?: HomeServerPluginSetup; } export interface SpacesPluginSetup { diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts index 5bb811ef6be4c..0bc1685345857 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts @@ -5,13 +5,13 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../../licensing/server'; export const mockRouteContext = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Valid, + state: LICENSE_CHECK_STATE.Valid, }), }, }, @@ -21,7 +21,7 @@ export const mockRouteContextWithInvalidLicense = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Invalid, + state: LICENSE_CHECK_STATE.Invalid, message: 'License is invalid for spaces', }), }, diff --git a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts index a3bc2fa71fefe..3838b1d134ea2 100644 --- a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts +++ b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { ObjectType } from '@kbn/config-schema'; -import { LICENSE_STATUS } from '../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; export const createLicensedRouteHandler = < P extends ObjectType, @@ -19,8 +19,8 @@ export const createLicensedRouteHandler = < const { license } = context.licensing; const licenseCheck = license.check('spaces', 'basic'); if ( - licenseCheck.check === LICENSE_STATUS.Unavailable || - licenseCheck.check === LICENSE_STATUS.Invalid + licenseCheck.state === LICENSE_CHECK_STATE.Unavailable || + licenseCheck.state === LICENSE_CHECK_STATE.Invalid ) { return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); } diff --git a/x-pack/typings/index.d.ts b/x-pack/typings/index.d.ts index 53724a72166e1..2413f986922ed 100644 --- a/x-pack/typings/index.d.ts +++ b/x-pack/typings/index.d.ts @@ -23,7 +23,11 @@ type PublicMethodsOf = Pick>; declare module 'axios/lib/adapters/xhr'; -type MockedKeys = { [P in keyof T]: jest.Mocked }; +type Writable = { + -readonly [K in keyof T]: T[K]; +}; + +type MockedKeys = { [P in keyof T]: jest.Mocked> }; type DeeplyMockedKeys = { [P in keyof T]: T[P] extends (...args: any[]) => any