diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a3d884c01b43..5948b9672e6d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,6 @@ # App /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app -/src/plugins/share/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app /src/legacy/server/sample_data/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app @@ -27,6 +26,7 @@ /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app /src/plugins/dev_tools/ @elastic/kibana-app +/src/plugins/dashboard_embeddable_container/ @elastic/kibana-app # App Architecture /packages/kbn-interpreter/ @elastic/kibana-app-arch @@ -42,7 +42,6 @@ /src/legacy/core_plugins/visualizations/ @elastic/kibana-app-arch /src/legacy/server/index_patterns/ @elastic/kibana-app-arch /src/plugins/bfetch/ @elastic/kibana-app-arch -/src/plugins/dashboard_embeddable_container/ @elastic/kibana-app-arch /src/plugins/data/ @elastic/kibana-app-arch /src/plugins/embeddable/ @elastic/kibana-app-arch /src/plugins/expressions/ @elastic/kibana-app-arch @@ -53,6 +52,9 @@ /src/plugins/navigation/ @elastic/kibana-app-arch /src/plugins/ui_actions/ @elastic/kibana-app-arch /src/plugins/visualizations/ @elastic/kibana-app-arch +/src/plugins/share/ @elastic/kibana-app-arch +/examples/url_generators_examples/ @elastic/kibana-app-arch +/examples/url_generators_explorer/ @elastic/kibana-app-arch /x-pack/plugins/advanced_ui_actions/ @elastic/kibana-app-arch /x-pack/plugins/drilldowns/ @elastic/kibana-app-arch diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md index c36d649837e8a..fa052c1179a30 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.md @@ -20,6 +20,7 @@ export interface CoreSetup | [context](./kibana-plugin-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | [http](./kibana-plugin-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | +| [metrics](./kibana-plugin-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-server.savedobjectsservicesetup.md) | | [uiSettings](./kibana-plugin-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-server.uisettingsservicesetup.md) | | [uuid](./kibana-plugin-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-server.uuidservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.metrics.md b/docs/development/core/server/kibana-plugin-server.coresetup.metrics.md new file mode 100644 index 0000000000000..5db723751be85 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.coresetup.metrics.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) > [metrics](./kibana-plugin-server.coresetup.metrics.md) + +## CoreSetup.metrics property + +[MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) + +Signature: + +```typescript +metrics: MetricsServiceSetup; +``` diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 7d0adb9b0e7ef..3d99e7298755f 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -325,10 +325,6 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request's `Referer` header. This setting may not be used when `server.compression.enabled` is set to `false`. -[[server-cors]]`server.cors:`:: *Default: `false`* Set to `true` to enable CORS support. This setting is required to configure `server.cors.origin`. - -`server.cors.origin:`:: *Default: none* Specifies origins. "origin" must be an array. To use this setting, you must set `server.cors` to `true`. To accept all origins, use `server.cors.origin: ["*"]`. - `server.customResponseHeaders:`:: *Default: `{}`* Header names and values to send on all responses to the client from the Kibana server. diff --git a/examples/search_explorer/public/es_strategy.tsx b/examples/search_explorer/public/es_strategy.tsx index 5d2617e64a79e..aaf9dada90341 100644 --- a/examples/search_explorer/public/es_strategy.tsx +++ b/examples/search_explorer/public/es_strategy.tsx @@ -33,8 +33,6 @@ import { import { DoSearch } from './do_search'; import { GuideSection } from './guide_section'; -// @ts-ignore -import serverPlugin from '!!raw-loader!./../../../src/plugins/data/server/search/es_search/es_search_service'; // @ts-ignore import serverStrategy from '!!raw-loader!./../../../src/plugins/data/server/search/es_search/es_search_strategy'; @@ -127,10 +125,7 @@ export class EsSearchTest extends React.Component { }, { title: 'Server', - code: [ - { description: 'es_search_service.ts', snippet: serverPlugin }, - { description: 'es_search_strategy.ts', snippet: serverStrategy }, - ], + code: [{ description: 'es_search_strategy.ts', snippet: serverStrategy }], }, ]} demo={this.renderDemo()} diff --git a/examples/url_generators_examples/README.md b/examples/url_generators_examples/README.md new file mode 100644 index 0000000000000..facd5c90c8c96 --- /dev/null +++ b/examples/url_generators_examples/README.md @@ -0,0 +1,7 @@ +## Access links examples + +This example app shows how to: + - Register a direct access link generator. + - Handle migration of legacy generators into a new one. + +To run this example, use the command `yarn start --run-examples`. Navigate to the access links explorer app \ No newline at end of file diff --git a/examples/url_generators_examples/kibana.json b/examples/url_generators_examples/kibana.json new file mode 100644 index 0000000000000..0767018e3bb98 --- /dev/null +++ b/examples/url_generators_examples/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "urlGeneratorsExamples", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["url_generators_examples"], + "server": false, + "ui": true, + "requiredPlugins": ["share"], + "optionalPlugins": [] +} diff --git a/examples/url_generators_examples/package.json b/examples/url_generators_examples/package.json new file mode 100644 index 0000000000000..e07482db25f43 --- /dev/null +++ b/examples/url_generators_examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "url_generators_examples", + "version": "1.0.0", + "main": "target/examples/url_generators_examples", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/examples/url_generators_examples/public/app.tsx b/examples/url_generators_examples/public/app.tsx new file mode 100644 index 0000000000000..c39cd876ea9b1 --- /dev/null +++ b/examples/url_generators_examples/public/app.tsx @@ -0,0 +1,89 @@ +/* + * 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 React from 'react'; +import ReactDOM from 'react-dom'; + +import { EuiPageBody } from '@elastic/eui'; +import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; +import { Route, Switch, Redirect, Router, useLocation } from 'react-router-dom'; +import { createBrowserHistory } from 'history'; +import { EuiText } from '@elastic/eui'; +import { AppMountParameters } from '../../../src/core/public'; + +function useQuery() { + const { search } = useLocation(); + const params = React.useMemo(() => new URLSearchParams(search), [search]); + return params; +} + +interface HelloPageProps { + firstName: string; + lastName: string; +} + +const HelloPage = ({ firstName, lastName }: HelloPageProps) => ( + {`Hello ${firstName} ${lastName}`} +); + +export const Routes: React.FC<{}> = () => { + const query = useQuery(); + + return ( + + + + + + + + + + + + + ); +}; + +export const LinksExample: React.FC<{ + appBasePath: string; +}> = props => { + const history = React.useMemo( + () => + createBrowserHistory({ + basename: props.appBasePath, + }), + [props.appBasePath] + ); + return ( + + + + ); +}; + +export const renderApp = (props: { appBasePath: string }, { element }: AppMountParameters) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/legacy/ui/public/time_buckets/index.d.ts b/examples/url_generators_examples/public/index.ts similarity index 87% rename from src/legacy/ui/public/time_buckets/index.d.ts rename to examples/url_generators_examples/public/index.ts index 70b9495b81f0e..e87f9237bff38 100644 --- a/src/legacy/ui/public/time_buckets/index.d.ts +++ b/examples/url_generators_examples/public/index.ts @@ -17,6 +17,6 @@ * under the License. */ -declare module 'ui/time_buckets' { - export const TimeBuckets: any; -} +import { AccessLinksExamplesPlugin } from './plugin'; + +export const plugin = () => new AccessLinksExamplesPlugin(); diff --git a/examples/url_generators_examples/public/plugin.tsx b/examples/url_generators_examples/public/plugin.tsx new file mode 100644 index 0000000000000..016494037ec05 --- /dev/null +++ b/examples/url_generators_examples/public/plugin.tsx @@ -0,0 +1,76 @@ +/* + * 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 { SharePluginStart, SharePluginSetup } from '../../../src/plugins/share/public'; +import { Plugin, CoreSetup, AppMountParameters } from '../../../src/core/public'; +import { + HelloLinkGeneratorState, + createHelloPageLinkGenerator, + LegacyHelloLinkGeneratorState, + HELLO_URL_GENERATOR_V1, + HELLO_URL_GENERATOR, + helloPageLinkGeneratorV1, +} from './url_generator'; + +declare module '../../../src/plugins/share/public' { + export interface UrlGeneratorStateMapping { + [HELLO_URL_GENERATOR_V1]: LegacyHelloLinkGeneratorState; + [HELLO_URL_GENERATOR]: HelloLinkGeneratorState; + } +} + +interface StartDeps { + share: SharePluginStart; +} + +interface SetupDeps { + share: SharePluginSetup; +} + +const APP_ID = 'urlGeneratorsExamples'; + +export class AccessLinksExamplesPlugin implements Plugin { + public setup(core: CoreSetup, { share: { urlGenerators } }: SetupDeps) { + urlGenerators.registerUrlGenerator( + createHelloPageLinkGenerator(async () => ({ + appBasePath: (await core.getStartServices())[0].application.getUrlForApp(APP_ID), + })) + ); + + urlGenerators.registerUrlGenerator(helloPageLinkGeneratorV1); + + core.application.register({ + id: APP_ID, + title: 'Access links examples', + async mount(params: AppMountParameters) { + const { renderApp } = await import('./app'); + return renderApp( + { + appBasePath: params.appBasePath, + }, + params + ); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/examples/url_generators_examples/public/url_generator.ts b/examples/url_generators_examples/public/url_generator.ts new file mode 100644 index 0000000000000..f21b1c9295e66 --- /dev/null +++ b/examples/url_generators_examples/public/url_generator.ts @@ -0,0 +1,78 @@ +/* + * 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 url from 'url'; +import { UrlGeneratorState, UrlGeneratorsDefinition } from '../../../src/plugins/share/public'; + +/** + * The name of the latest variable can always stay the same so code that + * uses this link generator statically will switch to the latest version. + * Typescript will warn the developer if incorrect state is being passed + * down. + */ +export const HELLO_URL_GENERATOR = 'HELLO_URL_GENERATOR_V2'; + +export interface HelloLinkState { + firstName: string; + lastName: string; +} + +export type HelloLinkGeneratorState = UrlGeneratorState; + +export const createHelloPageLinkGenerator = ( + getStartServices: () => Promise<{ appBasePath: string }> +): UrlGeneratorsDefinition => ({ + id: HELLO_URL_GENERATOR, + createUrl: async state => { + const startServices = await getStartServices(); + const appBasePath = startServices.appBasePath; + const parsedUrl = url.parse(window.location.href); + + return url.format({ + protocol: parsedUrl.protocol, + host: parsedUrl.host, + pathname: `${appBasePath}/hello`, + query: { + ...state, + }, + }); + }, +}); + +/** + * The name of this legacy generator id changes, but the *value* stays the same. + */ +export const HELLO_URL_GENERATOR_V1 = 'HELLO_URL_GENERATOR'; + +export interface HelloLinkStateV1 { + name: string; +} + +export type LegacyHelloLinkGeneratorState = UrlGeneratorState< + HelloLinkStateV1, + typeof HELLO_URL_GENERATOR, + HelloLinkState +>; + +export const helloPageLinkGeneratorV1: UrlGeneratorsDefinition = { + id: HELLO_URL_GENERATOR_V1, + isDeprecated: true, + migrate: async state => { + return { id: HELLO_URL_GENERATOR, state: { firstName: state.name, lastName: '' } }; + }, +}; diff --git a/examples/url_generators_examples/tsconfig.json b/examples/url_generators_examples/tsconfig.json new file mode 100644 index 0000000000000..091130487791b --- /dev/null +++ b/examples/url_generators_examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/examples/url_generators_explorer/README.md b/examples/url_generators_explorer/README.md new file mode 100644 index 0000000000000..922cf37aff847 --- /dev/null +++ b/examples/url_generators_explorer/README.md @@ -0,0 +1,8 @@ +## Access links explorer + +This example app shows how to: + - Generate links to other applications + - Generate dynamic links, when the target application is not known + - Handle backward compatibility of urls + +To run this example, use the command `yarn start --run-examples`. \ No newline at end of file diff --git a/examples/url_generators_explorer/kibana.json b/examples/url_generators_explorer/kibana.json new file mode 100644 index 0000000000000..94ab75b338889 --- /dev/null +++ b/examples/url_generators_explorer/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "urlGeneratorsExplorer", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["url_generators_explorer"], + "server": false, + "ui": true, + "requiredPlugins": ["share", "urlGeneratorsExamples"], + "optionalPlugins": [] +} diff --git a/examples/url_generators_explorer/package.json b/examples/url_generators_explorer/package.json new file mode 100644 index 0000000000000..52da533dc0c05 --- /dev/null +++ b/examples/url_generators_explorer/package.json @@ -0,0 +1,17 @@ +{ + "name": "url_generators_explorer", + "version": "1.0.0", + "main": "target/examples/url_generators_explorer", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/examples/url_generators_explorer/public/app.tsx b/examples/url_generators_explorer/public/app.tsx new file mode 100644 index 0000000000000..77e804ae08c5f --- /dev/null +++ b/examples/url_generators_explorer/public/app.tsx @@ -0,0 +1,170 @@ +/* + * 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 React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; + +import { EuiPage } from '@elastic/eui'; + +import { EuiButton } from '@elastic/eui'; +import { EuiPageBody } from '@elastic/eui'; +import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiPageHeader } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; +import { AppMountParameters } from '../../../src/core/public'; +import { UrlGeneratorsService } from '../../../src/plugins/share/public'; +import { + HELLO_URL_GENERATOR, + HELLO_URL_GENERATOR_V1, +} from '../../url_generators_examples/public/url_generator'; + +interface Props { + getLinkGenerator: UrlGeneratorsService['getUrlGenerator']; +} + +interface MigratedLink { + isDeprecated: boolean; + linkText: string; + link: string; +} + +const ActionsExplorer = ({ getLinkGenerator }: Props) => { + const [migratedLinks, setMigratedLinks] = useState([] as MigratedLink[]); + const [buildingLinks, setBuildingLinks] = useState(false); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + /** + * Lets pretend we grabbed these links from a persistent store, like a saved object. + * Some of these links were created with older versions of the hello link generator. + * They use deprecated generator ids. + */ + const [persistedLinks, setPersistedLinks] = useState([ + { + id: HELLO_URL_GENERATOR_V1, + linkText: 'Say hello to Mary', + state: { + name: 'Mary', + }, + }, + { + id: HELLO_URL_GENERATOR, + linkText: 'Say hello to George', + state: { + firstName: 'George', + lastName: 'Washington', + }, + }, + ]); + + useEffect(() => { + setBuildingLinks(true); + + const updateLinks = async () => { + const updatedLinks = await Promise.all( + persistedLinks.map(async savedLink => { + const generator = getLinkGenerator(savedLink.id); + const link = await generator.createUrl(savedLink.state); + return { + isDeprecated: generator.isDeprecated, + linkText: savedLink.linkText, + link, + }; + }) + ); + setMigratedLinks(updatedLinks); + setBuildingLinks(false); + }; + + updateLinks(); + }, [getLinkGenerator, persistedLinks]); + + return ( + + + Access links explorer + + + +

Create new links using the most recent version of a url generator.

+
+ { + setFirstName(e.target.value); + }} + /> + setLastName(e.target.value)} /> + + setPersistedLinks([ + ...persistedLinks, + { + id: HELLO_URL_GENERATOR, + state: { firstName, lastName }, + linkText: `Say hello to ${firstName} ${lastName}`, + }, + ]) + } + > + Add new link + + + + +

+ Existing links retrieved from storage. The links that were generated from legacy + generators are in red. This can be useful for developers to know they will have to + migrate persisted state or in a future version of Kibana, these links may no longer + work. They still work now because legacy url generators must provide a state + migration function. +

+
+ {buildingLinks ? ( +
loading...
+ ) : ( + migratedLinks.map(link => ( + + + {link.linkText} + +
+
+ )) + )} +
+
+
+
+ ); +}; + +export const renderApp = (props: Props, { element }: AppMountParameters) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/url_generators_explorer/public/index.ts b/examples/url_generators_explorer/public/index.ts new file mode 100644 index 0000000000000..30ff481dbe3a5 --- /dev/null +++ b/examples/url_generators_explorer/public/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { AccessLinksExplorerPlugin } from './plugin'; + +export const plugin = () => new AccessLinksExplorerPlugin(); diff --git a/src/plugins/data/server/search/es_search/es_search_service.test.ts b/examples/url_generators_explorer/public/page.tsx similarity index 54% rename from src/plugins/data/server/search/es_search/es_search_service.test.ts rename to examples/url_generators_explorer/public/page.tsx index 0b274c62958a9..90bea35804822 100644 --- a/src/plugins/data/server/search/es_search/es_search_service.test.ts +++ b/examples/url_generators_explorer/public/page.tsx @@ -17,26 +17,35 @@ * under the License. */ -import { coreMock } from '../../../../../core/server/mocks'; -import { EsSearchService } from './es_search_service'; -import { searchSetupMock } from '../mocks'; +import React from 'react'; -describe('ES search strategy service', () => { - let service: EsSearchService; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; - const mockCoreSetup = coreMock.createSetup(); - const context = coreMock.createPluginInitializerContext(); +interface PageProps { + title: string; + children: React.ReactNode; +} - beforeEach(() => { - service = new EsSearchService(context); - }); - - describe('setup()', () => { - it('registers the ES search strategy', async () => { - service.setup(mockCoreSetup, { - search: searchSetupMock, - }); - expect(searchSetupMock.registerSearchStrategyProvider).toBeCalled(); - }); - }); -}); +export function Page({ title, children }: PageProps) { + return ( + + + + +

{title}

+
+
+
+ + {children} + +
+ ); +} diff --git a/src/plugins/data/server/search/es_search/es_search_service.ts b/examples/url_generators_explorer/public/plugin.tsx similarity index 52% rename from src/plugins/data/server/search/es_search/es_search_service.ts rename to examples/url_generators_explorer/public/plugin.tsx index b33b6c6ecd318..1fe70476b8e79 100644 --- a/src/plugins/data/server/search/es_search/es_search_service.ts +++ b/examples/url_generators_explorer/public/plugin.tsx @@ -17,26 +17,32 @@ * under the License. */ -import { ISearchSetup } from '../i_search_setup'; -import { PluginInitializerContext, CoreSetup, Plugin } from '../../../../../core/server'; -import { esSearchStrategyProvider } from './es_search_strategy'; -import { ES_SEARCH_STRATEGY } from '../../../common/search'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { Plugin, CoreSetup, AppMountParameters } from '../../../src/core/public'; -interface IEsSearchDependencies { - search: ISearchSetup; +interface StartDeps { + share: SharePluginStart; } -export class EsSearchService implements Plugin { - constructor(private initializerContext: PluginInitializerContext) {} - - public setup(core: CoreSetup, deps: IEsSearchDependencies) { - deps.search.registerSearchStrategyProvider( - this.initializerContext.opaqueId, - ES_SEARCH_STRATEGY, - esSearchStrategyProvider - ); +export class AccessLinksExplorerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'urlGeneratorsExplorer', + title: 'Access links explorer', + async mount(params: AppMountParameters) { + const depsStart = (await core.getStartServices())[1]; + const { renderApp } = await import('./app'); + return renderApp( + { + getLinkGenerator: depsStart.share.urlGenerators.getUrlGenerator, + }, + params + ); + }, + }); } public start() {} + public stop() {} } diff --git a/examples/url_generators_explorer/tsconfig.json b/examples/url_generators_explorer/tsconfig.json new file mode 100644 index 0000000000000..091130487791b --- /dev/null +++ b/examples/url_generators_explorer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/src/core/public/http/fetch.test.ts b/src/core/public/http/fetch.test.ts index efd9fdd053674..f223956075e97 100644 --- a/src/core/public/http/fetch.test.ts +++ b/src/core/public/http/fetch.test.ts @@ -21,6 +21,7 @@ import fetchMock from 'fetch-mock/es5/client'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { first } from 'rxjs/operators'; import { Fetch } from './fetch'; import { BasePath } from './base_path'; @@ -30,9 +31,11 @@ function delay(duration: number) { return new Promise(r => setTimeout(r, duration)); } +const BASE_PATH = 'http://localhost/myBase'; + describe('Fetch', () => { const fetchInstance = new Fetch({ - basePath: new BasePath('http://localhost/myBase'), + basePath: new BasePath(BASE_PATH), kibanaVersion: 'VERSION', }); afterEach(() => { @@ -40,6 +43,79 @@ describe('Fetch', () => { fetchInstance.removeAllInterceptors(); }); + describe('getRequestCount$', () => { + const getCurrentRequestCount = () => + fetchInstance + .getRequestCount$() + .pipe(first()) + .toPromise(); + + it('should increase and decrease when request receives success response', async () => { + fetchMock.get('*', 200); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).resolves.not.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should increase and decrease when request receives error response', async () => { + fetchMock.get('*', 500); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).rejects.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should increase and decrease when request fails', async () => { + fetchMock.get('*', Promise.reject('Network!')); + + const fetchResponse = fetchInstance.fetch('/path'); + expect(await getCurrentRequestCount()).toEqual(1); + + await expect(fetchResponse).rejects.toThrow(); + expect(await getCurrentRequestCount()).toEqual(0); + }); + + it('should change for multiple requests', async () => { + fetchMock.get(`${BASE_PATH}/success`, 200); + fetchMock.get(`${BASE_PATH}/fail`, 400); + fetchMock.get(`${BASE_PATH}/network-fail`, Promise.reject('Network!')); + + const requestCounts: number[] = []; + const subscription = fetchInstance + .getRequestCount$() + .subscribe(count => requestCounts.push(count)); + + const success1 = fetchInstance.fetch('/success'); + const success2 = fetchInstance.fetch('/success'); + const failure1 = fetchInstance.fetch('/fail'); + const failure2 = fetchInstance.fetch('/fail'); + const networkFailure1 = fetchInstance.fetch('/network-fail'); + const success3 = fetchInstance.fetch('/success'); + const failure3 = fetchInstance.fetch('/fail'); + const networkFailure2 = fetchInstance.fetch('/network-fail'); + + const swallowError = (p: Promise) => p.catch(() => {}); + await Promise.all([ + success1, + success2, + success3, + swallowError(failure1), + swallowError(failure2), + swallowError(failure3), + swallowError(networkFailure1), + swallowError(networkFailure2), + ]); + + expect(requestCounts).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + subscription.unsubscribe(); + }); + }); + describe('http requests', () => { it('should fail with invalid arguments', async () => { fetchMock.get('*', {}); diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index b433acdb6dbb9..d88dc2e3a9037 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -19,6 +19,7 @@ import { merge } from 'lodash'; import { format } from 'url'; +import { BehaviorSubject } from 'rxjs'; import { IBasePath, @@ -43,6 +44,7 @@ const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; export class Fetch { private readonly interceptors = new Set(); + private readonly requestCount$ = new BehaviorSubject(0); constructor(private readonly params: Params) {} @@ -57,6 +59,10 @@ export class Fetch { this.interceptors.clear(); } + public getRequestCount$() { + return this.requestCount$.asObservable(); + } + public readonly delete = this.shorthand('DELETE'); public readonly get = this.shorthand('GET'); public readonly head = this.shorthand('HEAD'); @@ -76,6 +82,7 @@ export class Fetch { // a halt is called we do not resolve or reject, halting handling of the promise. return new Promise>(async (resolve, reject) => { try { + this.requestCount$.next(this.requestCount$.value + 1); const interceptedOptions = await interceptRequest( optionsWithPath, this.interceptors, @@ -98,6 +105,8 @@ export class Fetch { if (!(error instanceof HttpInterceptHaltError)) { reject(error); } + } finally { + this.requestCount$.next(this.requestCount$.value - 1); } }); }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index a40fcb06273dd..78220af9cc83b 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -24,6 +24,7 @@ import { loadingServiceMock } from './http_service.test.mocks'; import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { HttpService } from './http_service'; +import { Observable } from 'rxjs'; describe('interceptors', () => { afterEach(() => fetchMock.restore()); @@ -52,6 +53,18 @@ describe('interceptors', () => { }); }); +describe('#setup()', () => { + it('registers Fetch#getLoadingCount$() with LoadingCountSetup#addLoadingCountSource()', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + httpService.setup({ fatalErrors, injectedMetadata }); + const loadingServiceSetup = loadingServiceMock.setup.mock.results[0].value; + // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking + expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); + }); +}); + describe('#stop()', () => { it('calls loadingCount.stop()', () => { const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 44fc9d65565d4..98de1d919c481 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -45,6 +45,7 @@ export class HttpService implements CoreService { ); const fetchService = new Fetch({ basePath, kibanaVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); + loadingCount.addLoadingCountSource(fetchService.getRequestCount$()); this.service = { basePath, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 7d856ae101179..8e481171116fa 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -54,6 +54,7 @@ import { SavedObjectsClientContract } from './saved_objects/types'; import { SavedObjectsServiceSetup, SavedObjectsServiceStart } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { UuidServiceSetup } from './uuid'; +import { MetricsServiceSetup } from './metrics'; export { bootstrap } from './bootstrap'; export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities'; @@ -335,6 +336,8 @@ export interface CoreSetup { uiSettings: UiSettingsServiceSetup; /** {@link UuidServiceSetup} */ uuid: UuidServiceSetup; + /** {@link MetricsServiceSetup} */ + metrics: MetricsServiceSetup; /** * Allows plugins to get access to APIs available in start inside async handlers. * Promise will not resolve until Core and plugin dependencies have completed `start`. diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index ca83a287c57e6..f67148d720446 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -296,6 +296,9 @@ export class LegacyService implements CoreService { isTlsEnabled: setupDeps.core.http.isTlsEnabled, getServerInfo: setupDeps.core.http.getServerInfo, }, + metrics: { + getOpsMetrics$: setupDeps.core.metrics.getOpsMetrics$, + }, savedObjects: { setClientFactoryProvider: setupDeps.core.savedObjects.setClientFactoryProvider, addClientWrapper: setupDeps.core.savedObjects.addClientWrapper, diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 037f3bbed67e0..93d8e2c632e38 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -128,6 +128,7 @@ function createCoreSetupMock() { savedObjects: savedObjectsServiceMock.createInternalSetupContract(), uiSettings: uiSettingsMock, uuid: uuidServiceMock.createSetupContract(), + metrics: metricsServiceMock.createSetupContract(), getStartServices: jest .fn, object]>, []>() .mockResolvedValue([createCoreStartMock(), {}]), diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index f2a44e9f78d4f..b430fd28fb896 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -166,6 +166,9 @@ export function createPluginSetupContext( isTlsEnabled: deps.http.isTlsEnabled, getServerInfo: deps.http.getServerInfo, }, + metrics: { + getOpsMetrics$: deps.metrics.getOpsMetrics$, + }, savedObjects: { setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider, addClientWrapper: deps.savedObjects.addClientWrapper, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 6b0d962aedcd1..30695df33345a 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -606,6 +606,8 @@ export interface CoreSetup { // (undocumented) http: HttpServiceSetup; // (undocumented) + metrics: MetricsServiceSetup; + // (undocumented) savedObjects: SavedObjectsServiceSetup; // (undocumented) uiSettings: UiSettingsServiceSetup; diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 8d730d18a1755..ab3bc598bddd8 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -67,7 +67,6 @@ export { convertIPRangeToString, intervalOptions, // only used in Discover isDateHistogramBucketAggConfig, - setBounds, isStringType, isType, isValidInterval, @@ -80,6 +79,7 @@ export { Schemas, siblingPipelineType, termsAggFilter, + toAbsoluteDates, // search_source getRequestInspectorStats, getResponseInspectorStats, diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts index 7769aa29184d3..4913499403c00 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_config.test.ts @@ -21,7 +21,7 @@ import { identity } from 'lodash'; import { AggConfig, IAggConfig } from './agg_config'; import { AggConfigs, CreateAggConfigParams } from './agg_configs'; -import { AggType } from './agg_types'; +import { AggType } from './agg_type'; import { AggTypesRegistryStart } from './agg_types_registry'; import { mockDataServices, mockAggTypesRegistry } from './test_helpers'; import { IndexPatternField, IndexPattern } from '../../../../../../plugins/data/public'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts index 659bec3f702e3..1465731d5e82b 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_config.ts @@ -397,7 +397,6 @@ export class AggConfig { fieldIsTimeField() { const indexPattern = this.getIndexPattern(); if (!indexPattern) return false; - // @ts-ignore const timeFieldName = indexPattern.timeFieldName; return timeFieldName && this.fieldName() === timeFieldName; } diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts index 29f16b1e4f0bf..49eed55f0233d 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_configs.test.ts @@ -36,6 +36,7 @@ describe('AggConfigs', () => { let typesRegistry: AggTypesRegistryStart; beforeEach(() => { + mockDataServices(); indexPattern = stubIndexPatternWithFields as IndexPattern; typesRegistry = mockAggTypesRegistry(); }); @@ -296,7 +297,6 @@ describe('AggConfigs', () => { ]); beforeEach(() => { - mockDataServices(); indexPattern = stubIndexPattern as IndexPattern; indexPattern.fields.getByName = name => (name as unknown) as IndexPatternField; }); diff --git a/src/legacy/core_plugins/data/public/search/aggs/agg_types.ts b/src/legacy/core_plugins/data/public/search/aggs/agg_types.ts index c16eb06eeb116..691598fe27e31 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/agg_types.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/agg_types.ts @@ -88,27 +88,3 @@ export const aggTypes = { geoTileBucketAgg, ], }; - -export { AggType } from './agg_type'; -export { AggConfig } from './agg_config'; -export { AggConfigs } from './agg_configs'; -export { FieldParamType } from './param_types'; -export { aggTypeFieldFilters } from './param_types/filter'; -export { parentPipelineAggHelper } from './metrics/lib/parent_pipeline_agg_helper'; - -// static code -export { AggParamType } from './param_types/agg'; -export { AggGroupNames, aggGroupNamesMap } from './agg_groups'; -export { intervalOptions } from './buckets/_interval_options'; // only used in Discover -export { isDateHistogramBucketAggConfig, setBounds } from './buckets/date_histogram'; -export { termsAggFilter } from './buckets/terms'; -export { isType, isStringType } from './buckets/migrate_include_exclude_format'; -export { CidrMask } from './buckets/lib/cidr_mask'; -export { convertDateRangeToString } from './buckets/date_range'; -export { convertIPRangeToString } from './buckets/ip_range'; -export { aggTypeFilters, propFilter } from './filter'; -export { OptionedParamType } from './param_types/optioned'; -export { isValidJson, isValidInterval } from './utils'; -export { BUCKET_TYPES } from './buckets/bucket_agg_types'; -export { METRIC_TYPES } from './metrics/metric_agg_types'; -export { ISchemas, Schema, Schemas } from './schemas'; diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts index 2b47dc384bca2..f21ca6c975809 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/create_filter/date_histogram.test.ts @@ -26,9 +26,6 @@ import { dateHistogramBucketAgg, IBucketDateHistogramAggConfig } from '../date_h import { BUCKET_TYPES } from '../bucket_agg_types'; import { RangeFilter } from '../../../../../../../../plugins/data/public'; -// TODO: remove this once time buckets is migrated -jest.mock('ui/new_platform'); - describe('AggConfig Filters', () => { describe('date_histogram', () => { beforeEach(() => { diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/date_histogram.ts index a5368135728d4..8c8911bda99a5 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -21,8 +21,7 @@ import _ from 'lodash'; import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; -// TODO need to move TimeBuckets -import { TimeBuckets } from 'ui/time_buckets'; +import { TimeBuckets } from './lib/time_buckets'; import { BucketAggType, IBucketAggConfig } from './_bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterDateHistogram } from './create_filter/date_histogram'; @@ -31,34 +30,42 @@ import { dateHistogramInterval } from '../../../../common'; import { writeParams } from '../agg_params'; import { isMetricAggType } from '../metrics/metric_agg_type'; -import { KBN_FIELD_TYPES } from '../../../../../../../plugins/data/public'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { getQueryService, getUiSettings } from '../../../../../../../plugins/data/public/services'; +import { + fieldFormats, + KBN_FIELD_TYPES, + TimefilterContract, +} from '../../../../../../../plugins/data/public'; +import { + getFieldFormats, + getQueryService, + getUiSettings, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../plugins/data/public/services'; const detectedTimezone = moment.tz.guess(); const tzOffset = moment().format('Z'); -const getInterval = (agg: IBucketAggConfig): string => _.get(agg, ['params', 'interval']); - -export const setBounds = (agg: IBucketDateHistogramAggConfig, force?: boolean) => { - const { timefilter } = getQueryService().timefilter; - if (agg.buckets._alreadySet && !force) return; - agg.buckets._alreadySet = true; +const updateTimeBuckets = ( + agg: IBucketDateHistogramAggConfig, + timefilter: TimefilterContract, + customBuckets?: IBucketDateHistogramAggConfig['buckets'] +) => { const bounds = agg.params.timeRange ? timefilter.calculateBounds(agg.params.timeRange) : null; - agg.buckets.setBounds(agg.fieldIsTimeField() && bounds); + const buckets = customBuckets || agg.buckets; + buckets.setBounds(agg.fieldIsTimeField() && bounds); + buckets.setInterval(agg.params.interval); }; -// will be replaced by src/legacy/ui/public/time_buckets/time_buckets.js -interface TimeBuckets { - _alreadySet?: boolean; +// TODO: Need to incorporate these properly into TimeBuckets +interface ITimeBuckets { setBounds: Function; - getScaledDateFormatter: Function; + getScaledDateFormat: TimeBuckets['getScaledDateFormat']; setInterval: Function; getInterval: Function; } export interface IBucketDateHistogramAggConfig extends IBucketAggConfig { - buckets: TimeBuckets; + buckets: ITimeBuckets; } export function isDateHistogramBucketAggConfig(agg: any): agg is IBucketDateHistogramAggConfig { @@ -91,16 +98,18 @@ export const dateHistogramBucketAgg = new BucketAggType getUiSettings().get(key) + ); }, params: [ { @@ -122,8 +142,6 @@ export const dateHistogramBucketAgg = new BucketAggType string -) => { +export function convertDateRangeToString({ from, to }: DateRangeKey, format: (val: any) => string) { if (!from) { return 'Before ' + format(to); } else if (!to) { @@ -33,4 +30,4 @@ export const convertDateRangeToString = ( } else { return format(from) + ' to ' + format(to); } -}; +} diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/date_utils.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/date_utils.ts new file mode 100644 index 0000000000000..c333a1dbe8524 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/date_utils.ts @@ -0,0 +1,56 @@ +/* + * 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 dateMath from '@elastic/datemath'; +import { TimeBuckets } from './time_buckets'; +import { TimeRange } from '../../../../../../../../plugins/data/public'; +import { IUiSettingsClient } from '../../../../../../../../core/public'; + +export function toAbsoluteDates(range: TimeRange) { + const fromDate = dateMath.parse(range.from); + const toDate = dateMath.parse(range.to, { roundUp: true }); + + if (!fromDate || !toDate) { + return; + } + + return { + from: fromDate.toDate(), + to: toDate.toDate(), + }; +} + +export function getCalculateAutoTimeExpression(uiSettings: IUiSettingsClient) { + return function calculateAutoTimeExpression(range: TimeRange) { + const dates = toAbsoluteDates(range); + if (!dates) { + return; + } + + const buckets = new TimeBuckets({ uiSettings }); + + buckets.setInterval('auto'); + buckets.setBounds({ + min: dates.from, + max: dates.to, + }); + + return buckets.getInterval().expression; + }; +} diff --git a/src/legacy/ui/public/time_buckets/calc_auto_interval.test.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_auto_interval.test.ts similarity index 100% rename from src/legacy/ui/public/time_buckets/calc_auto_interval.test.ts rename to src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_auto_interval.test.ts diff --git a/src/legacy/ui/public/time_buckets/calc_auto_interval.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_auto_interval.ts similarity index 100% rename from src/legacy/ui/public/time_buckets/calc_auto_interval.ts rename to src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_auto_interval.ts diff --git a/src/legacy/ui/public/time_buckets/calc_es_interval.js b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts similarity index 81% rename from src/legacy/ui/public/time_buckets/calc_es_interval.js rename to src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts index abfaa50c1505f..3e7d315a0a42a 100644 --- a/src/legacy/ui/public/time_buckets/calc_es_interval.js +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts @@ -17,13 +17,20 @@ * under the License. */ -import dateMath from '@elastic/datemath'; +import moment from 'moment'; +import dateMath, { Unit } from '@elastic/datemath'; -import { parseEsInterval } from '../../../core_plugins/data/public'; +import { parseEsInterval } from '../../../../../../common'; const unitsDesc = dateMath.unitsDesc; const largeMax = unitsDesc.indexOf('M'); +export interface EsInterval { + expression: string; + unit: Unit; + value: number; +} + /** * Convert a moment.duration into an es * compatible expression, and provide @@ -32,7 +39,7 @@ const largeMax = unitsDesc.indexOf('M'); * @param {moment.duration} duration * @return {object} */ -export function convertDurationToNormalizedEsInterval(duration) { +export function convertDurationToNormalizedEsInterval(duration: moment.Duration): EsInterval { for (let i = 0; i < unitsDesc.length; i++) { const unit = unitsDesc[i]; const val = duration.as(unit); @@ -47,7 +54,7 @@ export function convertDurationToNormalizedEsInterval(duration) { return { value: val, - unit: unit, + unit, expression: val + unit, }; } @@ -61,7 +68,7 @@ export function convertDurationToNormalizedEsInterval(duration) { }; } -export function convertIntervalToEsInterval(interval) { +export function convertIntervalToEsInterval(interval: string): EsInterval { const { value, unit } = parseEsInterval(interval); return { value, diff --git a/src/legacy/ui/public/time_buckets/index.js b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/index.ts similarity index 100% rename from src/legacy/ui/public/time_buckets/index.js rename to src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/index.ts diff --git a/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts new file mode 100644 index 0000000000000..9f43181932d7e --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts @@ -0,0 +1,438 @@ +/* + * 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 moment from 'moment'; + +import { IUiSettingsClient } from '../../../../../../../../../core/public'; +import { parseInterval } from '../../../../../../../../../plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; +import { + convertDurationToNormalizedEsInterval, + convertIntervalToEsInterval, + EsInterval, +} from './calc_es_interval'; + +interface Bounds { + min: Date | number | null; + max: Date | number | null; +} + +interface TimeBucketsInterval extends moment.Duration { + // TODO double-check whether all of these are needed + description: string; + esValue: EsInterval['value']; + esUnit: EsInterval['unit']; + expression: EsInterval['expression']; + overflow: moment.Duration | boolean; + preScaled?: moment.Duration; + scale?: number; + scaled?: boolean; +} + +function isObject(o: any): o is Record { + return _.isObject(o); +} + +function isString(s: any): s is string { + return _.isString(s); +} + +function isValidMoment(m: any): boolean { + return m && 'isValid' in m && m.isValid(); +} + +interface TimeBucketsConfig { + uiSettings: IUiSettingsClient; +} + +/** + * Helper class for wrapping the concept of an "Interval", + * which describes a timespan that will separate moments. + * + * @param {state} object - one of "" + * @param {[type]} display [description] + */ +export class TimeBuckets { + private getConfig: (key: string) => any; + + private _lb: Bounds['min'] = null; + private _ub: Bounds['max'] = null; + private _originalInterval: string | null = null; + private _i?: moment.Duration | 'auto'; + + // because other parts of Kibana arbitrarily add properties + [key: string]: any; + + static __cached__(self: TimeBuckets) { + let cache: any = {}; + const sameMoment = same(moment.isMoment); + const sameDuration = same(moment.isDuration); + + const desc: Record = { + __cached__: { + value: self, + }, + }; + + const breakers: Record = { + setBounds: 'bounds', + clearBounds: 'bounds', + setInterval: 'interval', + }; + + const resources: Record = { + bounds: { + setup() { + return [self._lb, self._ub]; + }, + changes(prev: any) { + return !sameMoment(prev[0], self._lb) || !sameMoment(prev[1], self._ub); + }, + }, + interval: { + setup() { + return self._i; + }, + changes(prev: any) { + return !sameDuration(prev, self._i); + }, + }, + }; + + function cachedGetter(prop: string) { + return { + value: (...rest: any) => { + if (cache.hasOwnProperty(prop)) { + return cache[prop]; + } + + return (cache[prop] = self[prop](...rest)); + }, + }; + } + + function cacheBreaker(prop: string) { + const resource = resources[breakers[prop]]; + const setup = resource.setup; + const changes = resource.changes; + const fn = self[prop]; + + return { + value: (...args: any) => { + const prev = setup.call(self); + const ret = fn.apply(self, ...args); + + if (changes.call(self, prev)) { + cache = {}; + } + + return ret; + }, + }; + } + + function same(checkType: any) { + return function(a: any, b: any) { + if (a === b) return true; + if (checkType(a) === checkType(b)) return +a === +b; + return false; + }; + } + + _.forOwn(TimeBuckets.prototype, (fn, prop) => { + if (!prop || prop[0] === '_') return; + + if (breakers.hasOwnProperty(prop)) { + desc[prop] = cacheBreaker(prop); + } else { + desc[prop] = cachedGetter(prop); + } + }); + + return Object.create(self, desc); + } + + constructor({ uiSettings }: TimeBucketsConfig) { + this.getConfig = (key: string) => uiSettings.get(key); + return TimeBuckets.__cached__(this); + } + + /** + * Get a moment duration object representing + * the distance between the bounds, if the bounds + * are set. + * + * @return {moment.duration|undefined} + */ + private getDuration(): moment.Duration | undefined { + if (this._ub === null || this._lb === null || !this.hasBounds()) { + return; + } + const difference = (this._ub as number) - (this._lb as number); + return moment.duration(difference, 'ms'); + } + + /** + * Set the bounds that these buckets are expected to cover. + * This is required to support interval "auto" as well + * as interval scaling. + * + * @param {object} input - an object with properties min and max, + * representing the edges for the time span + * we should cover + * + * @returns {undefined} + */ + setBounds(input?: Bounds | Bounds[]) { + if (!input) return this.clearBounds(); + + let bounds; + if (_.isPlainObject(input) && !Array.isArray(input)) { + // accept the response from timefilter.getActiveBounds() + bounds = [input.min, input.max]; + } else { + bounds = Array.isArray(input) ? input : []; + } + + const moments = _(bounds) + .map(_.ary(moment, 1)) + .sortBy(Number); + + const valid = moments.size() === 2 && moments.every(isValidMoment); + if (!valid) { + this.clearBounds(); + throw new Error('invalid bounds set: ' + input); + } + + this._lb = moments.shift() as any; + this._ub = moments.pop() as any; + + const duration = this.getDuration(); + if (!duration || duration.asSeconds() < 0) { + throw new TypeError('Intervals must be positive'); + } + } + + /** + * Clear the stored bounds + * + * @return {undefined} + */ + clearBounds() { + this._lb = this._ub = null; + } + + /** + * Check to see if we have received bounds yet + * + * @return {Boolean} + */ + hasBounds(): boolean { + return isValidMoment(this._ub) && isValidMoment(this._lb); + } + + /** + * Return the current bounds, if we have any. + * + * THIS DOES NOT CLONE THE BOUNDS, so editing them + * may have unexpected side-effects. Always + * call bounds.min.clone() before editing + * + * @return {object|undefined} - If bounds are not defined, this + * returns undefined, else it returns the bounds + * for these buckets. This object has two props, + * min and max. Each property will be a moment() + * object + * + */ + getBounds(): Bounds | undefined { + if (!this.hasBounds()) return; + return { + min: this._lb, + max: this._ub, + }; + } + + /** + * Update the interval at which buckets should be + * generated. + * + * Input can be one of the following: + * - Any object from src/legacy/ui/agg_types.js + * - "auto" + * - Pass a valid moment unit + * - a moment.duration object. + * + * @param {object|string|moment.duration} input - see desc + */ + setInterval(input: null | string | Record | moment.Duration) { + let interval = input; + + // selection object -> val + if (isObject(input) && !moment.isDuration(input)) { + interval = input.val; + } + + if (!interval || interval === 'auto') { + this._i = 'auto'; + return; + } + + if (isString(interval)) { + input = interval; + + // Preserve the original units because they're lost when the interval is converted to a + // moment duration object. + this._originalInterval = input; + + interval = parseInterval(interval); + if (interval === null || +interval === 0) { + interval = null; + } + } + + // if the value wasn't converted to a duration, and isn't + // already a duration, we have a problem + if (!moment.isDuration(interval)) { + throw new TypeError('"' + input + '" is not a valid interval.'); + } + + this._i = interval; + } + + /** + * Get the interval for the buckets. If the + * number of buckets created by the interval set + * is larger than config:histogram:maxBars then the + * interval will be scaled up. If the number of buckets + * created is less than one, the interval is scaled back. + * + * The interval object returned is a moment.duration + * object that has been decorated with the following + * properties. + * + * interval.description: a text description of the interval. + * designed to be used list "field per {{ desc }}". + * - "minute" + * - "10 days" + * - "3 years" + * + * interval.expression: the elasticsearch expression that creates this + * interval. If the interval does not properly form an elasticsearch + * expression it will be forced into one. + * + * interval.scaled: the interval was adjusted to + * accommodate the maxBars setting. + * + * interval.scale: the number that y-values should be + * multiplied by + */ + getInterval(useNormalizedEsInterval = true): TimeBucketsInterval { + const duration = this.getDuration(); + + // either pull the interval from state or calculate the auto-interval + const readInterval = () => { + const interval = this._i; + if (moment.isDuration(interval)) return interval; + return calcAutoIntervalNear(this.getConfig('histogram:barTarget'), Number(duration)); + }; + + const parsedInterval = readInterval(); + + // check to see if the interval should be scaled, and scale it if so + const maybeScaleInterval = (interval: moment.Duration) => { + if (!this.hasBounds() || !duration) { + return interval; + } + + const maxLength: number = this.getConfig('histogram:maxBars'); + const approxLen = Number(duration) / Number(interval); + + let scaled; + + if (approxLen > maxLength) { + scaled = calcAutoIntervalLessThan(maxLength, Number(duration)); + } else { + return interval; + } + + if (+scaled === +interval) return interval; + + interval = decorateInterval(interval); + return Object.assign(scaled, { + preScaled: interval, + scale: Number(interval) / Number(scaled), + scaled: true, + }); + }; + + // append some TimeBuckets specific props to the interval + const decorateInterval = (interval: moment.Duration): TimeBucketsInterval => { + const esInterval = useNormalizedEsInterval + ? convertDurationToNormalizedEsInterval(interval) + : convertIntervalToEsInterval(String(this._originalInterval)); + const prettyUnits = moment.normalizeUnits(esInterval.unit); + + return Object.assign(interval, { + description: + esInterval.value === 1 ? prettyUnits : esInterval.value + ' ' + prettyUnits + 's', + esValue: esInterval.value, + esUnit: esInterval.unit, + expression: esInterval.expression, + overflow: + Number(duration) > Number(interval) + ? moment.duration(Number(interval) - Number(duration)) + : false, + }); + }; + + if (useNormalizedEsInterval) { + return decorateInterval(maybeScaleInterval(parsedInterval)); + } else { + return decorateInterval(parsedInterval); + } + } + + /** + * Get a date format string that will represent dates that + * progress at our interval. + * + * Since our interval can be as small as 1ms, the default + * date format is usually way too much. with `dateFormat:scaled` + * users can modify how dates are formatted within series + * produced by TimeBuckets + * + * @return {string} + */ + getScaledDateFormat() { + const interval = this.getInterval(); + const rules = this.getConfig('dateFormat:scaled'); + + for (let i = rules.length - 1; i >= 0; i--) { + const rule = rules[i]; + if (!rule[0] || (interval && interval >= moment.duration(rule[0]))) { + return rule[1]; + } + } + + return this.getConfig('dateFormat'); + } +} diff --git a/src/legacy/core_plugins/data/public/search/aggs/index.ts b/src/legacy/core_plugins/data/public/search/aggs/index.ts index f6914c36f6c05..be44e04a0129b 100644 --- a/src/legacy/core_plugins/data/public/search/aggs/index.ts +++ b/src/legacy/core_plugins/data/public/search/aggs/index.ts @@ -27,6 +27,7 @@ export { aggTypes } from './agg_types'; export { AggConfig } from './agg_config'; export { AggConfigs } from './agg_configs'; export { FieldParamType } from './param_types'; +export { getCalculateAutoTimeExpression } from './buckets/lib/date_utils'; export { MetricAggType } from './metrics/metric_agg_type'; export { AggTypeFilters } from './filter'; export { aggTypeFieldFilters, AggTypeFieldFilters } from './param_types/filter'; @@ -43,11 +44,12 @@ export { export { AggParamType } from './param_types/agg'; export { AggGroupNames, aggGroupNamesMap } from './agg_groups'; export { intervalOptions } from './buckets/_interval_options'; // only used in Discover -export { isDateHistogramBucketAggConfig, setBounds } from './buckets/date_histogram'; +export { isDateHistogramBucketAggConfig } from './buckets/date_histogram'; export { termsAggFilter } from './buckets/terms'; export { isType, isStringType } from './buckets/migrate_include_exclude_format'; export { CidrMask } from './buckets/lib/cidr_mask'; export { convertDateRangeToString } from './buckets/date_range'; +export { toAbsoluteDates } from './buckets/lib/date_utils'; export { convertIPRangeToString } from './buckets/ip_range'; export { aggTypeFilters, propFilter } from './filter'; export { OptionedParamType } from './param_types/optioned'; diff --git a/src/legacy/core_plugins/data/public/search/mocks.ts b/src/legacy/core_plugins/data/public/search/mocks.ts index 86b6a928dc5b4..5629f597edff4 100644 --- a/src/legacy/core_plugins/data/public/search/mocks.ts +++ b/src/legacy/core_plugins/data/public/search/mocks.ts @@ -17,8 +17,11 @@ * under the License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { coreMock } from '../../../../../../src/core/public/mocks'; import { SearchSetup, SearchStart } from './search_service'; import { AggTypesRegistrySetup, AggTypesRegistryStart } from './aggs/agg_types_registry'; +import { getCalculateAutoTimeExpression } from './aggs'; import { AggConfigs } from './aggs/agg_configs'; import { mockAggTypesRegistry } from './aggs/test_helpers'; @@ -41,12 +44,12 @@ const aggTypeConfigMock = () => ({ params: [aggTypeBaseParamMock()], }); -export const aggTypesRegistrySetupMock = (): MockedKeys => ({ +export const aggTypesRegistrySetupMock = (): AggTypesRegistrySetup => ({ registerBucket: jest.fn(), registerMetric: jest.fn(), }); -export const aggTypesRegistryStartMock = (): MockedKeys => ({ +export const aggTypesRegistryStartMock = (): AggTypesRegistryStart => ({ get: jest.fn().mockImplementation(aggTypeConfigMock), getBuckets: jest.fn().mockImplementation(() => [aggTypeConfigMock()]), getMetrics: jest.fn().mockImplementation(() => [aggTypeConfigMock()]), @@ -56,14 +59,16 @@ export const aggTypesRegistryStartMock = (): MockedKeys = })), }); -export const searchSetupMock = (): MockedKeys => ({ +export const searchSetupMock = (): SearchSetup => ({ aggs: { + calculateAutoTimeExpression: getCalculateAutoTimeExpression(coreMock.createSetup().uiSettings), types: aggTypesRegistrySetupMock(), }, }); -export const searchStartMock = (): MockedKeys => ({ +export const searchStartMock = (): SearchStart => ({ aggs: { + calculateAutoTimeExpression: getCalculateAutoTimeExpression(coreMock.createStart().uiSettings), createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { schemas, @@ -78,7 +83,6 @@ export const searchStartMock = (): MockedKeys => ({ FieldParamType: jest.fn(), MetricAggType: jest.fn(), parentPipelineAggHelper: jest.fn() as any, - setBounds: jest.fn(), siblingPipelineAggHelper: jest.fn() as any, }, }, diff --git a/src/legacy/core_plugins/data/public/search/search_service.ts b/src/legacy/core_plugins/data/public/search/search_service.ts index 6754c0e3551af..a38cc98c837ce 100644 --- a/src/legacy/core_plugins/data/public/search/search_service.ts +++ b/src/legacy/core_plugins/data/public/search/search_service.ts @@ -29,14 +29,15 @@ import { AggConfigs, CreateAggConfigParams, FieldParamType, + getCalculateAutoTimeExpression, MetricAggType, aggTypeFieldFilters, - setBounds, parentPipelineAggHelper, siblingPipelineAggHelper, } from './aggs'; interface AggsSetup { + calculateAutoTimeExpression: ReturnType; types: AggTypesRegistrySetup; } @@ -47,11 +48,11 @@ interface AggsStartLegacy { FieldParamType: typeof FieldParamType; MetricAggType: typeof MetricAggType; parentPipelineAggHelper: typeof parentPipelineAggHelper; - setBounds: typeof setBounds; siblingPipelineAggHelper: typeof siblingPipelineAggHelper; } interface AggsStart { + calculateAutoTimeExpression: ReturnType; createAggConfigs: ( indexPattern: IndexPattern, configStates?: CreateAggConfigParams[], @@ -85,6 +86,7 @@ export class SearchService { return { aggs: { + calculateAutoTimeExpression: getCalculateAutoTimeExpression(core.uiSettings), types: aggTypesSetup, }, }; @@ -94,6 +96,7 @@ export class SearchService { const aggTypesStart = this.aggTypesRegistry.start(); return { aggs: { + calculateAutoTimeExpression: getCalculateAutoTimeExpression(core.uiSettings), createAggConfigs: (indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { schemas, @@ -108,7 +111,6 @@ export class SearchService { FieldParamType, MetricAggType, parentPipelineAggHelper, // TODO make static - setBounds, // TODO make static siblingPipelineAggHelper, // TODO make static }, }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html index 3fd3c5b5b7633..18254aeca5094 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html @@ -23,7 +23,7 @@

{{screenTitle}}

-