diff --git a/.eslintrc.js b/.eslintrc.js index 24b04f8be511f0..a7bb204da47751 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -82,12 +82,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/vis_type_markdown/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/core_plugins/vis_type_table/**/*.{js,ts,tsx}'], rules: { @@ -347,9 +341,8 @@ module.exports = { 'src/fixtures/**/*.js', // TODO: this directory needs to be more obviously "public" (or go away) ], settings: { - // instructs import/no-extraneous-dependencies to treat modules - // in plugins/ or ui/ namespace as "core modules" so they don't - // trigger failures for not being listed in package.json + // instructs import/no-extraneous-dependencies to treat certain modules + // as core modules, even if they aren't listed in package.json 'import/core-modules': [ 'plugins', 'legacy/ui', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a0a22446ba31d1..9a4f2b71da1ffb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,6 +86,7 @@ /packages/kbn-es/ @elastic/kibana-operations /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations +/packages/kbn-ui-shared-deps/ @elastic/kibana-operations /src/legacy/server/keystore/ @elastic/kibana-operations /src/legacy/server/pid/ @elastic/kibana-operations /src/legacy/server/sass/ @elastic/kibana-operations diff --git a/docs/visualize/visualize_rollup_data.asciidoc b/docs/visualize/visualize_rollup_data.asciidoc index 110533589cab9c..481cbc6e394182 100644 --- a/docs/visualize/visualize_rollup_data.asciidoc +++ b/docs/visualize/visualize_rollup_data.asciidoc @@ -6,7 +6,7 @@ beta[] You can visualize your rolled up data in a variety of charts, tables, maps, and more. Most visualizations support rolled up data, with the exception of -Timelion, TSVB, and Vega visualizations. +Timelion and Vega visualizations. To get started, go to *Management > Kibana > Index patterns.* If a rollup index is detected in the cluster, *Create index pattern* diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json new file mode 100644 index 00000000000000..9114a414a4da39 --- /dev/null +++ b/examples/state_containers_examples/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "stateContainersExamples", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["state_containers_examples"], + "server": false, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [] +} diff --git a/examples/state_containers_examples/package.json b/examples/state_containers_examples/package.json new file mode 100644 index 00000000000000..b309494a366628 --- /dev/null +++ b/examples/state_containers_examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "state_containers_examples", + "version": "1.0.0", + "main": "target/examples/state_containers_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.7.2" + } +} diff --git a/examples/state_containers_examples/public/app.tsx b/examples/state_containers_examples/public/app.tsx new file mode 100644 index 00000000000000..319680d07f9bc8 --- /dev/null +++ b/examples/state_containers_examples/public/app.tsx @@ -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 { AppMountParameters } from 'kibana/public'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import { createHashHistory, createBrowserHistory } from 'history'; +import { TodoAppPage } from './todo'; + +export interface AppOptions { + appInstanceId: string; + appTitle: string; + historyType: History; +} + +export enum History { + Browser, + Hash, +} + +export const renderApp = ( + { appBasePath, element }: AppMountParameters, + { appInstanceId, appTitle, historyType }: AppOptions +) => { + const history = + historyType === History.Browser + ? createBrowserHistory({ basename: appBasePath }) + : createHashHistory(); + ReactDOM.render( + { + const stripTrailingSlash = (path: string) => + path.charAt(path.length - 1) === '/' ? path.substr(0, path.length - 1) : path; + const currentAppUrl = stripTrailingSlash(history.createHref(history.location)); + if (historyType === History.Browser) { + // browser history + const basePath = stripTrailingSlash(appBasePath); + return currentAppUrl === basePath && !history.location.search && !history.location.hash; + } else { + // hashed history + return currentAppUrl === '#' && !history.location.search; + } + }} + />, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/webpackShims/angular.js b/examples/state_containers_examples/public/index.ts similarity index 86% rename from webpackShims/angular.js rename to examples/state_containers_examples/public/index.ts index 4857f0f8975bca..bc7ad78574ddb8 100644 --- a/webpackShims/angular.js +++ b/examples/state_containers_examples/public/index.ts @@ -17,6 +17,6 @@ * under the License. */ -require('jquery'); -require('../node_modules/angular/angular'); -module.exports = window.angular; +import { StateContainersExamplesPlugin } from './plugin'; + +export const plugin = () => new StateContainersExamplesPlugin(); diff --git a/examples/state_containers_examples/public/plugin.ts b/examples/state_containers_examples/public/plugin.ts new file mode 100644 index 00000000000000..beb7b93dbc5b66 --- /dev/null +++ b/examples/state_containers_examples/public/plugin.ts @@ -0,0 +1,52 @@ +/* + * 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 { AppMountParameters, CoreSetup, Plugin } from 'kibana/public'; + +export class StateContainersExamplesPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'state-containers-example-browser-history', + title: 'State containers example - browser history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '1', + appTitle: 'Routing with browser history', + historyType: History.Browser, + }); + }, + }); + core.application.register({ + id: 'state-containers-example-hash-history', + title: 'State containers example - hash history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '2', + appTitle: 'Routing with hash history', + historyType: History.Hash, + }); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx new file mode 100644 index 00000000000000..84defb4a91e3f1 --- /dev/null +++ b/examples/state_containers_examples/public/todo.tsx @@ -0,0 +1,327 @@ +/* + * 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, { useEffect } from 'react'; +import { Link, Route, Router, Switch, useLocation } from 'react-router-dom'; +import { History } from 'history'; +import { + EuiButton, + EuiCheckbox, + EuiFieldText, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; +import { + BaseStateContainer, + INullableBaseStateContainer, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + createStateContainer, + createStateContainerReactHelpers, + PureTransition, + syncStates, + getStateFromKbnUrl, +} from '../../../src/plugins/kibana_utils/public'; +import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; + +interface GlobalState { + text: string; +} +interface GlobalStateAction { + setText: PureTransition; +} +const defaultGlobalState: GlobalState = { text: '' }; +const globalStateContainer = createStateContainer( + defaultGlobalState, + { + setText: state => text => ({ ...state, text }), + } +); + +const GlobalStateHelpers = createStateContainerReactHelpers(); + +const container = createStateContainer(defaultState, pureTransitions); +const { Provider, connect, useTransitions, useState } = createStateContainerReactHelpers< + typeof container +>(); + +interface TodoAppProps { + filter: 'completed' | 'not-completed' | null; +} + +const TodoApp: React.FC = ({ filter }) => { + const { setText } = GlobalStateHelpers.useTransitions(); + const { text } = GlobalStateHelpers.useState(); + const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); + const todos = useState(); + const filteredTodos = todos.filter(todo => { + if (!filter) return true; + if (filter === 'completed') return todo.completed; + if (filter === 'not-completed') return !todo.completed; + return true; + }); + const location = useLocation(); + return ( + <> +
+ + + All + + + + + Completed + + + + + Not Completed + + +
+
    + {filteredTodos.map(todo => ( +
  • + { + editTodo({ + ...todo, + completed: e.target.checked, + }); + }} + label={todo.text} + /> + { + deleteTodo(todo.id); + }} + > + Delete + +
  • + ))} +
+
{ + const inputRef = (e.target as HTMLFormElement).elements.namedItem( + 'newTodo' + ) as HTMLInputElement; + if (!inputRef || !inputRef.value) return; + addTodo({ + text: inputRef.value, + completed: false, + id: todos.map(todo => todo.id).reduce((a, b) => Math.max(a, b), 0) + 1, + }); + inputRef.value = ''; + e.preventDefault(); + }} + > + + +
+ + setText(e.target.value)} /> +
+ + ); +}; + +const TodoAppConnected = GlobalStateHelpers.connect(() => ({}))( + connect(() => ({}))(TodoApp) +); + +export const TodoAppPage: React.FC<{ + history: History; + appInstanceId: string; + appTitle: string; + appBasePath: string; + isInitialRoute: () => boolean; +}> = props => { + const initialAppUrl = React.useRef(window.location.href); + const [useHashedUrl, setUseHashedUrl] = React.useState(false); + + /** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage and tries to restore it on "componentDidMount" + */ + useUrlTracker(`lastUrlTracker:${props.appInstanceId}`, props.history, urlToRestore => { + // shouldRestoreUrl: + // App decides if it should restore url or not + // In this specific case, restore only if navigated to initial route + if (props.isInitialRoute()) { + // navigated to the base path, so should restore the url + return true; + } else { + // navigated to specific route, so should not restore the url + return false; + } + }); + + useEffect(() => { + // have to sync with history passed to react-router + // history v5 will be singleton and this will not be needed + const kbnUrlStateStorage = createKbnUrlStateStorage({ + useHash: useHashedUrl, + history: props.history, + }); + + const sessionStorageStateStorage = createSessionStorageStateStorage(); + + /** + * Restoring global state: + * State restoration similar to what GlobalState in legacy world did + * It restores state both from url and from session storage + */ + const globalStateKey = `_g`; + const globalStateFromInitialUrl = getStateFromKbnUrl( + globalStateKey, + initialAppUrl.current + ); + const globalStateFromCurrentUrl = kbnUrlStateStorage.get(globalStateKey); + const globalStateFromSessionStorage = sessionStorageStateStorage.get( + globalStateKey + ); + + const initialGlobalState: GlobalState = { + ...defaultGlobalState, + ...globalStateFromCurrentUrl, + ...globalStateFromSessionStorage, + ...globalStateFromInitialUrl, + }; + globalStateContainer.set(initialGlobalState); + kbnUrlStateStorage.set(globalStateKey, initialGlobalState, { replace: true }); + sessionStorageStateStorage.set(globalStateKey, initialGlobalState); + + /** + * Restoring app local state: + * State restoration similar to what AppState in legacy world did + * It restores state both from url + */ + const appStateKey = `_todo-${props.appInstanceId}`; + const initialAppState: TodoState = + getStateFromKbnUrl(appStateKey, initialAppUrl.current) || + kbnUrlStateStorage.get(appStateKey) || + defaultState; + container.set(initialAppState); + kbnUrlStateStorage.set(appStateKey, initialAppState, { replace: true }); + + // start syncing only when made sure, that state in synced + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: appStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: sessionStorageStateStorage, + }, + ]); + + start(); + + return () => { + stop(); + + // reset state containers + container.set(defaultState); + globalStateContainer.set(defaultGlobalState); + }; + }, [props.appInstanceId, props.history, useHashedUrl]); + + return ( + + + + + + + +

+ State sync example. Instance: ${props.appInstanceId}. {props.appTitle} +

+
+ setUseHashedUrl(!useHashedUrl)}> + {useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'} + +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +}; + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + if (Array.isArray(defaultState)) { + stateContainer.set(state || defaultState); + } else { + stateContainer.set({ + ...defaultState, + ...state, + }); + } + }, + }; +} diff --git a/examples/state_containers_examples/tsconfig.json b/examples/state_containers_examples/tsconfig.json new file mode 100644 index 00000000000000..091130487791bd --- /dev/null +++ b/examples/state_containers_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/package.json b/package.json index a91e9b80eb49d2..0ed74dd65d1ab9 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "packages": [ "packages/*", "x-pack", + "x-pack/plugins/*", "x-pack/legacy/plugins/*", "examples/*", "test/plugin_functional/plugins/*", @@ -133,6 +134,7 @@ "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", + "@kbn/ui-shared-deps": "1.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", "@types/node-forge": "^0.9.0", @@ -169,7 +171,6 @@ "elastic-apm-node": "^3.2.0", "elasticsearch": "^16.5.0", "elasticsearch-browser": "^16.5.0", - "encode-uri-query": "1.0.1", "execa": "^3.2.0", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", diff --git a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js index c51168ae2d91c3..e02c38494991a9 100755 --- a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js +++ b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js @@ -30,8 +30,6 @@ exports.getWebpackConfig = function(kibanaPath, projectRoot, config) { ui: fromKibana('src/legacy/ui/public'), test_harness: fromKibana('src/test_harness/public'), querystring: 'querystring-browser', - moment$: fromKibana('webpackShims/moment'), - 'moment-timezone$': fromKibana('webpackShims/moment-timezone'), // Dev defaults for test bundle https://github.com/elastic/kibana/blob/6998f074542e8c7b32955db159d15661aca253d7/src/core_plugins/tests_bundle/index.js#L73-L78 ng_mock$: fromKibana('src/test_utils/public/ng_mock'), diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c806547595089e..a3debf78fb8c86 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -58268,6 +58268,7 @@ function getProjectPaths({ if (!ossOnly) { projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index 6d5e67dca7d139..6ba8d58a26f88f 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -46,6 +46,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option if (!ossOnly) { projectPaths.push(resolve(rootPath, 'x-pack')); + projectPaths.push(resolve(rootPath, 'x-pack/plugins/*')); projectPaths.push(resolve(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-ui-shared-deps/README.md b/packages/kbn-ui-shared-deps/README.md new file mode 100644 index 00000000000000..3d3ee37ca5a755 --- /dev/null +++ b/packages/kbn-ui-shared-deps/README.md @@ -0,0 +1,3 @@ +# `@kbn/ui-shared-deps` + +Shared dependencies that must only have a single instance are installed and re-exported from here. To consume them, import the package and merge the `externals` export into your webpack config so that all references to the supported modules will be remapped to use the global versions. \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js new file mode 100644 index 00000000000000..250abd162f91d7 --- /dev/null +++ b/packages/kbn-ui-shared-deps/entry.js @@ -0,0 +1,39 @@ +/* + * 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. + */ + +// must load before angular +export const Jquery = require('jquery'); +window.$ = window.jQuery = Jquery; + +export const Angular = require('angular'); +export const ElasticCharts = require('@elastic/charts'); +export const ElasticEui = require('@elastic/eui'); +export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); +export const ElasticEuiLightTheme = require('@elastic/eui/dist/eui_theme_light.json'); +export const ElasticEuiDarkTheme = require('@elastic/eui/dist/eui_theme_dark.json'); +export const Moment = require('moment'); +export const MomentTimezone = require('moment-timezone/moment-timezone'); +export const React = require('react'); +export const ReactDom = require('react-dom'); +export const ReactIntl = require('react-intl'); +export const ReactRouter = require('react-router'); // eslint-disable-line +export const ReactRouterDom = require('react-router-dom'); + +// load timezone data into moment-timezone +Moment.tz.load(require('moment-timezone/data/packed/latest.json')); diff --git a/packages/kbn-ui-shared-deps/index.d.ts b/packages/kbn-ui-shared-deps/index.d.ts new file mode 100644 index 00000000000000..132445bbde7451 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.d.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** + * Absolute path to the distributable directory + */ +export const distDir: string; + +/** + * Filename of the main bundle file in the distributable directory + */ +export const distFilename: string; + +/** + * Filename of the dark-theme css file in the distributable directory + */ +export const darkCssDistFilename: string; + +/** + * Filename of the light-theme css file in the distributable directory + */ +export const lightCssDistFilename: string; + +/** + * Externals mapping inteded to be used in a webpack config + */ +export const externals: { + [key: string]: string; +}; diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js new file mode 100644 index 00000000000000..cef25295b35d74 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.js @@ -0,0 +1,41 @@ +/* + * 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. + */ + +const Path = require('path'); + +exports.distDir = Path.resolve(__dirname, 'target'); +exports.distFilename = 'kbn-ui-shared-deps.js'; +exports.lightCssDistFilename = 'kbn-ui-shared-deps.light.css'; +exports.darkCssDistFilename = 'kbn-ui-shared-deps.dark.css'; +exports.externals = { + angular: '__kbnSharedDeps__.Angular', + '@elastic/charts': '__kbnSharedDeps__.ElasticCharts', + '@elastic/eui': '__kbnSharedDeps__.ElasticEui', + '@elastic/eui/lib/services': '__kbnSharedDeps__.ElasticEuiLibServices', + '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.ElasticEuiLightTheme', + '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.ElasticEuiDarkTheme', + jquery: '__kbnSharedDeps__.Jquery', + moment: '__kbnSharedDeps__.Moment', + 'moment-timezone': '__kbnSharedDeps__.MomentTimezone', + react: '__kbnSharedDeps__.React', + 'react-dom': '__kbnSharedDeps__.ReactDom', + 'react-intl': '__kbnSharedDeps__.ReactIntl', + 'react-router': '__kbnSharedDeps__.ReactRouter', + 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', +}; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json new file mode 100644 index 00000000000000..014467d204d96c --- /dev/null +++ b/packages/kbn-ui-shared-deps/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kbn/ui-shared-deps", + "version": "1.0.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "node scripts/build", + "kbn:bootstrap": "node scripts/build --dev", + "kbn:watch": "node scripts/build --watch" + }, + "devDependencies": { + "@elastic/eui": "17.3.1", + "@elastic/charts": "^16.1.0", + "@kbn/dev-utils": "1.0.0", + "@yarnpkg/lockfile": "^1.1.0", + "angular": "^1.7.9", + "css-loader": "^2.1.1", + "del": "^5.1.0", + "jquery": "^3.4.1", + "mini-css-extract-plugin": "0.8.0", + "moment": "^2.24.0", + "moment-timezone": "^0.5.27", + "react-dom": "^16.12.0", + "react-intl": "^2.8.0", + "react": "^16.12.0", + "read-pkg": "^5.2.0", + "webpack": "4.41.0" + } +} \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js new file mode 100644 index 00000000000000..8b7c22dac24ff3 --- /dev/null +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -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. + */ + +const Path = require('path'); + +const { run, createFailError } = require('@kbn/dev-utils'); +const webpack = require('webpack'); +const Stats = require('webpack/lib/Stats'); +const del = require('del'); + +const { getWebpackConfig } = require('../webpack.config'); + +run( + async ({ log, flags }) => { + log.info('cleaning previous build output'); + await del(Path.resolve(__dirname, '../target')); + + const compiler = webpack( + getWebpackConfig({ + dev: flags.dev, + }) + ); + + /** @param {webpack.Stats} stats */ + const onCompilationComplete = stats => { + const took = Math.round((stats.endTime - stats.startTime) / 1000); + + if (!stats.hasErrors() && !stats.hasWarnings()) { + log.success(`webpack completed in about ${took} seconds`); + return; + } + + throw createFailError( + `webpack failure in about ${took} seconds\n${stats.toString({ + colors: true, + ...Stats.presetToOptions('minimal'), + })}` + ); + }; + + if (flags.watch) { + compiler.hooks.done.tap('report on stats', stats => { + try { + onCompilationComplete(stats); + } catch (error) { + log.error(error.message); + } + }); + + compiler.hooks.watchRun.tap('report on start', () => { + process.stdout.cursorTo(0, 0); + process.stdout.clearScreenDown(); + log.info('Running webpack compilation...'); + }); + + compiler.watch({}, error => { + if (error) { + log.error('Fatal webpack error'); + log.error(error); + process.exit(1); + } + }); + + return; + } + + onCompilationComplete( + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else { + resolve(stats); + } + }); + }) + ); + }, + { + description: 'build @kbn/ui-shared-deps', + flags: { + boolean: ['watch', 'dev'], + help: ` + --watch Run in watch mode + --dev Build development friendly version + `, + }, + } +); diff --git a/packages/kbn-ui-shared-deps/tsconfig.json b/packages/kbn-ui-shared-deps/tsconfig.json new file mode 100644 index 00000000000000..c5c3cba147fcfb --- /dev/null +++ b/packages/kbn-ui-shared-deps/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "index.d.ts" + ] +} diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js new file mode 100644 index 00000000000000..87cca2cc897f84 --- /dev/null +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -0,0 +1,90 @@ +/* + * 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. + */ + +const Path = require('path'); + +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { REPO_ROOT } = require('@kbn/dev-utils'); +const webpack = require('webpack'); + +const SharedDeps = require('./index'); + +const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); + +exports.getWebpackConfig = ({ dev = false } = {}) => ({ + mode: dev ? 'development' : 'production', + entry: { + [SharedDeps.distFilename.replace(/\.js$/, '')]: './entry.js', + [SharedDeps.darkCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_dark.css', + '@elastic/charts/dist/theme_only_dark.css', + ], + [SharedDeps.lightCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_light.css', + '@elastic/charts/dist/theme_only_light.css', + ], + }, + context: __dirname, + devtool: dev ? '#cheap-source-map' : false, + output: { + path: SharedDeps.distDir, + filename: '[name].js', + sourceMapFilename: '[file].map', + publicPath: '__REPLACE_WITH_PUBLIC_PATH__', + devtoolModuleFilenameTemplate: info => + `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, + library: '__kbnSharedDeps__', + }, + + module: { + noParse: [MOMENT_SRC], + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + + resolve: { + alias: { + moment: MOMENT_SRC, + }, + }, + + optimization: { + noEmitOnErrors: true, + }, + + performance: { + // NOTE: we are disabling this as those hints + // are more tailored for the final bundles result + // and not for the webpack compilations performance itself + hints: false, + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].css', + }), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': dev ? '"development"' : '"production"', + }), + ], +}); diff --git a/src/core/README.md b/src/core/README.md index 8863658e0040c3..f4539191d448bd 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -7,6 +7,7 @@ Core Plugin API Documentation: - [Core Public API](/docs/development/core/public/kibana-plugin-public.md) - [Core Server API](/docs/development/core/server/kibana-plugin-server.md) - [Conventions for Plugins](./CONVENTIONS.md) + - [Testing Kibana Plugins](./TESTING.md) - [Migration guide for porting existing plugins](./MIGRATION.md) Internal Documentation: diff --git a/src/core/TESTING.md b/src/core/TESTING.md new file mode 100644 index 00000000000000..6139820d02a14a --- /dev/null +++ b/src/core/TESTING.md @@ -0,0 +1,254 @@ +# Testing Kibana Plugins + +This document outlines best practices and patterns for testing Kibana Plugins. + +- [Strategy](#strategy) +- [Core Integrations](#core-integrations) + - [Core Mocks](#core-mocks) + - [Strategies for specific Core APIs](#strategies-for-specific-core-apis) + - [HTTP Routes](#http-routes) + - [SavedObjects](#savedobjects) + - [Elasticsearch](#elasticsearch) +- [Plugin Integrations](#plugin-integrations) +- [Plugin Contracts](#plugin-contracts) + +## Strategy + +In general, we recommend three tiers of tests: +- Unit tests: small, fast, exhaustive, make heavy use of mocks for external dependencies +- Integration tests: higher-level tests that verify interactions between systems (eg. HTTP APIs, Elasticsearch API calls, calling other plugin contracts). +- End-to-end tests (e2e): tests that verify user-facing behavior through the browser + +These tiers should roughly follow the traditional ["testing pyramid"](https://martinfowler.com/articles/practical-test-pyramid.html), where there are more exhaustive testing at the unit level, fewer at the integration level, and very few at the functional level. + +## New concerns in the Kibana Platform + +The Kibana Platform introduces new concepts that legacy plugins did not have concern themselves with. Namely: +- **Lifecycles**: plugins now have explicit lifecycle methods that must interop with Core APIs and other plugins. +- **Shared runtime**: plugins now all run in the same process at the same time. On the frontend, this is different behavior than the legacy plugins. Developers should take care not to break other plugins when interacting with their enviornment (Node.js or Browser). +- **Single page application**: Kibana's frontend is now a single-page application where all plugins are running, but only one application is mounted at a time. Plugins need to handle mounting and unmounting, cleanup, and avoid overriding global browser behaviors in this shared space. +- **Dependency management**: plugins must now explicitly declare their dependencies on other plugins, both required and optional. Plugins should ensure to test conditions where a optional dependency is missing. + +Simply porting over existing tests when migrating your plugin to the Kibana Platform will leave blind spots in test coverage. It is highly recommended that plugins add new tests that cover these new concerns. + +## Core Integrations + +### Core Mocks + +When testing a plugin's integration points with Core APIs, it is heavily recommended to utilize the mocks provided in `src/core/server/mocks` and `src/core/public/mocks`. The majority of these mocks are dumb `jest` mocks that mimic the interface of their respective Core APIs, however they do not return realistic return values. + +If the unit under test expects a particular response from a Core API, the test will need to set this return value explicitly. The return values are type checked to match the Core API where possible to ensure that mocks are updated when Core APIs changed. + +#### Example + +```ts +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +test('my test', async () => { + // Setup mock and faked response + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.callAsCurrentUser.mockResolvedValue(/** insert ES response here */); + + // Call unit under test with mocked client + const result = await myFunction(esClient); + + // Assert that client was called with expected arguments + expect(esClient.callAsCurrentUser).toHaveBeenCalledWith(/** expected args */); + // Expect that unit under test returns expected value based on client's response + expect(result).toEqual(/** expected return value */) +}); +``` + +### Strategies for specific Core APIs + +#### HTTP Routes + +_How to test route handlers_ + +### Applications + +Kibana Platform applications have less control over the page than legacy applications did. It is important that your app is built to handle it's co-habitance with other plugins in the browser. Applications are mounted and unmounted from the DOM as the user navigates between them, without full-page refreshes, as a single-page application (SPA). + +These long-lived sessions make cleanup more important than before. It's entirely possible a user has a single browsing session open for weeks at a time, without ever doing a full-page refresh. Common things that need to be cleaned up (and tested!) when your application is unmounted: +- Subscriptions and polling (eg. `uiSettings.get$()`) +- Any Core API calls that set state (eg. `core.chrome.setIsVisible`). +- Open connections (eg. a Websocket) + +While applications do get an opportunity to unmount and run cleanup logic, it is also important that you do not _depend_ on this logic to run. The browser tab may get closed without running cleanup logic, so it is not guaranteed to be run. For instance, you should not depend on unmounting logic to run in order to save state to `localStorage` or to the backend. + +#### Example + +By following the [renderApp](./CONVENTIONS.md#applications) convention, you can greatly reduce the amount of logic in your application's mount function. This makes testing your application's actual rendering logic easier. + +```tsx +/** public/plugin.ts */ +class Plugin { + setup(core) { + core.application.register({ + // id, title, etc. + async mount(params) { + const [{ renderApp }, [coreStart, startDeps]] = await Promise.all([ + import('./application'), + core.getStartServices() + ]); + + return renderApp(params, coreStart, startDeps); + } + }) + } +} +``` + +We _could_ still write tests for this logic, but you may find that you're just asserting the same things that would be covered by type-checks. + +
+See example + +```ts +/** public/plugin.test.ts */ +jest.mock('./application', () => ({ renderApp: jest.fn() })); +import { coreMock } from 'src/core/public/mocks'; +import { renderApp: renderAppMock } from './application'; +import { Plugin } from './plugin'; + +describe('Plugin', () => { + it('registers an app', () => { + const coreSetup = coreMock.createSetup(); + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + expect(coreSetup.application.register).toHaveBeenCalledWith({ + id: 'myApp', + mount: expect.any(Function) + }); + }); + + // Test the glue code from Plugin -> renderApp + it('application.mount wires up dependencies to renderApp', async () => { + const coreSetup = coreMock.createSetup(); + const [coreStartMock, startDepsMock] = await coreSetup.getStartServices(); + const unmountMock = jest.fn(); + renderAppMock.mockReturnValue(unmountMock); + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + // Grab registered mount function + const mount = coreSetup.application.register.mock.calls[0][0].mount; + + const unmount = await mount(params); + expect(renderAppMock).toHaveBeenCalledWith(params, coreStartMock, startDepsMock); + expect(unmount).toBe(unmountMock); + }); +}); +``` + +
+ +The more interesting logic is in `renderApp`: + +```ts +/** public/application.ts */ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { AppMountParams, CoreStart } from 'src/core/public'; +import { AppRoot } from './components/app_root'; + +export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { + // Hide the chrome while this app is mounted for a full screen experience + core.chrome.setIsVisible(false); + + // uiSettings subscription + const uiSettingsClient = core.uiSettings.client; + const pollingSubscription = uiSettingClient.get$('mysetting1').subscribe(async mySetting1 => { + const value = core.http.fetch(/** use `mySetting1` in request **/); + // ... + }); + + // Render app + ReactDOM.render( + , + element + ); + + return () => { + // Unmount UI + ReactDOM.unmountComponentAtNode(element); + // Close any subscriptions + pollingSubscription.unsubscribe(); + // Make chrome visible again + core.chrome.setIsVisible(true); + }; +}; +``` + +In testing `renderApp` you should be verifying that: +1) Your application mounts and unmounts correctly +2) Cleanup logic is completed as expected + +```ts +/** public/application.test.ts */ +import { coreMock } from 'src/core/public/mocks'; +import { renderApp } from './application'; + +describe('renderApp', () => { + it('mounts and unmounts UI', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify some expected DOM element is rendered into the element + const unmount = renderApp(params, core, {}); + expect(params.element.querySelector('.some-app-class')).not.toBeUndefined(); + // Verify the element is empty after unmounting + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('unsubscribes from uiSettings', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + // Create a fake Subject you can use to monitor observers + const settings$ = new Subject(); + core.uiSettings.get$.mockReturnValue(settings$); + + // Verify mounting adds an observer + const unmount = renderApp(params, core, {}); + expect(settings$.observers.length).toBe(1); + // Verify no observers remaining after unmount is called + unmount(); + expect(settings$.observers.length).toBe(0); + }); + + it('resets chrome visibility', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify stateful Core API was called on mount + const unmount = renderApp(params, core, {}); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(false); + core.chrome.setIsVisible.mockClear(); // reset mock + // Verify stateful Core API was called on unmount + unmount(); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(true); + }) +}); +``` + +#### SavedObjects + +_How to test SO operations_ + +#### Elasticsearch + +_How to test ES clients_ + +## Plugin Integrations + +_How to test against specific plugin APIs (eg. data plugin)_ + +## Plugin Contracts + +_How to test your plugin's exposed API_ + +Guidelines: +- Plugins should never interact with other plugins' REST API directly +- Plugins should interact with other plugins via JavaScript contracts +- Exposed contracts need to be well tested to ensure breaking changes are detected easily diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index c3b9b20d848657..a1523781010d47 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -142,6 +142,61 @@ describe('Handler', () => { statusCode: 400, }); }); + + it('accept to receive an array payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .send([{ foo: 'bar' }, { foo: 'dolly' }]) + .expect(200); + + expect(body).toEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + }); + + it('accept to receive a json primitive payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.number(), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .type('json') + .send('12') + .expect(200); + + expect(body).toEqual(12); + }); }); describe('handleLegacyErrors', () => { diff --git a/src/core/server/http/router/validator/validator.test.ts b/src/core/server/http/router/validator/validator.test.ts index 729eb1b60c10a8..e972e2075e7056 100644 --- a/src/core/server/http/router/validator/validator.test.ts +++ b/src/core/server/http/router/validator/validator.test.ts @@ -132,4 +132,62 @@ describe('Router validator', () => { 'The validation rule provided in the handler is not valid' ); }); + + it('should validate and infer type when data is an array', () => { + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.string()), + }).getBody(['foo', 'bar']) + ).toStrictEqual(['foo', 'bar']); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody([1, 2, 3]) + ).toStrictEqual([1, 2, 3]); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }).getBody([{ foo: 'bar' }, { foo: 'dolly' }]) + ).toStrictEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody(['foo', 'bar', 'dolly']) + ).toThrowError('[0]: expected value of type [number] but got [string]'); + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [array] but got [Object]'); + }); + + it('should validate and infer type when data is a primitive', () => { + expect( + RouteValidator.from({ + body: schema.string(), + }).getBody('foobar') + ).toStrictEqual('foobar'); + expect( + RouteValidator.from({ + body: schema.number(), + }).getBody(42) + ).toStrictEqual(42); + expect( + RouteValidator.from({ + body: schema.boolean(), + }).getBody(true) + ).toStrictEqual(true); + + expect(() => + RouteValidator.from({ + body: schema.string(), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [string] but got [Object]'); + expect(() => + RouteValidator.from({ + body: schema.number(), + }).getBody('foobar') + ).toThrowError('expected value of type [number] but got [string]'); + }); }); diff --git a/src/core/server/http/router/validator/validator.ts b/src/core/server/http/router/validator/validator.ts index 65c0a934e6ef00..97dd2bc894f813 100644 --- a/src/core/server/http/router/validator/validator.ts +++ b/src/core/server/http/router/validator/validator.ts @@ -274,7 +274,7 @@ export class RouteValidator

{ // if options.body.output === 'stream' return schema.stream(); } else { - return schema.maybe(schema.nullable(schema.object({}, { allowUnknowns: true }))); + return schema.maybe(schema.nullable(schema.any({}))); } } } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index abf025524522b8..78ac99567d10e8 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -671,7 +671,9 @@ function discoverController( $scope.$watch('state.query', (newQuery, oldQuery) => { if (!_.isEqual(newQuery, oldQuery)) { const query = migrateLegacyQuery(newQuery); - $scope.updateQueryAndFetch({ query }); + if (!_.isEqual(query, newQuery)) { + $scope.updateQueryAndFetch({ query }); + } } }); @@ -817,6 +819,7 @@ function discoverController( title: i18n.translate('kbn.discover.errorLoadingData', { defaultMessage: 'Error loading data', }), + toastMessage: error.shortMessage, }); } }); diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx index 53a7b1caef2a47..c70b6561c3101b 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx @@ -36,7 +36,7 @@ import { MarkdownVisParams } from './types'; function MarkdownOptions({ stateParams, setValue }: VisOptionsProps) { const onMarkdownUpdate = useCallback( (value: MarkdownVisParams['markdown']) => setValue('markdown', value), - [] + [setValue] ); return ( diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js index 3844f809a5257c..83d7ca4084a202 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js @@ -74,7 +74,11 @@ export function KbnAggTable(config, RecursionHelper) { // escape each cell in each row const csvRows = rows.map(function(row) { return Object.entries(row).map(([k, v]) => { - return escape(formatted ? columns.find(c => c.id === k).formatter.convert(v) : v); + const column = columns.find(c => c.id === k); + if (formatted && column) { + return escape(column.formatter.convert(v)); + } + return escape(v); }); }); @@ -110,12 +114,16 @@ export function KbnAggTable(config, RecursionHelper) { if (typeof $scope.dimensions === 'undefined') return; - const { buckets, metrics } = $scope.dimensions; + const { buckets, metrics, splitColumn } = $scope.dimensions; $scope.formattedColumns = table.columns .map(function(col, i) { const isBucket = buckets.find(bucket => bucket.accessor === i); - const dimension = isBucket || metrics.find(metric => metric.accessor === i); + const isSplitColumn = splitColumn + ? splitColumn.find(splitColumn => splitColumn.accessor === i) + : undefined; + const dimension = + isBucket || isSplitColumn || metrics.find(metric => metric.accessor === i); if (!dimension) return; diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index d8a55935b705a0..85b6de26b95164 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -14,6 +14,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { window.onload = function () { var files = [ '{{dllBundlePath}}/vendors.bundle.dll.js', + '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}', '{{regularBundlePath}}/commons.bundle.js', '{{regularBundlePath}}/{{appId}}.bundle.js' ]; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 0b266b8b62726d..a935270d23fced 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -21,6 +21,7 @@ import { createHash } from 'crypto'; import Boom from 'boom'; import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { AppBootstrap } from './bootstrap'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; @@ -41,18 +42,10 @@ export function uiRenderMixin(kbnServer, server, config) { // render all views from ./views server.setupViews(resolve(__dirname, 'views')); - server.exposeStaticDir( - '/node_modules/@elastic/eui/dist/{path*}', - fromRoot('node_modules/@elastic/eui/dist') - ); server.exposeStaticDir( '/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist') ); - server.exposeStaticDir( - '/node_modules/@elastic/charts/dist/{path*}', - fromRoot('node_modules/@elastic/charts/dist') - ); const translationsCache = { translations: null, hash: null }; server.route({ @@ -114,14 +107,12 @@ export function uiRenderMixin(kbnServer, server, config) { `${dllBundlePath}/vendors.style.dll.css`, ...(darkMode ? [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, ] : [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, ]), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, @@ -142,6 +133,7 @@ export function uiRenderMixin(kbnServer, server, config) { regularBundlePath, dllBundlePath, styleSheetPaths, + sharedDepsFilename: UiSharedDeps.distFilename, }, }); diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 9a21a4b1d5439e..efff7f0aa2b46b 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -19,6 +19,7 @@ import { writeFile } from 'fs'; import os from 'os'; + import Boom from 'boom'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; @@ -26,10 +27,10 @@ import webpack from 'webpack'; import Stats from 'webpack/lib/Stats'; import * as threadLoader from 'thread-loader'; import webpackMerge from 'webpack-merge'; -import { DynamicDllPlugin } from './dynamic_dll_plugin'; import WrapperPlugin from 'wrapper-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import { defaults } from 'lodash'; +import { DynamicDllPlugin } from './dynamic_dll_plugin'; import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils'; import { fromRoot } from '../core/server/utils'; @@ -403,6 +404,10 @@ export default class BaseOptimizer { // and not for the webpack compilations performance itself hints: false, }, + + externals: { + ...UiSharedDeps.externals, + }, }; // when running from the distributable define an environment variable we can use @@ -417,17 +422,6 @@ export default class BaseOptimizer { ], }; - // We need to add react-addons (and a few other bits) for enzyme to work. - // https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md - const supportEnzymeConfig = { - externals: { - mocha: 'mocha', - 'react/lib/ExecutionEnvironment': true, - 'react/addons': true, - 'react/lib/ReactContext': true, - }, - }; - const watchingConfig = { plugins: [ new webpack.WatchIgnorePlugin([ @@ -482,9 +476,7 @@ export default class BaseOptimizer { IS_CODE_COVERAGE ? coverageConfig : {}, commonConfig, IS_KIBANA_DISTRIBUTABLE ? isDistributableConfig : {}, - this.uiBundles.isDevMode() - ? webpackMerge(watchingConfig, supportEnzymeConfig) - : productionConfig + this.uiBundles.isDevMode() ? watchingConfig : productionConfig ) ); } @@ -515,22 +507,19 @@ export default class BaseOptimizer { } failedStatsToError(stats) { - const details = stats.toString( - defaults( - { colors: true, warningsFilter: STATS_WARNINGS_FILTER }, - Stats.presetToOptions('minimal') - ) - ); + const details = stats.toString({ + ...Stats.presetToOptions('minimal'), + colors: true, + warningsFilter: STATS_WARNINGS_FILTER, + }); return Boom.internal( `Optimizations failure.\n${details.split('\n').join('\n ')}\n`, - stats.toJson( - defaults({ - warningsFilter: STATS_WARNINGS_FILTER, - ...Stats.presetToOptions('detailed'), - maxModules: 1000, - }) - ) + stats.toJson({ + warningsFilter: STATS_WARNINGS_FILTER, + ...Stats.presetToOptions('detailed'), + maxModules: 1000, + }) ); } diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.js index d3c08fae922647..f0261d44e03474 100644 --- a/src/optimize/bundles_route/bundles_route.js +++ b/src/optimize/bundles_route/bundles_route.js @@ -19,6 +19,7 @@ import { isAbsolute, extname } from 'path'; import LruCache from 'lru-cache'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { createDynamicAssetResponse } from './dynamic_asset_response'; /** @@ -66,6 +67,12 @@ export function createBundlesRoute({ } return [ + buildRouteForBundles( + `${basePublicPath}/bundles/kbn-ui-shared-deps/`, + '/bundles/kbn-ui-shared-deps/', + UiSharedDeps.distDir, + fileHashCache + ), buildRouteForBundles( `${basePublicPath}/bundles/`, '/bundles/', diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index 2a3d3dd659c67e..ecf5def5aa6cac 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -23,6 +23,7 @@ import webpack from 'webpack'; import webpackMerge from 'webpack-merge'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; function generateDLL(config) { const { @@ -145,6 +146,9 @@ function generateDLL(config) { // and not for the webpack compilations performance itself hints: false, }, + externals: { + ...UiSharedDeps.externals, + }, }; } diff --git a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts index 7c90119fcc1bc3..0d5cd6ea17f165 100644 --- a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts +++ b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts @@ -41,7 +41,7 @@ const grammarRuleTranslations: Record = { interface KQLSyntaxErrorData extends Error { found: string; - expected: KQLSyntaxErrorExpected[]; + expected: KQLSyntaxErrorExpected[] | null; location: any; } @@ -53,19 +53,22 @@ export class KQLSyntaxError extends Error { shortMessage: string; constructor(error: KQLSyntaxErrorData, expression: any) { - const translatedExpectations = error.expected.map(expected => { - return grammarRuleTranslations[expected.description] || expected.description; - }); + let message = error.message; + if (error.expected) { + const translatedExpectations = error.expected.map(expected => { + return grammarRuleTranslations[expected.description] || expected.description; + }); - const translatedExpectationText = translatedExpectations.join(', '); + const translatedExpectationText = translatedExpectations.join(', '); - const message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { - defaultMessage: 'Expected {expectedList} but {foundInput} found.', - values: { - expectedList: translatedExpectationText, - foundInput: error.found ? `"${error.found}"` : endOfInputText, - }, - }); + message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { + defaultMessage: 'Expected {expectedList} but {foundInput} found.', + values: { + expectedList: translatedExpectationText, + foundInput: error.found ? `"${error.found}"` : endOfInputText, + }, + }); + } const fullMessage = [message, expression, repeat('-', error.location.start.offset) + '^'].join( '\n' diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 10b7dd2b4da449..cfe89f16e99dd5 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -25,4 +25,5 @@ export * from './overlays'; export * from './ui_settings'; export * from './field_icon'; export * from './table_list_view'; +export { useUrlTracker } from './use_url_tracker'; export { toMountPoint } from './util'; diff --git a/webpackShims/moment.js b/src/plugins/kibana_react/public/use_url_tracker/index.ts similarity index 90% rename from webpackShims/moment.js rename to src/plugins/kibana_react/public/use_url_tracker/index.ts index 31476d18c9562a..fdceaf34e04ee7 100644 --- a/webpackShims/moment.js +++ b/src/plugins/kibana_react/public/use_url_tracker/index.ts @@ -17,4 +17,4 @@ * under the License. */ -module.exports = require('../node_modules/moment/min/moment-with-locales.min.js'); +export { useUrlTracker } from './use_url_tracker'; diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx new file mode 100644 index 00000000000000..d1425a09b2f9c5 --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useUrlTracker } from './use_url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory } from 'history'; + +describe('useUrlTracker', () => { + const key = 'key'; + let storage = new StubBrowserStorage(); + let history = createMemoryHistory(); + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + }); + + it('should track history changes and save them to storage', () => { + expect(storage.getItem(key)).toBeNull(); + const { unmount } = renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(storage.getItem(key)).toBe('/'); + history.push('/change'); + expect(storage.getItem(key)).toBe('/change'); + unmount(); + history.push('/other-change'); + expect(storage.getItem(key)).toBe('/change'); + }); + + it('by default should restore initial url', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, undefined, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should restore initial url if shouldRestoreUrl cb returns true', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => true, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should not restore initial url if shouldRestoreUrl cb returns false', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(history.location.pathname).toBe('/'); + }); +}); diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx new file mode 100644 index 00000000000000..97e69fe22a8420 --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx @@ -0,0 +1,52 @@ +/* + * 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 { History } from 'history'; +import { useLayoutEffect } from 'react'; +import { createUrlTracker } from '../../../kibana_utils/public/'; + +/** + * State management url_tracker in react hook form + * + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + * + * @param key - key to use in storage + * @param history - history instance to use + * @param shouldRestoreUrl - cb if url should be restored + * @param storage - storage to use. window.sessionStorage is default + */ +export function useUrlTracker( + key: string, + history: History, + shouldRestoreUrl: (urlToRestore: string) => boolean = () => true, + storage: Storage = sessionStorage +) { + useLayoutEffect(() => { + const urlTracker = createUrlTracker(key, storage); + const urlToRestore = urlTracker.getTrackedUrl(); + if (urlToRestore && shouldRestoreUrl(urlToRestore)) { + history.replace(urlToRestore); + } + const stopTrackingUrl = urlTracker.startTrackingUrl(history); + return () => { + stopTrackingUrl(); + }; + }, [key, history]); +} diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts new file mode 100644 index 00000000000000..24f8f13f214788 --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { Subject } from 'rxjs'; +import { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; +import { toArray } from 'rxjs/operators'; +import deepEqual from 'fast-deep-equal'; + +describe('distinctUntilChangedWithInitialValue', () => { + it('should skip updates with the same value', async () => { + const subject = new Subject(); + const result = subject.pipe(distinctUntilChangedWithInitialValue(1), toArray()).toPromise(); + + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + + expect(await result).toEqual([2, 3]); + }); + + it('should accept promise as initial value', async () => { + const subject = new Subject(); + const result = subject + .pipe( + distinctUntilChangedWithInitialValue( + new Promise(resolve => { + resolve(1); + setTimeout(() => { + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + }); + }) + ), + toArray() + ) + .toPromise(); + expect(await result).toEqual([2, 3]); + }); + + it('should accept custom comparator', async () => { + const subject = new Subject(); + const result = subject + .pipe(distinctUntilChangedWithInitialValue({ test: 1 }, deepEqual), toArray()) + .toPromise(); + + subject.next({ test: 1 }); + subject.next({ test: 2 }); + subject.next({ test: 2 }); + subject.next({ test: 3 }); + subject.complete(); + + expect(await result).toEqual([{ test: 2 }, { test: 3 }]); + }); +}); diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts new file mode 100644 index 00000000000000..6af9cc1e8ac3ae --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts @@ -0,0 +1,42 @@ +/* + * 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 { MonoTypeOperatorFunction, queueScheduler, scheduled, from } from 'rxjs'; +import { concatAll, distinctUntilChanged, skip } from 'rxjs/operators'; + +export function distinctUntilChangedWithInitialValue( + initialValue: T | Promise, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => + scheduled( + [isPromise(initialValue) ? from(initialValue) : [initialValue], input$], + queueScheduler + ).pipe(concatAll(), distinctUntilChanged(compare), skip(1)); +} + +function isPromise(value: T | Promise): value is Promise { + return ( + !!value && + typeof value === 'object' && + 'then' in value && + typeof value.then === 'function' && + !('subscribe' in value) + ); +} diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index d13a250cedf2e3..eb3bb96c8e8740 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -18,3 +18,4 @@ */ export * from './defer'; +export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 4e792ceef117a5..5c50e152ad46ca 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -19,6 +19,7 @@ import { result as counterResult } from './state_containers/counter'; import { result as todomvcResult } from './state_containers/todomvc'; +import { result as urlSyncResult } from './state_sync/url'; describe('demos', () => { describe('state containers', () => { @@ -33,4 +34,12 @@ describe('demos', () => { ]); }); }); + + describe('state sync', () => { + test('url sync demo works', async () => { + expect(await urlSyncResult).toMatchInlineSnapshot( + `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + ); + }); + }); }); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts new file mode 100644 index 00000000000000..657b64f55a7766 --- /dev/null +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -0,0 +1,70 @@ +/* + * 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 { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; +import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { + createKbnUrlStateStorage, + syncState, + INullableBaseStateContainer, +} from '../../public/state_sync'; + +const tick = () => new Promise(resolve => setTimeout(resolve)); + +const stateContainer = createStateContainer(defaultState, pureTransitions); +const { start, stop } = syncState({ + stateContainer: withDefaultState(stateContainer, defaultState), + storageKey: '_s', + stateStorage: createKbnUrlStateStorage(), +}); + +start(); +export const result = Promise.resolve() + .then(() => { + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers')" + + stateContainer.transitions.add({ + id: 2, + text: 'test', + completed: false, + }); + + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers'),(completed:!f,id:2,text:test))" + + /* actual url updates happens async */ + return tick(); + }) + .then(() => { + stop(); + return window.location.href; + }); + +function withDefaultState( + // eslint-disable-next-line no-shadow + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts index 72f3716147efa7..99b49b401a8b8e 100644 --- a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts +++ b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts @@ -19,7 +19,9 @@ import { mapValues, isString } from 'lodash'; import { FieldMappingSpec, MappingObject } from './types'; -import { ES_FIELD_TYPES } from '../../../data/public'; + +// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin +import { ES_FIELD_TYPES } from '../../../data/common/types'; /** @private */ type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index af2fc9e31b21bb..0ba444c4e93956 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -27,6 +27,34 @@ export * from './render_complete'; export * from './resize_checker'; export * from './state_containers'; export * from './storage'; -export * from './storage/hashed_item_store'; -export * from './state_management/state_hash'; -export * from './state_management/url'; +export { hashedItemStore, HashedItemStore } from './storage/hashed_item_store'; +export { + createStateHash, + persistState, + retrieveState, + isStateHash, +} from './state_management/state_hash'; +export { + hashQuery, + hashUrl, + unhashUrl, + unhashQuery, + createUrlTracker, + createKbnUrlControls, + getStateFromKbnUrl, + getStatesFromKbnUrl, + setStateToKbnUrl, +} from './state_management/url'; +export { + syncState, + syncStates, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + IStateSyncConfig, + ISyncStateRef, + IKbnUrlStateStorage, + INullableBaseStateContainer, + ISessionStorageStateStorage, + StartSyncStateFnType, + StopSyncStateFnType, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 9165181299a90a..95f4c35f2ce01b 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -113,6 +113,13 @@ test('multiple subscribers can subscribe', () => { expect(spy2.mock.calls[1][0]).toEqual({ a: 2 }); }); +test('can create state container without transitions', () => { + const state = { foo: 'bar' }; + const stateContainer = createStateContainer(state); + expect(stateContainer.transitions).toEqual({}); + expect(stateContainer.get()).toEqual(state); +}); + test('creates impure mutators from pure mutators', () => { const { mutators } = create( {}, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index 1ef4a1c0128170..b949a9daed0ae6 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -41,11 +41,11 @@ const freeze: (value: T) => RecursiveReadonly = export const createStateContainer = < State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} >( defaultState: State, - pureTransitions: PureTransitions, + pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors ): ReduxLikeStateContainer => { const data$ = new BehaviorSubject>(freeze(defaultState)); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index 8f5810f3e147d3..c1a35441b637b4 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -193,12 +193,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create< - { - cnt: number; - }, - any - >( + const { store } = create( { cnt: 0, }, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index e94165cc483760..45b34b13251f44 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts @@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions; + const useTransitions = (): Container['transitions'] => useContainer().transitions; const useSelector = ( selector: (state: UnboxState) => Result, diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts index e0a1a18972635e..e120f60e72b8f3 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -42,7 +42,7 @@ export interface BaseStateContainer { export interface StateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -51,7 +51,7 @@ export interface StateContainer< export interface ReduxLikeStateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { getState: () => RecursiveReadonly; diff --git a/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts new file mode 100644 index 00000000000000..c535e965aa772e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts @@ -0,0 +1,61 @@ +/* + * 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 rison, { RisonValue } from 'rison-node'; +import { isStateHash, retrieveState, persistState } from '../state_hash'; + +// should be: +// export function decodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function decodeState(expandedOrHashedState: string): State { + if (isStateHash(expandedOrHashedState)) { + return retrieveState(expandedOrHashedState); + } else { + return (rison.decode(expandedOrHashedState) as unknown) as State; + } +} + +// should be: +// export function encodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function encodeState(state: State, useHash: boolean): string { + if (useHash) { + return persistState(state); + } else { + return rison.encode((state as unknown) as RisonValue); + } +} + +export function hashedStateToExpandedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return encodeState(retrieveState(expandedOrHashedState), false); + } + + return expandedOrHashedState; +} + +export function expandedStateToHashedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return expandedOrHashedState; + } + + return persistState(decodeState(expandedOrHashedState)); +} diff --git a/webpackShims/jquery.js b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts similarity index 85% rename from webpackShims/jquery.js rename to src/plugins/kibana_utils/public/state_management/state_encoder/index.ts index da81dd18cf71e7..da1382720faff1 100644 --- a/webpackShims/jquery.js +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts @@ -17,4 +17,9 @@ * under the License. */ -window.jQuery = window.$ = module.exports = require('../node_modules/jquery/dist/jquery'); +export { + encodeState, + decodeState, + expandedStateToHashedState, + hashedStateToExpandedState, +} from './encode_decode_state'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts index 0e52c4c55872d2..24c3c57613477b 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './state_hash'; +export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts index a3eb5272b112da..f56d71297c030d 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Sha256 } from '../../../../../core/public/utils'; import { hashedItemStore } from '../../storage/hashed_item_store'; @@ -52,3 +53,46 @@ export function createStateHash( export function isStateHash(str: string) { return String(str).indexOf(HASH_PREFIX) === 0; } + +export function retrieveState(stateHash: string): State { + const json = hashedItemStore.getItem(stateHash); + const throwUnableToRestoreUrlError = () => { + throw new Error( + i18n.translate('kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage', { + defaultMessage: + 'Unable to completely restore the URL, be sure to use the share functionality.', + }) + ); + }; + if (json === null) { + return throwUnableToRestoreUrlError(); + } + try { + return JSON.parse(json); + } catch (e) { + return throwUnableToRestoreUrlError(); + } +} + +export function persistState(state: State): string { + const json = JSON.stringify(state); + const hash = createStateHash(json); + + const isItemSet = hashedItemStore.setItem(hash, json); + if (isItemSet) return hash; + // If we ran out of space trying to persist the state, notify the user. + const message = i18n.translate( + 'kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage', + { + defaultMessage: + 'Kibana is unable to store history items in your session ' + + `because it is full and there don't seem to be items any items safe ` + + 'to delete.\n\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at {gitHubIssuesUrl}.', + values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, + } + ); + throw new Error(message); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/format.test.ts b/src/plugins/kibana_utils/public/state_management/url/format.test.ts new file mode 100644 index 00000000000000..728f069840c72d --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { replaceUrlHashQuery } from './format'; + +describe('format', () => { + describe('replaceUrlHashQuery', () => { + it('should add hash query to url without hash', () => { + const url = 'http://localhost:5601/oxf/app/kibana'; + expect(replaceUrlHashQuery(url, () => ({ test: 'test' }))).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#?test=test"` + ); + }); + + it('should replace hash query', () => { + const url = 'http://localhost:5601/oxf/app/kibana#?test=test'; + expect( + replaceUrlHashQuery(url, query => ({ + ...query, + test1: 'test1', + })) + ).toMatchInlineSnapshot(`"http://localhost:5601/oxf/app/kibana#?test=test&test1=test1"`); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/format.ts b/src/plugins/kibana_utils/public/state_management/url/format.ts new file mode 100644 index 00000000000000..988ee086273823 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.ts @@ -0,0 +1,41 @@ +/* + * 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 { format as formatUrl } from 'url'; +import { ParsedUrlQuery } from 'querystring'; +import { parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; + +export function replaceUrlHashQuery( + rawUrl: string, + queryReplacer: (query: ParsedUrlQuery) => ParsedUrlQuery +) { + const url = parseUrl(rawUrl); + const hash = parseUrlHash(rawUrl); + const newQuery = queryReplacer(hash?.query || {}); + const searchQueryString = stringifyQueryString(newQuery); + if ((!hash || !hash.search) && !searchQueryString) return rawUrl; // nothing to change. return original url + return formatUrl({ + ...url, + hash: formatUrl({ + pathname: hash?.pathname || '', + search: searchQueryString, + }), + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts index a85158acddefd5..ec87b8464ac2dc 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts @@ -29,13 +29,6 @@ describe('hash unhash url', () => { describe('hash url', () => { describe('does nothing', () => { - it('if missing input', () => { - expect(() => { - // @ts-ignore - hashUrl(); - }).not.toThrowError(); - }); - it('if url is empty', () => { const url = ''; expect(hashUrl(url)).toBe(url); diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts index 872e7953f938bb..a29f8bb9ac635c 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts @@ -17,13 +17,8 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import rison, { RisonObject } from 'rison-node'; -import { stringify as stringifyQueryString } from 'querystring'; -import encodeUriQuery from 'encode-uri-query'; -import { format as formatUrl, parse as parseUrl } from 'url'; -import { hashedItemStore } from '../../storage/hashed_item_store'; -import { createStateHash, isStateHash } from '../state_hash'; +import { expandedStateToHashedState, hashedStateToExpandedState } from '../state_encoder'; +import { replaceUrlHashQuery } from './format'; export type IParsedUrlQuery = Record; @@ -32,8 +27,8 @@ interface IUrlQueryMapperOptions { } export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions; -export const unhashQuery = createQueryMapper(stateHashToRisonState); -export const hashQuery = createQueryMapper(risonStateToStateHash); +export const unhashQuery = createQueryMapper(hashedStateToExpandedState); +export const hashQuery = createQueryMapper(expandedStateToHashedState); export const unhashUrl = createQueryReplacer(unhashQuery); export const hashUrl = createQueryReplacer(hashQuery); @@ -61,97 +56,5 @@ function createQueryReplacer( queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery, options?: IUrlQueryReplacerOptions ) { - return (url: string) => { - if (!url) return url; - - const parsedUrl = parseUrl(url, true); - if (!parsedUrl.hash) return url; - - const appUrl = parsedUrl.hash.slice(1); // trim the # - if (!appUrl) return url; - - const appUrlParsed = parseUrl(appUrl, true); - if (!appUrlParsed.query) return url; - - const changedAppQuery = queryMapper(appUrlParsed.query, options); - - // encodeUriQuery implements the less-aggressive encoding done naturally by - // the browser. We use it to generate the same urls the browser would - const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, { - encodeURIComponent: encodeUriQuery, - }); - - return formatUrl({ - ...parsedUrl, - hash: formatUrl({ - pathname: appUrlParsed.pathname, - search: changedAppQueryString, - }), - }); - }; -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function retrieveState(stateHash: string): RisonObject { - const json = hashedItemStore.getItem(stateHash); - const throwUnableToRestoreUrlError = () => { - throw new Error( - i18n.translate('kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage', { - defaultMessage: - 'Unable to completely restore the URL, be sure to use the share functionality.', - }) - ); - }; - if (json === null) { - return throwUnableToRestoreUrlError(); - } - try { - return JSON.parse(json); - } catch (e) { - return throwUnableToRestoreUrlError(); - } -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function persistState(state: RisonObject): string { - const json = JSON.stringify(state); - const hash = createStateHash(json); - - const isItemSet = hashedItemStore.setItem(hash, json); - if (isItemSet) return hash; - // If we ran out of space trying to persist the state, notify the user. - const message = i18n.translate( - 'kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage', - { - defaultMessage: - 'Kibana is unable to store history items in your session ' + - `because it is full and there don't seem to be items any items safe ` + - 'to delete.\n\n' + - 'This can usually be fixed by moving to a fresh tab, but could ' + - 'be caused by a larger issue. If you are seeing this message regularly, ' + - 'please file an issue at {gitHubIssuesUrl}.', - values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, - } - ); - throw new Error(message); -} - -function stateHashToRisonState(stateHashOrRison: string): string { - if (isStateHash(stateHashOrRison)) { - return rison.encode(retrieveState(stateHashOrRison)); - } - - return stateHashOrRison; -} - -function risonStateToStateHash(stateHashOrRison: string): string | null { - if (isStateHash(stateHashOrRison)) { - return stateHashOrRison; - } - - return persistState(rison.decode(stateHashOrRison) as RisonObject); + return (url: string) => replaceUrlHashQuery(url, query => queryMapper(query, options)); } diff --git a/src/plugins/kibana_utils/public/state_management/url/index.ts b/src/plugins/kibana_utils/public/state_management/url/index.ts index 30c5696233db73..40491bf7a274bf 100644 --- a/src/plugins/kibana_utils/public/state_management/url/index.ts +++ b/src/plugins/kibana_utils/public/state_management/url/index.ts @@ -17,4 +17,12 @@ * under the License. */ -export * from './hash_unhash_url'; +export { hashUrl, hashQuery, unhashUrl, unhashQuery } from './hash_unhash_url'; +export { + createKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, + getStatesFromKbnUrl, + IKbnUrlControls, +} from './kbn_url_storage'; +export { createUrlTracker } from './url_tracker'; diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts new file mode 100644 index 00000000000000..f1c527d3d53097 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts @@ -0,0 +1,246 @@ +/* + * 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 '../../storage/hashed_item_store/mock'; +import { + History, + createBrowserHistory, + createHashHistory, + createMemoryHistory, + createPath, +} from 'history'; +import { + getRelativeToHistoryPath, + createKbnUrlControls, + IKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, +} from './kbn_url_storage'; + +describe('kbn_url_storage', () => { + describe('getStateFromUrl & setStateToUrl', () => { + const url = 'http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id'; + const state1 = { + testStr: '123', + testNumber: 0, + testObj: { test: '123' }, + testNull: null, + testArray: [1, 2, {}], + }; + const state2 = { + test: '123', + }; + + it('should set expanded state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: false }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: false }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(test:'123')"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + + it('should set hashed state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@a897fac"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: true }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@40f94d5"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + }); + + describe('urlControls', () => { + let history: History; + let urlControls: IKbnUrlControls; + beforeEach(() => { + history = createMemoryHistory(); + urlControls = createKbnUrlControls(history); + }); + + const getCurrentUrl = () => createPath(history.location); + it('should update url', () => { + urlControls.update('/1', false); + + expect(getCurrentUrl()).toBe('/1'); + expect(history.length).toBe(2); + + urlControls.update('/2', true); + + expect(getCurrentUrl()).toBe('/2'); + expect(history.length).toBe(2); + }); + + it('should update url async', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('should push url state if at least 1 push in async chain', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should replace url state if all updates in async chain are replace', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(1); + }); + + it('should listen for url updates', async () => { + const cb = jest.fn(); + urlControls.listen(cb); + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + await Promise.all([pr1, pr2, pr3]); + + urlControls.update('/4', false); + urlControls.update('/5', true); + + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('should flush async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + urlControls.flush(); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('flush should take priority over regular replace behaviour', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.flush(false); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should cancel async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.cancel(); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/'); + }); + }); + + describe('getRelativeToHistoryPath', () => { + it('should extract path relative to browser history without basename', () => { + const history = createBrowserHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename', () => { + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const history1 = createBrowserHistory({ basename: '/oxf/app/' }); + const relativePath1 = getRelativeToHistoryPath(url, history1); + expect(relativePath1).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + + const history2 = createBrowserHistory({ basename: '/oxf/app/kibana/' }); + const relativePath2 = getRelativeToHistoryPath(url, history2); + expect(relativePath2).toEqual( + "#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename from relative url', () => { + const history = createBrowserHistory({ basename: '/oxf/app/' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history without basename', () => { + const history = createHashHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename from relative url', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts new file mode 100644 index 00000000000000..03c136ea3d0928 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -0,0 +1,235 @@ +/* + * 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 { format as formatUrl } from 'url'; +import { createBrowserHistory, History } from 'history'; +import { decodeState, encodeState } from '../state_encoder'; +import { getCurrentUrl, parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; +import { replaceUrlHashQuery } from './format'; + +/** + * Parses a kibana url and retrieves all the states encoded into url, + * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage) + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * will return object: + * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}}; + */ +export function getStatesFromKbnUrl( + url: string = window.location.href, + keys?: string[] +): Record { + const query = parseUrlHash(url)?.query; + + if (!query) return {}; + const decoded: Record = {}; + Object.entries(query) + .filter(([key]) => (keys ? keys.includes(key) : true)) + .forEach(([q, value]) => { + decoded[q] = decodeState(value as string); + }); + + return decoded; +} + +/** + * Retrieves specific state from url by key + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * and key '_a' + * will return object: + * {tab: 'indexedFields'} + */ +export function getStateFromKbnUrl( + key: string, + url: string = window.location.href +): State | null { + return (getStatesFromKbnUrl(url, [key])[key] as State) || null; +} + +/** + * Sets state to the url by key and returns a new url string. + * Doesn't actually updates history + * + * e.g.: + * given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * key: '_a' + * and state: {tab: 'other'} + * + * will return url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'') + */ +export function setStateToKbnUrl( + key: string, + state: State, + { useHash = false }: { useHash: boolean } = { useHash: false }, + rawUrl = window.location.href +): string { + return replaceUrlHashQuery(rawUrl, query => { + const encoded = encodeState(state, useHash); + return { + ...query, + [key]: encoded, + }; + }); +} + +/** + * A tiny wrapper around history library to listen for url changes and update url + * History library handles a bunch of cross browser edge cases + */ +export interface IKbnUrlControls { + /** + * Listen for url changes + * @param cb - get's called when url has been changed + */ + listen: (cb: () => void) => () => void; + + /** + * Updates url synchronously + * @param url - url to update to + * @param replace - use replace instead of push + */ + update: (url: string, replace: boolean) => string; + + /** + * Schedules url update to next microtask, + * Useful to batch sync changes to url to cause only one browser history update + * @param updater - fn which receives current url and should return next url to update to + * @param replace - use replace instead of push + */ + updateAsync: (updater: UrlUpdaterFnType, replace?: boolean) => Promise; + + /** + * Synchronously flushes scheduled url updates + * @param replace - if replace passed in, then uses it instead of push. Otherwise push or replace is picked depending on updateQueue + */ + flush: (replace?: boolean) => string; + + /** + * Cancels any pending url updates + */ + cancel: () => void; +} +export type UrlUpdaterFnType = (currentUrl: string) => string; + +export const createKbnUrlControls = ( + history: History = createBrowserHistory() +): IKbnUrlControls => { + const updateQueue: Array<(currentUrl: string) => string> = []; + + // if we should replace or push with next async update, + // if any call in a queue asked to push, then we should push + let shouldReplace = true; + + function updateUrl(newUrl: string, replace = false): string { + const currentUrl = getCurrentUrl(); + if (newUrl === currentUrl) return currentUrl; // skip update + + const historyPath = getRelativeToHistoryPath(newUrl, history); + + if (replace) { + history.replace(historyPath); + } else { + history.push(historyPath); + } + + return getCurrentUrl(); + } + + // queue clean up + function cleanUp() { + updateQueue.splice(0, updateQueue.length); + shouldReplace = true; + } + + // runs scheduled url updates + function flush(replace = shouldReplace) { + if (updateQueue.length === 0) return getCurrentUrl(); + const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); + + cleanUp(); + + const newUrl = updateUrl(resultUrl, replace); + return newUrl; + } + + return { + listen: (cb: () => void) => + history.listen(() => { + cb(); + }), + update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), + updateAsync: (updater: (currentUrl: string) => string, replace = false) => { + updateQueue.push(updater); + if (shouldReplace) { + shouldReplace = replace; + } + + // Schedule url update to the next microtask + // this allows to batch synchronous url changes + return Promise.resolve().then(() => { + return flush(); + }); + }, + flush: (replace?: boolean) => { + return flush(replace); + }, + cancel: () => { + cleanUp(); + }, + }; +}; + +/** + * Depending on history configuration extracts relative path for history updates + * 4 possible cases (see tests): + * 1. Browser history with empty base path + * 2. Browser history with base path + * 3. Hash history with empty base path + * 4. Hash history with base path + */ +export function getRelativeToHistoryPath(absoluteUrl: string, history: History): History.Path { + function stripBasename(path: string = '') { + const stripLeadingHash = (_: string) => (_.charAt(0) === '#' ? _.substr(1) : _); + const stripTrailingSlash = (_: string) => + _.charAt(_.length - 1) === '/' ? _.substr(0, _.length - 1) : _; + const baseName = stripLeadingHash(stripTrailingSlash(history.createHref({}))); + return path.startsWith(baseName) ? path.substr(baseName.length) : path; + } + const isHashHistory = history.createHref({}).includes('#'); + const parsedUrl = isHashHistory ? parseUrlHash(absoluteUrl)! : parseUrl(absoluteUrl); + const parsedHash = isHashHistory ? null : parseUrlHash(absoluteUrl); + + return formatUrl({ + pathname: stripBasename(parsedUrl.pathname), + search: stringifyQueryString(parsedUrl.query), + hash: parsedHash + ? formatUrl({ + pathname: parsedHash.pathname, + search: stringifyQueryString(parsedHash.query), + }) + : parsedUrl.hash, + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.test.ts b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts new file mode 100644 index 00000000000000..774f18b7345142 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { parseUrlHash } from './parse'; + +describe('parseUrlHash', () => { + it('should return null if no hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana')).toBeNull(); + }); + + it('should return parsed hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana/#/path?test=test')).toMatchObject({ + pathname: '/path', + query: { + test: 'test', + }, + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.ts b/src/plugins/kibana_utils/public/state_management/url/parse.ts new file mode 100644 index 00000000000000..95041d0662f56d --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.ts @@ -0,0 +1,29 @@ +/* + * 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 { parse as _parseUrl } from 'url'; + +export const parseUrl = (url: string) => _parseUrl(url, true); +export const parseUrlHash = (url: string) => { + const hash = parseUrl(url).hash; + return hash ? parseUrl(hash.slice(1)) : null; +}; +export const getCurrentUrl = () => window.location.href; +export const parseCurrentUrl = () => parseUrl(getCurrentUrl()); +export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts new file mode 100644 index 00000000000000..3ca6cb42146829 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { encodeUriQuery, stringifyQueryString } from './stringify_query_string'; + +describe('stringifyQueryString', () => { + it('stringifyQueryString', () => { + expect( + stringifyQueryString({ + a: 'asdf1234asdf', + b: "-_.!~*'() -_.!~*'()", + c: ':@$, :@$,', + d: "&;=+# &;=+#'", + f: ' ', + g: 'null', + }) + ).toMatchInlineSnapshot( + `"a=asdf1234asdf&b=-_.!~*'()%20-_.!~*'()&c=:@$,%20:@$,&d=%26;%3D%2B%23%20%26;%3D%2B%23'&f=%20&g=null"` + ); + }); +}); + +describe('encodeUriQuery', function() { + it('should correctly encode uri query and not encode chars defined as pchar set in rfc3986', () => { + // don't encode alphanum + expect(encodeUriQuery('asdf1234asdf')).toBe('asdf1234asdf'); + + // don't encode unreserved + expect(encodeUriQuery("-_.!~*'() -_.!~*'()")).toBe("-_.!~*'()+-_.!~*'()"); + + // don't encode the rest of pchar + expect(encodeUriQuery(':@$, :@$,')).toBe(':@$,+:@$,'); + + // encode '&', ';', '=', '+', and '#' + expect(encodeUriQuery('&;=+# &;=+#')).toBe('%26;%3D%2B%23+%26;%3D%2B%23'); + + // encode ' ' as '+' + expect(encodeUriQuery(' ')).toBe('++'); + + // encode ' ' as '%20' when a flag is used + expect(encodeUriQuery(' ', true)).toBe('%20%20'); + + // do not encode `null` as '+' when flag is used + expect(encodeUriQuery('null', true)).toBe('null'); + + // do not encode `null` with no flag + expect(encodeUriQuery('null')).toBe('null'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts new file mode 100644 index 00000000000000..e951dfac29c025 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts @@ -0,0 +1,57 @@ +/* + * 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 { stringify, ParsedUrlQuery } from 'querystring'; + +// encodeUriQuery implements the less-aggressive encoding done naturally by +// the browser. We use it to generate the same urls the browser would +export const stringifyQueryString = (query: ParsedUrlQuery) => + stringify(query, undefined, undefined, { + // encode spaces with %20 is needed to produce the same queries as angular does + // https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1377 + encodeURIComponent: (val: string) => encodeUriQuery(val, true), + }); + +/** + * Extracted from angular.js + * repo: https://github.com/angular/angular.js + * license: MIT - https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/LICENSE + * source: https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1413-L1432 + */ + +/** + * This method is intended for encoding *key* or *value* parts of query component. We need a custom + * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be + * encoded per http://tools.ietf.org/html/rfc3986: + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ +export function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) { + return encodeURIComponent(val) + .replace(/%40/gi, '@') + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%3B/gi, ';') + .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts new file mode 100644 index 00000000000000..d7e5f99ffb700c --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.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 { createUrlTracker, IUrlTracker } from './url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory, History } from 'history'; + +describe('urlTracker', () => { + let storage: StubBrowserStorage; + let history: History; + let urlTracker: IUrlTracker; + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + urlTracker = createUrlTracker('test', storage); + }); + + it('should return null if no tracked url', () => { + expect(urlTracker.getTrackedUrl()).toBeNull(); + }); + + it('should return last tracked url', () => { + urlTracker.trackUrl('http://localhost:4200'); + urlTracker.trackUrl('http://localhost:4201'); + urlTracker.trackUrl('http://localhost:4202'); + expect(urlTracker.getTrackedUrl()).toBe('http://localhost:4202'); + }); + + it('should listen to history and track updates', () => { + const stop = urlTracker.startTrackingUrl(history); + expect(urlTracker.getTrackedUrl()).toBe('/'); + history.push('/1'); + history.replace('/2'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + + stop(); + history.replace('/3'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts new file mode 100644 index 00000000000000..89e72e94ba6b46 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts @@ -0,0 +1,49 @@ +/* + * 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 { createBrowserHistory, History, Location } from 'history'; +import { getRelativeToHistoryPath } from './kbn_url_storage'; + +export interface IUrlTracker { + startTrackingUrl: (history?: History) => () => void; + getTrackedUrl: () => string | null; + trackUrl: (url: string) => void; +} +/** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + */ +export function createUrlTracker(key: string, storage: Storage = sessionStorage): IUrlTracker { + return { + startTrackingUrl(history: History = createBrowserHistory()) { + const track = (location: Location) => { + const url = getRelativeToHistoryPath(history.createHref(location), history); + storage.setItem(key, url); + }; + track(history.location); + return history.listen(track); + }, + getTrackedUrl() { + return storage.getItem(key); + }, + trackUrl(url: string) { + storage.setItem(key, url); + }, + }; +} diff --git a/typings/encode_uri_query.d.ts b/src/plugins/kibana_utils/public/state_sync/index.ts similarity index 68% rename from typings/encode_uri_query.d.ts rename to src/plugins/kibana_utils/public/state_sync/index.ts index 4bfc5546244463..1dfa998c5bb9d9 100644 --- a/typings/encode_uri_query.d.ts +++ b/src/plugins/kibana_utils/public/state_sync/index.ts @@ -17,8 +17,17 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +export { IStateSyncConfig, INullableBaseStateContainer } from './types'; +export { + syncState, + syncStates, + StopSyncStateFnType, + StartSyncStateFnType, + ISyncStateRef, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts new file mode 100644 index 00000000000000..cc513bc674d0fc --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -0,0 +1,308 @@ +/* + * 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 { BaseStateContainer, createStateContainer } from '../state_containers'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../demos/state_containers/todomvc'; +import { syncState, syncStates } from './state_sync'; +import { IStateStorage } from './state_sync_state_storage/types'; +import { Observable, Subject } from 'rxjs'; +import { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createBrowserHistory, History } from 'history'; +import { INullableBaseStateContainer } from './types'; + +describe('state_sync', () => { + describe('basic', () => { + const container = createStateContainer(defaultState, pureTransitions); + beforeEach(() => { + container.set(defaultState); + }); + const storageChange$ = new Subject(); + let testStateStorage: IStateStorage; + + beforeEach(() => { + testStateStorage = { + set: jest.fn(), + get: jest.fn(), + change$: (key: string) => storageChange$.asObservable() as Observable, + }; + }); + + it('should sync state to storage', () => { + const key = '_s'; + const { start, stop } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of state to storage is not happening + expect(testStateStorage.set).not.toBeCalled(); + + container.transitions.add({ + id: 1, + text: 'Learning transitions...', + completed: false, + }); + expect(testStateStorage.set).toBeCalledWith(key, container.getState()); + stop(); + }); + + it('should sync storage to state', () => { + const key = '_s'; + const storageState1 = [{ id: 1, text: 'todo', completed: false }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState1); + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of storage to state is not happening + expect(container.getState()).toEqual(defaultState); + + const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); + storageChange$.next(storageState2); + + expect(container.getState()).toEqual(storageState2); + + stop(); + }); + + it('should not update storage if no actual state change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + (testStateStorage.set as jest.Mock).mockClear(); + + container.set(defaultState); + expect(testStateStorage.set).not.toBeCalled(); + + stop(); + }); + + it('should not update state container if no actual storage change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + const originalState = container.getState(); + const storageState = [...originalState]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); + storageChange$.next(storageState); + + expect(container.getState()).toBe(originalState); + + stop(); + }); + + it('storage change to null should notify state', () => { + container.set([{ completed: false, id: 1, text: 'changed' }]); + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: '_s', + stateStorage: testStateStorage, + }, + ]); + start(); + + (testStateStorage.get as jest.Mock).mockImplementation(() => null); + storageChange$.next(null); + + expect(container.getState()).toEqual(defaultState); + + stop(); + }); + }); + + describe('integration', () => { + const key = '_s'; + const container = createStateContainer(defaultState, pureTransitions); + + let sessionStorage: StubBrowserStorage; + let sessionStorageSyncStrategy: ISessionStorageStateStorage; + let history: History; + let urlSyncStrategy: IKbnUrlStateStorage; + const getCurrentUrl = () => history.createHref(history.location); + const tick = () => new Promise(resolve => setTimeout(resolve)); + + beforeEach(() => { + container.set(defaultState); + + window.location.href = '/'; + sessionStorage = new StubBrowserStorage(); + sessionStorageSyncStrategy = createSessionStorageStateStorage(sessionStorage); + history = createBrowserHistory(); + urlSyncStrategy = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('change to one storage should also update other storage', () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: sessionStorageSyncStrategy, + }, + ]); + start(); + + const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; + history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + + expect(container.getState()).toEqual(newStateFromUrl); + expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); + + stop(); + }); + + it('KbnUrlSyncStrategy applies url updates asynchronously to trigger single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + expect(history.length).toBe(startHistoryLength + 1); + + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports flushing url updates synchronously and triggers single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.flush(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + await tick(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports cancellation of pending updates ', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.cancel(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + stop(); + }); + }); +}); + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts new file mode 100644 index 00000000000000..f0ef1423dec71b --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -0,0 +1,171 @@ +/* + * 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 { EMPTY, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import defaultComparator from 'fast-deep-equal'; +import { IStateSyncConfig } from './types'; +import { IStateStorage } from './state_sync_state_storage'; +import { distinctUntilChangedWithInitialValue } from '../../common'; + +/** + * Utility for syncing application state wrapped in state container + * with some kind of storage (e.g. URL) + * + * Examples: + * + * 1. the simplest use case + * const stateStorage = createKbnUrlStateStorage(); + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 2. conditionally configuring sync strategy + * const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')}) + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 3. implementing custom sync strategy + * const localStorageStateStorage = { + * set: (storageKey, state) => localStorage.setItem(storageKey, JSON.stringify(state)), + * get: (storageKey) => localStorage.getItem(storageKey) ? JSON.parse(localStorage.getItem(storageKey)) : null + * }; + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage: localStorageStateStorage + * }); + * + * 4. Transform state before serialising + * Useful for: + * * Migration / backward compatibility + * * Syncing part of state + * * Providing default values + * const stateToStorage = (s) => ({ tab: s.tab }); + * syncState({ + * storageKey: '_s', + * stateContainer: { + * get: () => stateToStorage(stateContainer.get()), + * set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }), + * state$: stateContainer.state$.pipe(map(stateToStorage)) + * }, + * stateStorage + * }); + * + * Caveats: + * + * 1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing + * No initial sync happens when syncState() is called + */ +export type StopSyncStateFnType = () => void; +export type StartSyncStateFnType = () => void; +export interface ISyncStateRef { + // stop syncing state with storage + stop: StopSyncStateFnType; + // start syncing state with storage + start: StartSyncStateFnType; +} +export function syncState({ + storageKey, + stateStorage, + stateContainer, +}: IStateSyncConfig): ISyncStateRef { + const subscriptions: Subscription[] = []; + + const updateState = () => { + const newState = stateStorage.get(storageKey); + const oldState = stateContainer.get(); + if (!defaultComparator(newState, oldState)) { + stateContainer.set(newState); + } + }; + + const updateStorage = () => { + const newStorageState = stateContainer.get(); + const oldStorageState = stateStorage.get(storageKey); + if (!defaultComparator(newStorageState, oldStorageState)) { + stateStorage.set(storageKey, newStorageState); + } + }; + + const onStateChange$ = stateContainer.state$.pipe( + distinctUntilChangedWithInitialValue(stateContainer.get(), defaultComparator), + tap(() => updateStorage()) + ); + + const onStorageChange$ = stateStorage.change$ + ? stateStorage.change$(storageKey).pipe( + distinctUntilChangedWithInitialValue(stateStorage.get(storageKey), defaultComparator), + tap(() => { + updateState(); + }) + ) + : EMPTY; + + return { + stop: () => { + // if stateStorage has any cancellation logic, then run it + if (stateStorage.cancel) { + stateStorage.cancel(); + } + + subscriptions.forEach(s => s.unsubscribe()); + subscriptions.splice(0, subscriptions.length); + }, + start: () => { + if (subscriptions.length > 0) { + throw new Error("syncState: can't start syncing state, when syncing is in progress"); + } + subscriptions.push(onStateChange$.subscribe(), onStorageChange$.subscribe()); + }, + }; +} + +/** + * multiple different sync configs + * syncStates([ + * { + * storageKey: '_s1', + * stateStorage: stateStorage1, + * stateContainer: stateContainer1, + * }, + * { + * storageKey: '_s2', + * stateStorage: stateStorage2, + * stateContainer: stateContainer2, + * }, + * ]); + * @param stateSyncConfigs - Array of IStateSyncConfig to sync + */ +export function syncStates(stateSyncConfigs: Array>): ISyncStateRef { + const syncRefs = stateSyncConfigs.map(config => syncState(config)); + return { + stop: () => { + syncRefs.forEach(s => s.stop()); + }, + start: () => { + syncRefs.forEach(s => s.start()); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts new file mode 100644 index 00000000000000..826122176e061e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -0,0 +1,120 @@ +/* + * 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 '../../storage/hashed_item_store/mock'; +import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +import { History, createBrowserHistory } from 'history'; +import { takeUntil, toArray } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +describe('KbnUrlStateStorage', () => { + describe('useHash: false', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should flush state to url', () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.flush(); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should cancel url updates', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + const pr = urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.cancel(); + await pr; + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + expect(urlStateStorage.get(key)).toEqual(null); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); + + describe('useHash: true', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: true, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=h@487e077"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts new file mode 100644 index 00000000000000..245006349ad55b --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -0,0 +1,84 @@ +/* + * 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 { Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { History } from 'history'; +import { IStateStorage } from './types'; +import { + createKbnUrlControls, + getStateFromKbnUrl, + setStateToKbnUrl, +} from '../../state_management/url'; + +export interface IKbnUrlStateStorage extends IStateStorage { + set: (key: string, state: State, opts?: { replace: boolean }) => Promise; + get: (key: string) => State | null; + change$: (key: string) => Observable; + + // cancels any pending url updates + cancel: () => void; + + // synchronously runs any pending url updates + flush: (opts?: { replace?: boolean }) => void; +} + +/** + * Implements syncing to/from url strategies. + * Replicates what was implemented in state (AppState, GlobalState) + * Both expanded and hashed use cases + */ +export const createKbnUrlStateStorage = ( + { useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false } +): IKbnUrlStateStorage => { + const url = createKbnUrlControls(history); + return { + set: ( + key: string, + state: State, + { replace = false }: { replace: boolean } = { replace: false } + ) => { + // syncState() utils doesn't wait for this promise + return url.updateAsync( + currentUrl => setStateToKbnUrl(key, state, { useHash }, currentUrl), + replace + ); + }, + get: key => getStateFromKbnUrl(key), + change$: (key: string) => + new Observable(observer => { + const unlisten = url.listen(() => { + observer.next(); + }); + + return () => { + unlisten(); + }; + }).pipe( + map(() => getStateFromKbnUrl(key)), + share() + ), + flush: ({ replace = false }: { replace?: boolean } = {}) => { + url.flush(replace); + }, + cancel() { + url.cancel(); + }, + }; +}; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts new file mode 100644 index 00000000000000..f69629e7550088 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; + +describe('SessionStorageStateStorage', () => { + let browserStorage: StubBrowserStorage; + let stateStorage: ISessionStorageStateStorage; + beforeEach(() => { + browserStorage = new StubBrowserStorage(); + stateStorage = createSessionStorageStateStorage(browserStorage); + }); + + it('should synchronously sync to storage', () => { + const state = { state: 'state' }; + stateStorage.set('key', state); + expect(stateStorage.get('key')).toEqual(state); + expect(browserStorage.getItem('key')).not.toBeNull(); + }); + + it('should not implement change$', () => { + expect(stateStorage.change$).not.toBeDefined(); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts new file mode 100644 index 00000000000000..00edfdfd1ed61e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts @@ -0,0 +1,34 @@ +/* + * 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 { IStateStorage } from './types'; + +export interface ISessionStorageStateStorage extends IStateStorage { + set: (key: string, state: State) => void; + get: (key: string) => State | null; +} + +export const createSessionStorageStateStorage = ( + storage: Storage = window.sessionStorage +): ISessionStorageStateStorage => { + return { + set: (key: string, state: State) => storage.setItem(key, JSON.stringify(state)), + get: (key: string) => JSON.parse(storage.getItem(key)!), + }; +}; diff --git a/test/typings/encode_uri_query.d.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts similarity index 75% rename from test/typings/encode_uri_query.d.ts rename to src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts index 4bfc5546244463..fe04333e5ef159 100644 --- a/test/typings/encode_uri_query.d.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts @@ -17,8 +17,9 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { IStateStorage } from './types'; +export { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +export { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts new file mode 100644 index 00000000000000..add1dc259be458 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts @@ -0,0 +1,51 @@ +/* + * 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 { Observable } from 'rxjs'; + +/** + * Any StateStorage have to implement IStateStorage interface + * StateStorage is responsible for: + * * state serialisation / deserialization + * * persisting to and retrieving from storage + * + * For an example take a look at already implemented KbnUrl state storage + */ +export interface IStateStorage { + /** + * Take in a state object, should serialise and persist + */ + set: (key: string, state: State) => any; + + /** + * Should retrieve state from the storage and deserialize it + */ + get: (key: string) => State | null; + + /** + * Should notify when the stored state has changed + */ + change$?: (key: string) => Observable; + + /** + * Optional method to cancel any pending activity + * syncState() will call it, if it is provided by IStateStorage + */ + cancel?: () => void; +} diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts new file mode 100644 index 00000000000000..0f7395ad0f0e56 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/types.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 { BaseStateContainer } from '../state_containers/types'; +import { IStateStorage } from './state_sync_state_storage'; + +export interface INullableBaseStateContainer extends BaseStateContainer { + // State container for stateSync() have to accept "null" + // for example, set() implementation could handle null and fallback to some default state + // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. + // state container will be notified about about storage becoming empty with null passed in + set: (state: State | null) => void; +} + +export interface IStateSyncConfig< + State = unknown, + StateStorage extends IStateStorage = IStateStorage +> { + /** + * Storage key to use for syncing, + * e.g. storageKey '_a' should sync state to ?_a query param + */ + storageKey: string; + /** + * State container to keep in sync with storage, have to implement INullableBaseStateContainer interface + * The idea is that ./state_containers/ should be used as a state container, + * but it is also possible to implement own custom container for advanced use cases + */ + stateContainer: INullableBaseStateContainer; + /** + * State storage to use, + * State storage is responsible for serialising / deserialising and persisting / retrieving stored state + * + * There are common strategies already implemented: + * './state_sync_state_storage/' + * which replicate what State (AppState, GlobalState) in legacy world did + * + */ + stateStorage: StateStorage; +} diff --git a/tasks/config/karma.js b/tasks/config/karma.js index c0d6074da61c56..0acd452530b305 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -20,6 +20,7 @@ import { dirname } from 'path'; import { times } from 'lodash'; import { makeJunitReportPath } from '@kbn/test'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; const TOTAL_CI_SHARDS = 4; const ROOT = dirname(require.resolve('../../package.json')); @@ -48,6 +49,25 @@ module.exports = function(grunt) { return ['progress']; } + function getKarmaFiles(shardNum) { + return [ + 'http://localhost:5610/test_bundle/built_css.css', + + `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', + + shardNum === undefined + ? `http://localhost:5610/bundles/tests.bundle.js` + : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`, + + // this causes tilemap tests to fail, probably because the eui styles haven't been + // included in the karma harness a long some time, if ever + // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', + 'http://localhost:5610/bundles/tests.style.css', + ]; + } + const config = { options: { // base path that will be used to resolve all patterns (eg. files, exclude) @@ -90,15 +110,7 @@ module.exports = function(grunt) { }, // list of files / patterns to load in the browser - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - 'http://localhost:5610/bundles/tests.bundle.js', - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(), proxies: { '/tests/': 'http://localhost:5610/tests/', @@ -181,15 +193,7 @@ module.exports = function(grunt) { config[`ciShard-${n}`] = { singleRun: true, options: { - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${n}`, - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(n), }, }; }); diff --git a/webpackShims/moment-timezone.js b/webpackShims/moment-timezone.js deleted file mode 100644 index d5e032ff21eef5..00000000000000 --- a/webpackShims/moment-timezone.js +++ /dev/null @@ -1,21 +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. - */ - -var moment = (module.exports = require('../node_modules/moment-timezone/moment-timezone')); -moment.tz.load(require('../node_modules/moment-timezone/data/packed/latest.json')); diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx index 8cdb7f050027dc..0bd38967826034 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx @@ -16,8 +16,8 @@ export const LicenseContext = React.createContext( export function LicenseProvider({ children }: { children: React.ReactChild }) { const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$); - const hasInvalidLicense = !license?.isActive; + const license = useObservable(license$, { isActive: true } as ILicense); + const hasInvalidLicense = !license.isActive; // if license is invalid show an error message if (hasInvalidLicense) { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js deleted file mode 100644 index e397bda763f1ab..00000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js +++ /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 expect from '@kbn/expect'; -import { getColorsFromPalette } from '../../lib/get_colors_from_palette'; -import { - grayscalePalette, - gradientPalette, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; - -describe('getColorsFromPalette', () => { - it('returns the array of colors from a palette object when gradient is false', () => { - expect(getColorsFromPalette(grayscalePalette, 20)).to.eql(grayscalePalette.colors); - }); - - it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { - const result = getColorsFromPalette(gradientPalette, 16); - expect(result) - .to.have.length(16) - .and.to.eql([ - '#ffffff', - '#eeeeee', - '#dddddd', - '#cccccc', - '#bbbbbb', - '#aaaaaa', - '#999999', - '#888888', - '#777777', - '#666666', - '#555555', - '#444444', - '#333333', - '#222222', - '#111111', - '#000000', - ]); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js deleted file mode 100644 index ba43db7a83677f..00000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js +++ /dev/null @@ -1,49 +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 expect from '@kbn/expect'; -import { getLegendConfig } from '../get_legend_config'; - -describe('getLegendConfig', () => { - describe('show', () => { - it('hides the legend', () => { - expect(getLegendConfig(false, 2)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(false, 10)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('hides the legend when there are less than 2 series', () => { - expect(getLegendConfig(false, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(true, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('shows the legend when there are two or more series', () => { - expect(getLegendConfig('sw', 2)).to.have.property('show', true); - expect(getLegendConfig(true, 5)).to.have.property('show', true); - }); - }); - - describe('position', () => { - it('sets the position of the legend', () => { - expect(getLegendConfig('nw')).to.have.property('position', 'nw'); - expect(getLegendConfig('ne')).to.have.property('position', 'ne'); - expect(getLegendConfig('sw')).to.have.property('position', 'sw'); - expect(getLegendConfig('se')).to.have.property('position', 'se'); - }); - - it("defaults to 'ne'", () => { - expect(getLegendConfig(true)).to.have.property('position', 'ne'); - expect(getLegendConfig('foo')).to.have.property('position', 'ne'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts index 616e45c86c4afc..88bb32c846c245 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts @@ -7,184 +7,195 @@ jest.mock('ui/new_platform'); import { functionSpecs } from '../../__tests__/fixtures/function_specs'; -import { getAutocompleteSuggestions } from './autocomplete'; - -describe('getAutocompleteSuggestions', () => { - it('should suggest functions', () => { - const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(0); - }); - - it('should suggest functions filtered by text', () => { - const expression = 'pl'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); - const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); - expect(nonmatching.length).toBe(0); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - expect(suggestions.length).toBe(Object.keys(plotFn.args).length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments filtered by text', () => { - const expression = 'plot axis'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); - expect(suggestions.length).toBe(matchingArgs.length); - expect(suggestions[0].start).toBe('plot '.length); - expect(suggestions[0].end).toBe('plot axis'.length); - }); - - it('should suggest values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest values filtered by text', () => { - const expression = 'shape shape=ar'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - 'ar'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest functions inside an expression', () => { - const expression = 'if {}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest arguments inside an expression', () => { - const expression = 'if {lt }'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const ltFn = functionSpecs.find(spec => spec.name === 'lt'); - expect(suggestions.length).toBe(Object.keys(ltFn.args).length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside an expression', () => { - const expression = 'if {shape shape=}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside quotes', () => { - const expression = 'shape shape="ar"'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should prioritize functions that start with text', () => { - const expression = 't'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); - const alterColumnIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('alterColumn') - ); - expect(tableIndex).toBeLessThan(alterColumnIndex); - }); - - it('should prioritize functions that match the previous function type', () => { - const expression = 'plot | '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(renderIndex).toBeLessThan(anyIndex); - }); - - it('should alphabetize functions', () => { - const expression = ''; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(anyIndex).toBeLessThan(metricIndex); - }); - - it('should prioritize arguments that start with text', () => { - const expression = 'plot y'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(yaxisIndex).toBeLessThan(defaultStyleIndex); - }); - - it('should prioritize unnamed arguments', () => { - const expression = 'case '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); - const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); - expect(whenIndex).toBeLessThan(thenIndex); - }); - - it('should alphabetize arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(defaultStyleIndex).toBeLessThan(yaxisIndex); - }); - - it('should quote string values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toMatch(/^".*"$/); - }); - - it('should not quote sub expression value suggestions', () => { - const expression = 'plot font='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('{font}'); - }); - - it('should not quote booleans', () => { - const expression = 'table paginate=true'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('true'); +import { getAutocompleteSuggestions, getFnArgDefAtPosition } from './autocomplete'; + +describe('autocomplete', () => { + describe('getFnArgDefAtPosition', () => { + it('should return function definition for plot', () => { + const expression = 'plot '; + const def = getFnArgDefAtPosition(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(def.fnDef).toBe(plotFn); + }); + }); + + describe('getAutocompleteSuggestions', () => { + it('should suggest functions', () => { + const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(0); + }); + + it('should suggest functions filtered by text', () => { + const expression = 'pl'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); + const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); + expect(nonmatching.length).toBe(0); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(suggestions.length).toBe(Object.keys(plotFn.args).length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments filtered by text', () => { + const expression = 'plot axis'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); + expect(suggestions.length).toBe(matchingArgs.length); + expect(suggestions[0].start).toBe('plot '.length); + expect(suggestions[0].end).toBe('plot axis'.length); + }); + + it('should suggest values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest values filtered by text', () => { + const expression = 'shape shape=ar'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - 'ar'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest functions inside an expression', () => { + const expression = 'if {}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest arguments inside an expression', () => { + const expression = 'if {lt }'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const ltFn = functionSpecs.find(spec => spec.name === 'lt'); + expect(suggestions.length).toBe(Object.keys(ltFn.args).length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside an expression', () => { + const expression = 'if {shape shape=}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside quotes', () => { + const expression = 'shape shape="ar"'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should prioritize functions that start with text', () => { + const expression = 't'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); + const alterColumnIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('alterColumn') + ); + expect(tableIndex).toBeLessThan(alterColumnIndex); + }); + + it('should prioritize functions that match the previous function type', () => { + const expression = 'plot | '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(renderIndex).toBeLessThan(anyIndex); + }); + + it('should alphabetize functions', () => { + const expression = ''; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(anyIndex).toBeLessThan(metricIndex); + }); + + it('should prioritize arguments that start with text', () => { + const expression = 'plot y'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(yaxisIndex).toBeLessThan(defaultStyleIndex); + }); + + it('should prioritize unnamed arguments', () => { + const expression = 'case '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); + const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); + expect(whenIndex).toBeLessThan(thenIndex); + }); + + it('should alphabetize arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(defaultStyleIndex).toBeLessThan(yaxisIndex); + }); + + it('should quote string values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toMatch(/^".*"$/); + }); + + it('should not quote sub expression value suggestions', () => { + const expression = 'plot font='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('{font}'); + }); + + it('should not quote booleans', () => { + const expression = 'table paginate=true'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('true'); + }); }); }); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts similarity index 77% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts index acd9e6d1821d60..8bfe723bc2ae00 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidDataUrl, parseDataUrl } from '../dataurl'; +import { isValidDataUrl, parseDataUrl } from './dataurl'; const BASE64_TEXT = 'data:text/plain;charset=utf-8;base64,VGhpcyBpcyBhIHRlc3Q='; const BASE64_SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; const BASE64_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk8PxfDwADYgHJvQ16TAAAAABJRU5ErkJggg=='; +const INVALID_BASE64_PIXEL = + 'data:image/png;%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%01%00%00%00%01%08%06%00%00%00%1F%15%C4%89%0'; const RAW_TEXT = 'data:text/plain;charset=utf-8,This%20is%20a%20test'; const RAW_SVG = @@ -20,10 +22,13 @@ const RAW_PIXEL = describe('dataurl', () => { describe('isValidDataUrl', () => { - test('invalid data url', () => { + it('returns false for an invalid data url', () => { expect(isValidDataUrl('somestring')).toBe(false); }); - test('valid data urls', () => { + it('returns false for an empty string', () => { + expect(isValidDataUrl('')).toBe(false); + }); + it('returns true for valid data urls', () => { expect(isValidDataUrl(BASE64_TEXT)).toBe(true); expect(isValidDataUrl(BASE64_SVG)).toBe(true); expect(isValidDataUrl(BASE64_PIXEL)).toBe(true); @@ -34,10 +39,13 @@ describe('dataurl', () => { }); describe('dataurl.parseDataUrl', () => { - test('invalid data url', () => { + it('returns null for an invalid data url', () => { expect(parseDataUrl('somestring')).toBeNull(); }); - test('text data urls', () => { + it('returns null for an invalid base64 image', () => { + expect(parseDataUrl(INVALID_BASE64_PIXEL)).toBeNull(); + }); + it('returns correct values for text data urls', () => { expect(parseDataUrl(BASE64_TEXT)).toEqual({ charset: 'utf-8', data: null, @@ -55,7 +63,7 @@ describe('dataurl', () => { mimetype: 'text/plain', }); }); - test('png data urls', () => { + it('returns correct values for png data urls', () => { expect(parseDataUrl(RAW_PIXEL)).toBeNull(); expect(parseDataUrl(BASE64_PIXEL)).toEqual({ charset: undefined, @@ -66,7 +74,7 @@ describe('dataurl', () => { mimetype: 'image/png', }); }); - test('svg data urls', () => { + it('returns correct values for svg data urls', () => { expect(parseDataUrl(RAW_SVG)).toEqual({ charset: undefined, data: null, diff --git a/x-pack/legacy/plugins/canvas/common/lib/errors.test.js b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js new file mode 100644 index 00000000000000..a589fde5dadb66 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RenderError } from './errors'; + +describe('errors', () => { + it('creates a test error', () => { + // eslint-disable-next-line new-cap + const throwTestError = () => RenderError(); + expect(throwTestError.name).toBe('throwTestError'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js new file mode 100644 index 00000000000000..ae46661b50cd24 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFormHandlers } from './expression_form_handlers'; + +describe('ExpressionFormHandlers', () => { + it('executes destroy function', () => { + const handler = new ExpressionFormHandlers(); + handler.onDestroy(() => { + return 'DESTROYED!'; + }); + expect(handler.destroy()).toBe('DESTROYED!'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts new file mode 100644 index 00000000000000..d06c2af4f062ab --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.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 { fetch, arrayBufferFetch } from './fetch'; + +describe('fetch', () => { + it('test fetch headers', () => { + expect(fetch.defaults.headers.Accept).toBe('application/json'); + expect(fetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text'); + }); + + it('test arrayBufferFetch headers', () => { + expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe( + 'professionally-crafted-string-of-text' + ); + expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js new file mode 100644 index 00000000000000..ebc72db1f67f0f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js @@ -0,0 +1,40 @@ +/* + * 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 { + grayscalePalette, + gradientPalette, +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { getColorsFromPalette } from './get_colors_from_palette'; + +describe('getColorsFromPalette', () => { + it('returns the array of colors from a palette object when gradient is false', () => { + expect(getColorsFromPalette(grayscalePalette, 20)).toBe(grayscalePalette.colors); + }); + + it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { + const result = getColorsFromPalette(gradientPalette, 16); + expect(result).toEqual([ + '#ffffff', + '#eeeeee', + '#dddddd', + '#cccccc', + '#bbbbbb', + '#aaaaaa', + '#999999', + '#888888', + '#777777', + '#666666', + '#555555', + '#444444', + '#333333', + '#222222', + '#111111', + '#000000', + ]); + expect(result).toHaveLength(16); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts similarity index 87% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts index 34cfbb5a2befb9..82e724c33ecc81 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getFieldType } from '../get_field_type'; import { emptyTable, testTable, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { getFieldType } from './get_field_type'; describe('getFieldType', () => { it('returns type of a field in a datatable', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js new file mode 100644 index 00000000000000..b9ab9ae6aba546 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js @@ -0,0 +1,40 @@ +/* + * 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 { getLegendConfig } from './get_legend_config'; + +describe('getLegendConfig', () => { + describe('show', () => { + it('hides the legend', () => { + expect(getLegendConfig(false, 2)).toHaveProperty('show', false); + expect(getLegendConfig(false, 10)).toHaveProperty('show', false); + }); + + it('hides the legend when there are less than 2 series', () => { + expect(getLegendConfig(false, 1)).toHaveProperty('show', false); + expect(getLegendConfig(true, 1)).toHaveProperty('show', false); + }); + + it('shows the legend when there are two or more series', () => { + expect(getLegendConfig('sw', 2)).toHaveProperty('show', true); + expect(getLegendConfig(true, 5)).toHaveProperty('show', true); + }); + }); + + describe('position', () => { + it('sets the position of the legend', () => { + expect(getLegendConfig('nw')).toHaveProperty('position', 'nw'); + expect(getLegendConfig('ne')).toHaveProperty('position', 'ne'); + expect(getLegendConfig('sw')).toHaveProperty('position', 'sw'); + expect(getLegendConfig('se')).toHaveProperty('position', 'se'); + }); + + it("defaults to 'ne'", () => { + expect(getLegendConfig(true)).toHaveProperty('position', 'ne'); + expect(getLegendConfig('foo')).toHaveProperty('position', 'ne'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js new file mode 100644 index 00000000000000..5fcb2d42395fa3 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { testTable } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { Handlebars } from './handlebars'; + +describe('handlebars', () => { + it('registers math function and returns argument error', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template()).toBe('test math: MATH ERROR: first argument must be an array'); + }); + it('evaluates math function successfully', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template(testTable)).toBe('test math: 82164.33'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts similarity index 80% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts index d9aa56314948e9..00b4b40fa98391 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hexToRgb } from '../hex_to_rgb'; +import { hexToRgb } from './hex_to_rgb'; describe('hexToRgb', () => { - test('invalid hex', () => { + it('returns null for an invalid hex', () => { expect(hexToRgb('hexadecimal')).toBeNull(); expect(hexToRgb('#00')).toBeNull(); expect(hexToRgb('#00000')).toBeNull(); }); - test('shorthand', () => { + it('returns correct value for shorthand hex codes', () => { expect(hexToRgb('#000')).toEqual([0, 0, 0]); expect(hexToRgb('#FFF')).toEqual([255, 255, 255]); expect(hexToRgb('#fff')).toEqual([255, 255, 255]); expect(hexToRgb('#fFf')).toEqual([255, 255, 255]); }); - test('longhand', () => { + it('returns correct value for longhand hex codes', () => { expect(hexToRgb('#000000')).toEqual([0, 0, 0]); expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]); expect(hexToRgb('#fffFFF')).toEqual([255, 255, 255]); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts index 2a7cef7cf42365..65bc2469647aa9 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidHttpUrl } from '../httpurl'; +import { isValidHttpUrl } from './httpurl'; describe('httpurl.isValidHttpUrl', () => { it('matches HTTP URLs', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts index 6f6d42e7129a91..faf319769cab03 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pivotObjectArray } from '../pivot_object_array'; +import { pivotObjectArray } from './pivot_object_array'; interface Car { make: string; diff --git a/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js new file mode 100644 index 00000000000000..bbbd4f51d483fa --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { missingImage } from '../../common/lib/missing_asset'; +import { resolveFromArgs, resolveWithMissingImage } from './resolve_dataurl'; + +describe('resolve_dataurl', () => { + describe('resolveFromArgs', () => { + it('finds and returns the dataurl from args successfully', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: [missingImage, 'test2'], + }; + expect(resolveFromArgs(args)).toBe(missingImage); + }); + it('finds and returns null for invalid dataurl', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: ['invalid url', 'test2'], + }; + expect(resolveFromArgs(args)).toBe(null); + }); + }); + + describe('resolveWithMissingImage', () => { + it('returns valid dataurl', () => { + expect(resolveWithMissingImage(missingImage)).toBe(missingImage); + }); + it('returns missingImage for invalid dataurl', () => { + expect(resolveWithMissingImage('invalid dataurl')).toBe(missingImage); + }); + it('returns null for null dataurl', () => { + expect(resolveWithMissingImage(null)).toBe(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts similarity index 94% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts index e67e46f9e5dacd..d6eeb9392f40f1 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { unquoteString } from '../unquote_string'; +import { unquoteString } from './unquote_string'; describe('unquoteString', () => { it('removes double quotes', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/url.test.js b/x-pack/legacy/plugins/canvas/common/lib/url.test.js new file mode 100644 index 00000000000000..d49d12c6bf3828 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/url.test.js @@ -0,0 +1,21 @@ +/* + * 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 { missingImage } from '../../common/lib/missing_asset'; +import { isValidUrl } from './url'; + +describe('resolve_dataurl', () => { + it('returns valid dataurl', () => { + expect(isValidUrl(missingImage)).toBe(true); + }); + it('returns valid http url', () => { + const httpurl = 'https://test.com/s/'; + expect(isValidUrl(httpurl)).toBe(true); + }); + it('returns false for invalid url', () => { + expect(isValidUrl('test')).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/webpackShims/moment.js b/x-pack/legacy/plugins/canvas/webpackShims/moment.js deleted file mode 100644 index 1261aa7f7bd0fa..00000000000000 --- a/x-pack/legacy/plugins/canvas/webpackShims/moment.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -module.exports = require('../../../node_modules/moment/min/moment.min.js'); diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index 2f38f68b01f16d..d7cbcf59213be8 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -33,6 +33,7 @@ describe('field_manager', () => { selected: true, type: 'string', hopSize: 5, + aggregatable: true, }, { name: 'field2', @@ -42,6 +43,7 @@ describe('field_manager', () => { type: 'string', hopSize: 0, lastValidHopSize: 5, + aggregatable: false, }, { name: 'field3', @@ -50,6 +52,16 @@ describe('field_manager', () => { selected: false, type: 'string', hopSize: 5, + aggregatable: true, + }, + { + name: 'field4', + color: 'orange', + icon: getSuitableIcon('field4'), + selected: false, + type: 'string', + hopSize: 5, + aggregatable: false, }, ]) ); @@ -86,6 +98,17 @@ describe('field_manager', () => { ).toEqual('field2'); }); + it('should show selected non-aggregatable fields in picker, but hide unselected ones', () => { + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field2', 'field3']); + }); + it('should select fields from picker', () => { expect( getInstance() @@ -130,6 +153,25 @@ describe('field_manager', () => { expect(getInstance().find(FieldEditor).length).toEqual(1); }); + it('should show remove non-aggregatable fields from picker after deselection', () => { + act(() => { + getInstance() + .find(FieldEditor) + .at(1) + .dive() + .find(EuiContextMenu) + .prop('panels')![0].items![2].onClick!({} as any); + }); + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field3']); + }); + it('should disable field', () => { const toggleItem = getInstance() .find(FieldEditor) diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx index 6ad792defb6697..b38e3f8430980e 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx @@ -114,9 +114,26 @@ export function FieldPicker({ function toOptions( fields: WorkspaceField[] ): Array<{ label: string; checked?: 'on' | 'off'; prepend?: ReactNode }> { - return fields.map(field => ({ - label: field.name, - prepend: , - checked: field.selected ? 'on' : undefined, - })); + return ( + fields + // don't show non-aggregatable fields, except for the case when they are already selected. + // this is necessary to ensure backwards compatibility with existing workspaces that might + // contain non-aggregatable fields. + .filter(field => isExplorable(field) || field.selected) + .map(field => ({ + label: field.name, + prepend: , + checked: field.selected ? 'on' : undefined, + })) + ); +} + +const explorableTypes = ['string', 'number', 'date', 'ip', 'boolean']; + +function isExplorable(field: WorkspaceField) { + if (!field.aggregatable) { + return false; + } + + return explorableTypes.includes(field.type); } diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx index a615901f40e253..0109e1f5a5ac7b 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx @@ -112,6 +112,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, { selected: false, @@ -123,6 +124,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, ]) ); diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts index 3bfc868fcb06e9..79ff4debc7e825 100644 --- a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts @@ -13,8 +13,24 @@ describe('fetch_top_nodes', () => { it('should build terms agg', async () => { const postMock = jest.fn(() => Promise.resolve({ resp: {} })); await fetchTopNodes(postMock as any, 'test', [ - { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: '', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: '', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', { body: JSON.stringify({ @@ -65,8 +81,24 @@ describe('fetch_top_nodes', () => { }) ); const result = await fetchTopNodes(postMock as any, 'test', [ - { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: 'red', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: 'blue', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(result.length).toEqual(4); expect(result[0]).toEqual({ diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts index d38c950a5986f2..1861479f85f189 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -119,9 +119,9 @@ describe('deserialize', () => { savedWorkspace, { getNonScriptedFields: () => [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'string' }, - { name: 'field3', type: 'string' }, + { name: 'field1', type: 'string', aggregatable: true }, + { name: 'field2', type: 'string', aggregatable: true }, + { name: 'field3', type: 'string', aggregatable: true }, ], } as IndexPattern, workspace @@ -140,6 +140,7 @@ describe('deserialize', () => { expect(allFields).toMatchInlineSnapshot(` Array [ Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -149,6 +150,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -158,6 +160,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "#CE0060", "hopSize": 5, "icon": Object { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts index af34b4f1a725b4..43425077cc174d 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts @@ -89,6 +89,7 @@ export function mapFields(indexPattern: IndexPattern): WorkspaceField[] { color: colorChoices[index % colorChoices.length], selected: false, type: field.type, + aggregatable: Boolean(field.aggregatable), })) .sort((a, b) => { if (a.name < b.name) { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts index 0e0c750383a71e..a3942eccfdac36 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts @@ -41,6 +41,7 @@ describe('serialize', () => { name: 'field1', selected: true, type: 'string', + aggregatable: true, }, { color: 'black', @@ -48,6 +49,7 @@ describe('serialize', () => { name: 'field2', selected: true, type: 'string', + aggregatable: true, }, ], selectedIndex: { diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts index eef8060f07f5cd..876f2cf23b53a9 100644 --- a/x-pack/legacy/plugins/graph/public/types/app_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts @@ -25,6 +25,7 @@ export interface WorkspaceField { icon: FontawesomeIcon; selected: boolean; type: string; + aggregatable: boolean; } export interface AdvancedSettings { diff --git a/x-pack/legacy/plugins/graph/public/types/persistence.ts b/x-pack/legacy/plugins/graph/public/types/persistence.ts index 7fc5e15d9ea72b..adb07605b61c46 100644 --- a/x-pack/legacy/plugins/graph/public/types/persistence.ts +++ b/x-pack/legacy/plugins/graph/public/types/persistence.ts @@ -37,7 +37,7 @@ export interface SerializedUrlTemplate extends Omit { +export interface SerializedField extends Omit { iconClass: string; } diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 9463eccb93a022..2c0ea7fe699b80 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -20,8 +20,8 @@ import sinon from 'sinon'; import { findTestSubject } from '@elastic/eui/lib/test'; import { positiveNumbersAboveZeroErrorMessage, - numberRequiredMessage, positiveNumberRequiredMessage, + numberRequiredMessage, maximumAgeRequiredMessage, maximumSizeRequiredMessage, policyNameRequiredMessage, @@ -243,17 +243,18 @@ describe('edit policy', () => { noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', ''); save(rendered); expectedErrorMessages(rendered, [numberRequiredMessage]); }); - test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); setPhaseAfter(rendered, 'warm', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save warm phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -383,14 +384,14 @@ describe('edit policy', () => { }); }); describe('cold phase', () => { - test('should show positive number required error when trying to save cold phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'cold'); setPhaseAfter(rendered, 'cold', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save cold phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -464,14 +465,14 @@ describe('edit policy', () => { }); }); describe('delete phase', () => { - test('should show positive number required error when trying to save delete phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'delete'); setPhaseAfter(rendered, 'delete', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', () => { const rendered = mountWithIntl(component); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js index 0ed28bbaa905f3..b4c9f4e958cd22 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js @@ -131,7 +131,7 @@ export const MinAgeInput = props => { onChange={async e => { setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); }} - min={1} + min={0} /> diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js index b0af0e6547803e..a8f7fd3f4bdfa9 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js @@ -17,7 +17,7 @@ import { export const defaultColdPhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_REPLICA_COUNT]: '', diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js index 5a44539ff90f84..b5296cd83fabdc 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js @@ -15,7 +15,7 @@ export const defaultDeletePhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', }; export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js index d3dc55178b253d..f02ac2096675fc 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js @@ -23,7 +23,7 @@ export const defaultWarmPhase = { [PHASE_ROLLOVER_ALIAS]: '', [PHASE_FORCE_MERGE_SEGMENTS]: '', [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_SHRINK_ENABLED]: false, diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js index 026845c78ee662..750a7feb19c3d5 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js @@ -120,12 +120,6 @@ export const validatePhase = (type, phase, errors) => { phaseErrors[numberedAttribute] = [numberRequiredMessage]; } else if (phase[numberedAttribute] < 0) { phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } else if ( - (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE || - numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) && - phase[numberedAttribute] < 1 - ) { - phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage]; } } } diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index 053b389a590110..f05b7eba9e7e01 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -39,7 +39,7 @@ export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS'; export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER'; export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST'; export const REMOVE_LAYER = 'REMOVE_LAYER'; -export const TOGGLE_LAYER_VISIBLE = 'TOGGLE_LAYER_VISIBLE'; +export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY'; export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED'; export const MAP_READY = 'MAP_READY'; export const MAP_DESTROYED = 'MAP_DESTROYED'; @@ -72,6 +72,7 @@ export const DISABLE_TOOLTIP_CONTROL = 'DISABLE_TOOLTIP_CONTROL'; export const HIDE_TOOLBAR_OVERLAY = 'HIDE_TOOLBAR_OVERLAY'; export const HIDE_LAYER_CONTROL = 'HIDE_LAYER_CONTROL'; export const HIDE_VIEW_CONTROL = 'HIDE_VIEW_CONTROL'; +export const SET_WAITING_FOR_READY_HIDDEN_LAYERS = 'SET_WAITING_FOR_READY_HIDDEN_LAYERS'; function getLayerLoadingCallbacks(dispatch, layerId) { return { @@ -252,23 +253,25 @@ export function cleanTooltipStateForLayer(layerId, layerFeatures = []) { }; } -export function toggleLayerVisible(layerId) { +export function setLayerVisibility(layerId, makeVisible) { return async (dispatch, getState) => { //if the current-state is invisible, we also want to sync data //e.g. if a layer was invisible at start-up, it won't have any data loaded const layer = getLayerById(layerId, getState()); - if (!layer) { + + // If the layer visibility is already what we want it to be, do nothing + if (!layer || layer.isVisible() === makeVisible) { return; } - const makeVisible = !layer.isVisible(); if (!makeVisible) { dispatch(cleanTooltipStateForLayer(layerId)); } await dispatch({ - type: TOGGLE_LAYER_VISIBLE, + type: SET_LAYER_VISIBILITY, layerId, + visibility: makeVisible, }); if (makeVisible) { dispatch(syncDataForLayer(layerId)); @@ -276,6 +279,18 @@ export function toggleLayerVisible(layerId) { }; } +export function toggleLayerVisible(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + const makeVisible = !layer.isVisible(); + + dispatch(setLayerVisibility(layerId, makeVisible)); + }; +} + export function setSelectedLayer(layerId) { return async (dispatch, getState) => { const oldSelectedLayer = getSelectedLayerId(getState()); @@ -840,3 +855,17 @@ export function hideLayerControl() { export function hideViewControl() { return { type: HIDE_VIEW_CONTROL, hideViewControl: true }; } + +export function setHiddenLayers(hiddenLayerIds) { + return (dispatch, getState) => { + const isMapReady = getMapReady(getState()); + + if (!isMapReady) { + dispatch({ type: SET_WAITING_FOR_READY_HIDDEN_LAYERS, hiddenLayerIds }); + } else { + getLayerListRaw(getState()).forEach(layer => + dispatch(setLayerVisibility(layer.id, !hiddenLayerIds.includes(layer.id))) + ); + } + }; +} diff --git a/x-pack/legacy/plugins/maps/public/embeddable/README.md b/x-pack/legacy/plugins/maps/public/embeddable/README.md index eb6571a96016c7..1de327702fb87a 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/README.md +++ b/x-pack/legacy/plugins/maps/public/embeddable/README.md @@ -9,6 +9,7 @@ - **hideToolbarOverlay:** (Boolean) Will disable toolbar, which can be used to navigate to coordinate by entering lat/long and zoom values. - **hideLayerControl:** (Boolean) Will hide useful layer control, which can be used to hide/show a layer to get a refined view of the map. - **hideViewControl:** (Boolean) Will hide view control at bottom right of the map, which shows lat/lon values based on mouse hover in the map, this is useful to get coordinate value from a particular point in map. +- **hiddenLayers:** (Array of Strings) Array of layer ids that should be hidden. Any other layers will be set to visible regardless of their value in the layerList used to initialize the embeddable ### Creating a Map embeddable from saved object ``` diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js index 2ee766f91fbca2..c723e996ee6797 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js @@ -32,11 +32,12 @@ import { hideToolbarOverlay, hideLayerControl, hideViewControl, + setHiddenLayers, } from '../actions/map_actions'; import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializable_instances'; -import { getMapCenter, getMapZoom } from '../selectors/map_selectors'; +import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; export class MapEmbeddable extends Embeddable { @@ -153,6 +154,9 @@ export class MapEmbeddable extends Embeddable { } this._store.dispatch(replaceLayerList(this._layerList)); + if (this.input.hiddenLayers) { + this._store.dispatch(setHiddenLayers(this.input.hiddenLayers)); + } this._dispatchSetQuery(this.input); this._dispatchSetRefreshConfig(this.input); @@ -244,5 +248,13 @@ export class MapEmbeddable extends Embeddable { openTOCDetails, }); } + + const hiddenLayerIds = getHiddenLayerIds(this._store.getState()); + + if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) { + this.updateInput({ + hiddenLayers: hiddenLayerIds, + }); + } } } diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js index 7dd60f013cefde..ac409c685c71af 100644 --- a/x-pack/legacy/plugins/maps/public/reducers/map.js +++ b/x-pack/legacy/plugins/maps/public/reducers/map.js @@ -16,7 +16,7 @@ import { ADD_WAITING_FOR_MAP_READY_LAYER, CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST, REMOVE_LAYER, - TOGGLE_LAYER_VISIBLE, + SET_LAYER_VISIBILITY, MAP_EXTENT_CHANGED, MAP_READY, MAP_DESTROYED, @@ -46,6 +46,7 @@ import { HIDE_TOOLBAR_OVERLAY, HIDE_LAYER_CONTROL, HIDE_VIEW_CONTROL, + SET_WAITING_FOR_READY_HIDDEN_LAYERS, } from '../actions/map_actions'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util'; @@ -307,8 +308,8 @@ export function map(state = INITIAL_STATE, action) { ...state, waitingForMapReadyLayerList: [], }; - case TOGGLE_LAYER_VISIBLE: - return updateLayerInList(state, action.layerId, 'visible'); + case SET_LAYER_VISIBILITY: + return updateLayerInList(state, action.layerId, 'visible', action.visibility); case UPDATE_LAYER_STYLE: const styleLayerId = action.layerId; return updateLayerInList(state, styleLayerId, 'style', { ...action.style }); @@ -376,6 +377,14 @@ export function map(state = INITIAL_STATE, action) { hideViewControl: action.hideViewControl, }, }; + case SET_WAITING_FOR_READY_HIDDEN_LAYERS: + return { + ...state, + waitingForMapReadyLayerList: state.waitingForMapReadyLayerList.map(layer => ({ + ...layer, + visible: !action.hiddenLayerIds.includes(layer.id), + })), + }; default: return state; } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index 3d8e6f97ef077d..4b3d1355e4264f 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -150,6 +150,10 @@ export const getLayerList = createSelector( } ); +export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => + layers.filter(layer => !layer.visible).map(layer => layer.id) +); + export const getSelectedLayer = createSelector( getSelectedLayerId, getLayerList, diff --git a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index aa28831e8d8074..a28dc41fa1790f 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -37,8 +37,7 @@ function useRefWithCallback() { if (left + tooltipWidth > contentWidth) { // the tooltip is hanging off the side of the page, // so move it to the other side of the target - const markerWidthAdjustment = 25; - left = left - (tooltipWidth + offset.x + markerWidthAdjustment); + left = left - (tooltipWidth + offset.x); } const top = targetPosition.top + offset.y - parentBounding.top; diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap index fb6b9c7c9db66c..f7752a1ae25a1c 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap @@ -86,7 +86,7 @@ exports[`ValidateJob renders the button 1`] = ` iconSide="right" iconType="questionInCircle" isDisabled={false} - isLoading={false} + isLoading={true} onClick={[Function]} size="s" > diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js index 9b92265c4034bc..a5ed7c3753b2f0 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -21,6 +21,9 @@ import { EuiOverlayMask, EuiSpacer, EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -38,7 +41,7 @@ const defaultIconType = 'questionInCircle'; const getDefaultState = () => ({ ui: { iconType: defaultIconType, - isLoading: false, + isLoading: true, isModalVisible: false, }, data: { @@ -150,6 +153,14 @@ Callout.propTypes = { }), }; +const LoadingSpinner = () => ( + + + + + +); + const Modal = ({ close, title, children }) => ( @@ -249,10 +260,11 @@ export class ValidateJob extends Component { const isDisabled = this.props.isDisabled !== true ? false : true; const embedded = this.props.embedded === true; const idFilterList = this.props.idFilterList || []; + const isLoading = this.state.ui.isLoading; return ( - {embedded === false && ( + {embedded === false ? (

} > - + {isLoading ? ( + + ) : ( + + )} )}
- )} - {embedded === true && ( - + ) : ( + + {isLoading ? ( + + ) : ( + + )} + )} ); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 9db6f8f0a1c35b..4d10d73bcc0489 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -1523,12 +1523,12 @@ const TimeseriesChartIntl = injectI18n( } else { tooltipData.push({ name: intl.formatMessage({ - id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel', - defaultMessage: 'value', + id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.actualLabel', + defaultMessage: 'actual', }), - value: formatValue(marker.value, marker.function, fieldFormat), + value: formatValue(marker.actual, marker.function, fieldFormat), seriesKey, - yAccessor: 'value', + yAccessor: 'actual', }); tooltipData.push({ name: intl.formatMessage({ diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 202448340f526d..0ab10c4fe69cd9 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1394,156 +1394,158 @@ export class TimeSeriesExplorer extends React.Component { jobs.length > 0 && (fullRefresh === false || loading === false) && hasResults === true && ( - - {/* Make sure ChartTooltip is inside this plain wrapping element so positioning can be infered correctly. */} +
+ {/* Make sure ChartTooltip is inside this plain wrapping element without padding so positioning can be infered correctly. */} - - {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { - defaultMessage: 'Single time series analysis of {functionLabel}', - values: { functionLabel: chartDetails.functionLabel }, - })} - -   - {chartDetails.entityData.count === 1 && ( - - {chartDetails.entityData.entities.length > 0 && '('} - {chartDetails.entityData.entities - .map(entity => { - return `${entity.fieldName}: ${entity.fieldValue}`; - }) - .join(', ')} - {chartDetails.entityData.entities.length > 0 && ')'} - - )} - {chartDetails.entityData.count !== 1 && ( - - {chartDetails.entityData.entities.map((countData, i) => { - return ( - - {i18n.translate( - 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', - { - defaultMessage: - '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', - values: { - openBrace: i === 0 ? '(' : '', - closeBrace: - i === chartDetails.entityData.entities.length - 1 ? ')' : '', - cardinalityValue: - countData.cardinality === 0 - ? allValuesLabel - : countData.cardinality, - cardinality: countData.cardinality, - fieldName: countData.fieldName, - }, - } - )} - {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} - - ); + + + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel }, })} - )} - - {showModelBoundsCheckbox && ( - - + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities + .map(entity => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }) + .join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate( + 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', + { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: i === 0 ? '(' : '', + closeBrace: + i === chartDetails.entityData.entities.length - 1 ? ')' : '', + cardinalityValue: + countData.cardinality === 0 + ? allValuesLabel + : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName, + }, + } + )} + {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} + + ); + })} + + )} + + {showModelBoundsCheckbox && ( + + + + )} + + {showAnnotationsCheckbox && ( + + + + )} + + {showForecastCheckbox && ( + + + + )} + +
+ +
+ {showAnnotations && focusAnnotationData.length > 0 && ( +
+ + {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { + defaultMessage: 'Annotations', })} - checked={showModelBounds} - onChange={this.toggleShowModelBoundsHandler} + + - + +
)} - - {showAnnotationsCheckbox && ( - - + + {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { + defaultMessage: 'Anomalies', + })} + + + + + > + + - )} - - {showForecastCheckbox && ( - - + + > + + - )} - -
- -
- {showAnnotations && focusAnnotationData.length > 0 && ( -
- - {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { - defaultMessage: 'Annotations', - })} - - - -
- )} - - - {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { - defaultMessage: 'Anomalies', - })} - - - - - - - - - - - - - - - + + + +
)} {arePartitioningFieldsProvided && jobs.length > 0 && ( diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts index a7afee237dba9c..8cdaa192fcbc9b 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -345,6 +345,98 @@ describe('ML - custom URL utils', () => { ); }); + test('returns expected URL for APM', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:"$trace.id$" and transaction.name:"$transaction.name$"&_g=()', + }; + + const testRecords = { + job_id: 'abnormal_trace_durations_nodejs', + result_type: 'record', + probability: 0.025597710862701226, + multi_bucket_impact: 5, + record_score: 13.124152090331723, + initial_record_score: 13.124152090331723, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573339500000, + by_field_name: 'transaction.name', + by_field_value: 'GET /test-data', + function: 'high_mean', + function_description: 'mean', + typical: [802.0600710562369], + actual: [761.1531339031332], + field_name: 'transaction.duration.us', + influencers: [ + { + influencer_field_name: 'transaction.name', + influencer_field_values: ['GET /test-data'], + }, + { + influencer_field_name: 'trace.id', + influencer_field_values: [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + }, + { + influencer_field_name: 'service.name', + influencer_field_values: ['example-service'], + }, + ], + 'trace.id': [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + 'service.name': ['example-service'], + 'transaction.name': ['GET /test-data'], + earliest: '2019-11-09T20:45:00.000Z', + latest: '2019-11-10T01:00:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/traces?rangeFrom=2019-11-09T20:45:00.000Z&rangeTo=2019-11-10T01:00:00.000Z&kuery=(trace.id:"000a09d58a428f38550e7e87637733c1" OR trace.id:"0039c771d8bbadf6137767d3aeb89f96" OR trace.id:"01279ed5bb9f4249e3822d16dec7f2f2") AND transaction.name:"GET%20%2Ftest-data"&_g=()' + ); + }); + + test('removes an empty path component with a trailing slash', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request', + }; + + const testRecords = { + job_id: 'decreased_throughput_jsbase', + result_type: 'record', + probability: 8.91350850732573e-9, + multi_bucket_impact: 5, + record_score: 93.63625728951217, + initial_record_score: 93.63625728951217, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573266600000, + function: 'low_count', + function_description: 'count', + typical: [100615.66506877479], + actual: [25251], + earliest: '2019-11-09T00:30:00.000Z', + latest: '2019-11-09T04:45:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/services/transactions?rangeFrom=2019-11-09T00:30:00.000Z&rangeTo=2019-11-09T04:45:00.000Z&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request' + ); + }); + test('returns expected URL for other type URL', () => { expect(getUrlForRecord(TEST_OTHER_URL, TEST_RECORD)).toBe( 'http://airlinecodes.info/airline-code-AAL' diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts index e2f2dc0ad0fe84..7774f6dec0c953 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts @@ -97,7 +97,11 @@ export function openCustomUrlWindow(fullUrl: string, urlConfig: UrlConfig) { // a Kibana Discover or Dashboard page running on the same server as this ML plugin. function isKibanaUrl(urlConfig: UrlConfig) { const urlValue = urlConfig.url_value; - return urlValue.startsWith('kibana#/discover') || urlValue.startsWith('kibana#/dashboard'); + return ( + urlValue.startsWith('kibana#/discover') || + urlValue.startsWith('kibana#/dashboard') || + urlValue.startsWith('apm#/') + ); } /** @@ -136,13 +140,14 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); - return str.replace(/\$([^?&$\'"]+)\$/g, (match, name: string) => { + // Looking for a $token$ with an optional trailing slash + return str.replace(/\$([^?&$\'"]+)\$(\/)?/g, (match, name: string, slash: string = '') => { // Use lodash get to allow nested JSON fields to be retrieved. let tokenValue: string | string[] | undefined = get(record, name); tokenValue = Array.isArray(tokenValue) ? tokenValue[0] : tokenValue; - // If property not found string is not replaced. - return tokenValue === undefined ? match : getResultTokenValue(tokenValue); + // If property not found token is replaced with an empty string. + return tokenValue === undefined ? '' : getResultTokenValue(tokenValue) + slash; }); }; @@ -155,7 +160,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); return str.replace( - /(.+query:')([^']*)('.+)/, + /(.+query:'|.+&kuery=)([^']*)(['&].+)/, (fullMatch, prefix: string, queryString: string, postfix: string) => { const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); @@ -170,28 +175,39 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) const queryParts: string[] = []; const joinOperator = ' AND '; - for (let i = 0; i < queryFields.length; i++) { + fieldsLoop: for (let i = 0; i < queryFields.length; i++) { const field = queryFields[i]; // Use lodash get to allow nested JSON fields to be retrieved. - const tokenValues: string[] | string | null = get(record, field) || null; + let tokenValues: string[] | string | null = get(record, field) || null; if (tokenValues === null) { continue; } + tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; + // Create a pair `influencerField:value`. // In cases where there are multiple influencer field values for an anomaly // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. - let result = (Array.isArray(tokenValues) ? tokenValues : [tokenValues]) - .map(value => `${field}:"${getResultTokenValue(value)}"`) - .join(' OR '); - result = tokenValues.length > 1 ? `(${result})` : result; - - // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. - availableCharactersLeft -= result.length - (i === 0 ? 0 : joinOperator.length); - - if (availableCharactersLeft <= 0) { - break; - } else { - queryParts.push(result); + let result = ''; + for (let j = 0; j < tokenValues.length; j++) { + const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue( + tokenValues[j] + )}"`; + + // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. + if (availableCharactersLeft < part.length) { + if (result.length > 0) { + queryParts.push(j > 0 ? `(${result})` : result); + } + break fieldsLoop; + } + + result += part; + + availableCharactersLeft -= result.length; + } + + if (result.length > 0) { + queryParts.push(tokenValues.length > 1 ? `(${result})` : result); } } diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js index cb268ffede7fae..9c5048daeee3f0 100644 --- a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js @@ -12,6 +12,8 @@ describe('ML - data recognizer', () => { const moduleIds = [ 'apache_ecs', + 'apm_jsbase', + 'apm_nodejs', 'apm_transaction', 'auditbeat_process_docker_ecs', 'auditbeat_process_hosts_ecs', diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json new file mode 100644 index 00000000000000..3905c809fbd7a8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json new file mode 100644 index 00000000000000..e463b34be0fc27 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "apm_jsbase", + "title": "APM: RUM Javascript", + "description": "Detect problematic spans and identify user agents that are potentially causing issues.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { + "filter": [{ "term": { "agent.name": "js-base" } }] + } + }, + "jobs": [ + { + "id": "abnormal_span_durations_jsbase", + "file": "abnormal_span_durations_jsbase.json" + }, + { + "id": "anomalous_error_rate_for_user_agents_jsbase", + "file": "anomalous_error_rate_for_user_agents_jsbase.json" + }, + { + "id": "decreased_throughput_jsbase", + "file": "decreased_throughput_jsbase.json" + }, + { + "id": "high_count_by_user_agent_jsbase", + "file": "high_count_by_user_agent_jsbase.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_jsbase", + "file": "datafeed_abnormal_span_durations_jsbase.json", + "job_id": "abnormal_span_durations_jsbase" + }, + { + "id": "datafeed-anomalous_error_rate_for_user_agents_jsbase", + "file": "datafeed_anomalous_error_rate_for_user_agents_jsbase.json", + "job_id": "anomalous_error_rate_for_user_agents_jsbase" + }, + { + "id": "datafeed-decreased_throughput_jsbase", + "file": "datafeed_decreased_throughput_jsbase.json", + "job_id": "decreased_throughput_jsbase" + }, + { + "id": "datafeed-high_count_by_user_agent_jsbase", + "file": "datafeed_high_count_by_user_agent_jsbase.json", + "job_id": "high_count_by_user_agent_jsbase" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json new file mode 100644 index 00000000000000..e0b51a4dcd05e9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 00000000000000..66fd9858c6885a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are encountering errors at an above normal rate. This can help detect browser compatibility issues.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high error rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "error.exception.message.keyword", + "error.page.url", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/errors?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json new file mode 100644 index 00000000000000..7ecbe2890b826c --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 00000000000000..fbfedcbf47561b --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "exists": { "field": "user_agent.name" } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json new file mode 100644 index 00000000000000..48cba1f1578154 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "js-base" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json new file mode 100644 index 00000000000000..18ca6b13892871 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": [{ "exists": { "field": "user_agent.name" } }] } }, + { "bool": { "filter": { "term": { "processor.event": "transaction" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json new file mode 100644 index 00000000000000..4bc8757f19dc96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json new file mode 100644 index 00000000000000..7e1316359eabbd --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are making requests at a suspiciously high rate. This is useful in identifying bots.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high request rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json new file mode 100644 index 00000000000000..3905c809fbd7a8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json new file mode 100644 index 00000000000000..1865a33a1d3011 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "apm_nodejs", + "title": "APM: NodeJS", + "description": "Detect abnormal traces, anomalous spans, and identify periods of decreased throughput.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { "filter": [{ "term": { "agent.name": "nodejs" } }] } + }, + "jobs": [ + { + "id": "abnormal_span_durations_nodejs", + "file": "abnormal_span_durations_nodejs.json" + }, + { + "id": "abnormal_trace_durations_nodejs", + "file": "abnormal_trace_durations_nodejs.json" + }, + { + "id": "decreased_throughput_nodejs", + "file": "decreased_throughput_nodejs.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_nodejs", + "file": "datafeed_abnormal_span_durations_nodejs.json", + "job_id": "abnormal_span_durations_nodejs" + }, + { + "id": "datafeed-abnormal_trace_durations_nodejs", + "file": "datafeed_abnormal_trace_durations_nodejs.json", + "job_id": "abnormal_trace_durations_nodejs" + }, + { + "id": "datafeed-decreased_throughput_nodejs", + "file": "datafeed_decreased_throughput_nodejs.json", + "job_id": "decreased_throughput_nodejs" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json new file mode 100644 index 00000000000000..1a8318437790ef --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json new file mode 100644 index 00000000000000..875b49e895a008 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies trace transactions that are processing more slowly than usual.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased trace duration", + "function": "high_mean", + "field_name": "transaction.duration.us", + "by_field_name": "transaction.name" + } + ], + "influencers": [ + "transaction.name", + "trace.id", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\" and transaction.name:\"$transaction.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json new file mode 100644 index 00000000000000..3e4f4877bd0424 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json new file mode 100644 index 00000000000000..d87f809a499406 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json @@ -0,0 +1,13 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must_not": [{ "exists": { "field": "parent.id" } }], + "must": [{ "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json new file mode 100644 index 00000000000000..451957c327dd04 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "nodejs" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json new file mode 100644 index 00000000000000..f63c6289a5cd90 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "transaction.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js index 57fdbd5cc62383..c08ae91769b9d4 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js @@ -58,7 +58,7 @@ describe('getPaginatedNodes', () => { }, }, }; - const shardStats = { + const nodesShardCount = { nodes: { 1: { shardCount: 10, @@ -78,7 +78,7 @@ describe('getPaginatedNodes', () => { pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -98,7 +98,7 @@ describe('getPaginatedNodes', () => { pagination, { ...sort, field: 'foo', direction: 'desc' }, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -118,7 +118,7 @@ describe('getPaginatedNodes', () => { pagination, sort, 'tw', - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [{ name: 'two', uuid: 2, isOnline: false, shardCount: 5, foo: 12 }], diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js index 4bfd0090fced06..7581a325909712 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js @@ -29,7 +29,7 @@ import { LISTING_METRICS_NAMES, LISTING_METRICS_PATHS } from './nodes_listing_me * @param {Object} shardStats: per-node information about shards * @return {Array} node info combined with metrics for each node from handle_response */ -export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats) { +export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, nodesShardCount) { checkParam(esIndexPattern, 'esIndexPattern in getNodes'); const start = moment.utc(req.payload.timeRange.min).valueOf(); @@ -104,5 +104,9 @@ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, s const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); const response = await callWithRequest(req, 'search', params); - return handleResponse(response, clusterStats, shardStats, pageOfNodes, { min, max, bucketSize }); + return handleResponse(response, clusterStats, nodesShardCount, pageOfNodes, { + min, + max, + bucketSize, + }); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js index 15084d952b3438..0023b9515ad1cc 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js @@ -35,7 +35,7 @@ export async function getPaginatedNodes( pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ) { const config = req.server.config(); const size = config.get('xpack.monitoring.max_bucket_size'); @@ -45,7 +45,7 @@ export async function getPaginatedNodes( const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); for (const node of nodes) { node.isOnline = !isUndefined(get(clusterState, ['nodes', node.uuid])); - node.shardCount = get(shardStats, `nodes[${node.uuid}].shardCount`, 0); + node.shardCount = get(nodesShardCount, `nodes[${node.uuid}].shardCount`, 0); } // `metricSet` defines a list of metrics that are sortable in the UI diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js index 55072a10866417..651fd20d775544 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js @@ -13,17 +13,23 @@ import { uncovertMetricNames } from '../../convert_metric_names'; * Process the response from the get_nodes query * @param {Object} response: response data from get_nodes * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @param {Object} timeOptions: min, max, and bucketSize needed for date histogram creation * @return {Array} node info combined with metrics for each node */ -export function handleResponse(response, clusterStats, shardStats, pageOfNodes, timeOptions = {}) { +export function handleResponse( + response, + clusterStats, + nodesShardCount, + pageOfNodes, + timeOptions = {} +) { if (!get(response, 'hits.hits')) { return []; } const nodeHits = get(response, 'hits.hits', []); - const nodesInfo = mapNodesInfo(nodeHits, clusterStats, shardStats); + const nodesInfo = mapNodesInfo(nodeHits, clusterStats, nodesShardCount); /* * Every node bucket is an object with a field for nodeId and fields for diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js index 23ee614d48ec44..3c719c2ddfbf8c 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js @@ -10,10 +10,10 @@ import { calculateNodeType, getNodeTypeClassLabel } from '../'; /** * @param {Array} nodeHits: info about each node from the hits in the get_nodes query * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @return {Object} summarized info about each node keyed by nodeId */ -export function mapNodesInfo(nodeHits, clusterStats, shardStats) { +export function mapNodesInfo(nodeHits, clusterStats, nodesShardCount) { const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); return nodeHits.reduce((prev, node) => { @@ -35,7 +35,7 @@ export function mapNodesInfo(nodeHits, clusterStats, shardStats) { isOnline, nodeTypeLabel: nodeTypeLabel, nodeTypeClass: nodeTypeClass, - shardCount: get(shardStats, `nodes[${sourceNode.uuid}].shardCount`, 0), + shardCount: get(nodesShardCount, `nodes[${sourceNode.uuid}].shardCount`, 0), }, }; }, {}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js new file mode 100644 index 00000000000000..e8d484e7021f47 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js @@ -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 { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; +import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals'; + +async function getUnassignedShardData(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + indices: { + terms: { + field: 'shard.index', + size: maxBucketSize, + }, + aggs: { + state: { + filter: { + terms: { + 'shard.state': ['UNASSIGNED', 'INITIALIZING'], + }, + }, + aggs: { + primary: { + terms: { + field: 'shard.primary', + size: 2, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getIndicesUnassignedShardStats(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getUnassignedShardData(req, esIndexPattern, cluster); + const indices = get(response, 'aggregations.indices.buckets', []).reduce((accum, bucket) => { + const index = bucket.key; + const states = get(bucket, 'state.primary.buckets', []); + const unassignedReplica = states + .filter(state => state.key_as_string === 'false') + .reduce((total, state) => total + state.doc_count, 0); + const unassignedPrimary = states + .filter(state => state.key_as_string === 'true') + .reduce((total, state) => total + state.doc_count, 0); + + let status = 'green'; + if (unassignedReplica > 0) { + status = 'yellow'; + } + if (unassignedPrimary > 0) { + status = 'red'; + } + + accum[index] = { + unassigned: { primary: unassignedPrimary, replica: unassignedReplica }, + status, + }; + return accum; + }, {}); + + const indicesTotals = calculateIndicesTotals(indices); + return { indices, indicesTotals }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js new file mode 100644 index 00000000000000..a899b48cdd4342 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js @@ -0,0 +1,59 @@ +/* + * 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 { getIndicesUnassignedShardStats } from './get_indices_unassigned_shard_stats'; + +describe('getIndicesUnassignedShardStats', () => { + it('should return the unassigned shard stats for indices', async () => { + const indices = { + 12345: { status: 'red', unassigned: { primary: 1, replica: 0 } }, + 6789: { status: 'yellow', unassigned: { primary: 0, replica: 1 } }, + absdf82: { status: 'green', unassigned: { primary: 0, replica: 0 } }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + indices: { + buckets: Object.keys(indices).map(id => ({ + key: id, + state: { + primary: { + buckets: + indices[id].unassigned.primary || indices[id].unassigned.replica + ? [ + { + key_as_string: indices[id].unassigned.primary + ? 'true' + : 'false', + doc_count: 1, + }, + ] + : [], + }, + }, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const stats = await getIndicesUnassignedShardStats(req, esIndexPattern, cluster); + expect(stats.indices).toEqual(indices); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js new file mode 100644 index 00000000000000..c11bd4aead693f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; + +async function getShardCountPerNode(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + nodes: { + terms: { + field: 'shard.node', + size: maxBucketSize, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getNodesShardCount(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getShardCountPerNode(req, esIndexPattern, cluster); + const nodes = get(response, 'aggregations.nodes.buckets', []).reduce((accum, bucket) => { + accum[bucket.key] = { shardCount: bucket.doc_count }; + return accum; + }, {}); + return { nodes }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js new file mode 100644 index 00000000000000..023f12db1bf46f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js @@ -0,0 +1,45 @@ +/* + * 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 { getNodesShardCount } from './get_nodes_shard_count'; + +describe('getNodeShardCount', () => { + it('should return the shard count per node', async () => { + const nodes = { + 12345: { shardCount: 10 }, + 6789: { shardCount: 1 }, + absdf82: { shardCount: 20 }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + nodes: { + buckets: Object.keys(nodes).map(id => ({ + key: id, + doc_count: nodes[id].shardCount, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const counts = await getNodesShardCount(req, esIndexPattern, cluster); + expect(counts.nodes).toEqual(nodes); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js index a718ef8569dbf8..eddd50612cdb13 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js @@ -8,7 +8,7 @@ * @param {Object} config - Kibana config service * @param {Boolean} includeNodes - whether to add the aggs for node shards */ -export function getShardAggs(config, includeNodes) { +export function getShardAggs(config, includeNodes, includeIndices) { const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); const aggSize = 10; const indicesAgg = { @@ -40,7 +40,7 @@ export function getShardAggs(config, includeNodes) { }; return { - ...{ indices: indicesAgg }, + ...{ indices: includeIndices ? indicesAgg : undefined }, ...{ nodes: includeNodes ? nodesAgg : undefined }, }; } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js index c77e03673bb4c4..132e9d6b01dbee 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js @@ -21,11 +21,11 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) { if (buckets && buckets.length !== 0) { indices = buckets.reduce(normalizeIndexShards, {}); indicesTotals = calculateIndicesTotals(indices); + } - if (includeNodes) { - const masterNode = get(cluster, 'cluster_state.master_node'); - nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); - } + if (includeNodes) { + const masterNode = get(cluster, 'cluster_state.master_node'); + nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); } return { @@ -39,12 +39,19 @@ export function getShardStats( req, esIndexPattern, cluster, - { includeNodes = false, includeIndices = false } = {} + { includeNodes = false, includeIndices = false, indexName = null, nodeUuid = null } = {} ) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const config = req.server.config(); const metric = ElasticsearchMetric.getMetricFields(); + const filters = [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }]; + if (indexName) { + filters.push({ term: { 'shard.index': indexName } }); + } + if (nodeUuid) { + filters.push({ term: { 'shard.node': nodeUuid } }); + } const params = { index: esIndexPattern, size: 0, @@ -55,10 +62,10 @@ export function getShardStats( type: 'shards', clusterUuid: cluster.cluster_uuid, metric, - filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + filters, }), aggs: { - ...getShardAggs(config, includeNodes), + ...getShardAggs(config, includeNodes, includeIndices), }, }, }; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js index e6380a724590e2..c32e25d9f20d19 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js @@ -60,6 +60,7 @@ export function esIndexRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeNodes: true, includeIndices: true, + indexName: indexUuid, }); const indexSummary = await getIndexSummary(req, esIndexPattern, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js index c8cf4bd29e26d1..241b54fbf0c2aa 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getIndices } from '../../../../lib/elasticsearch/indices'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esIndicesRoute(server) { server.route({ @@ -43,13 +43,20 @@ export function esIndicesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeIndices: true, - }); - const indices = await getIndices(req, esIndexPattern, showSystemIndices, shardStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const indices = await getIndices( + req, + esIndexPattern, + showSystemIndices, + indicesUnassignedShardStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), indices, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js index 1876f751dd1667..de3b9863d91414 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getMlJobs } from '../../../../lib/elasticsearch/get_ml_jobs'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function mlJobRoute(server) { server.route({ @@ -39,11 +39,15 @@ export function mlJobRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); const rows = await getMlJobs(req, esIndexPattern); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), rows, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js index 5da2e7128e7e4a..10226d74ed0010 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js @@ -78,6 +78,7 @@ export function esNodeRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeIndices: true, includeNodes: true, + nodeUuid, }); const nodeSummary = await getNodeSummary(req, esIndexPattern, clusterState, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js index 88e65332603ad8..fb2d04ecc041d3 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js @@ -8,12 +8,13 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getNodes } from '../../../../lib/elasticsearch/nodes'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; +import { getNodesShardCount } from '../../../../lib/elasticsearch/shards/get_nodes_shard_count'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; import { getPaginatedNodes } from '../../../../lib/elasticsearch/nodes/get_nodes/get_paginated_nodes'; import { LISTING_METRICS_NAMES } from '../../../../lib/elasticsearch/nodes/get_nodes/nodes_listing_metrics'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esNodesRoute(server) { server.route({ @@ -53,10 +54,13 @@ export function esNodesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeNodes: true, - }); - const clusterStatus = getClusterStatus(clusterStats, shardStats); + const nodesShardCount = await getNodesShardCount(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const clusterStatus = getClusterStatus(clusterStats, indicesUnassignedShardStats); const metricSet = LISTING_METRICS_NAMES; const { pageOfNodes, totalNodeCount } = await getPaginatedNodes( @@ -69,11 +73,17 @@ export function esNodesRoute(server) { queryText, { clusterStats, - shardStats, + nodesShardCount, } ); - const nodes = await getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats); + const nodes = await getNodes( + req, + esIndexPattern, + pageOfNodes, + clusterStats, + nodesShardCount + ); return { clusterStatus, nodes, totalNodeCount }; } catch (err) { throw handleError(err, req); diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js index 9022471dfb7f81..b0045502fa228c 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js @@ -9,7 +9,6 @@ import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getLastRecovery } from '../../../../lib/elasticsearch/get_last_recovery'; import { getMetrics } from '../../../../lib/details/get_metrics'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { metricSet } from './metric_set_overview'; @@ -18,6 +17,7 @@ import { INDEX_PATTERN_FILEBEAT, } from '../../../../../common/constants'; import { getLogs } from '../../../../lib/logs'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esOverviewRoute(server) { server.route({ @@ -54,10 +54,14 @@ export function esOverviewRoute(server) { getLastRecovery(req, esIndexPattern), getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }), ]); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), metrics, logs, shardActivity, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json index 1f17a7b78a29ec..584618057256a1 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json @@ -2,6 +2,36 @@ { "hits": { "hits": [ + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 1 + } + } + } + } + } + }, + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 3 + } + } + } + } + } + }, { "_source" : { "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js index 7734441a302c3a..522be71555fba6 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js @@ -168,6 +168,11 @@ describe('Get Beats Stats', () => { }, monitors: 3, }, + functionbeat: { + functions: { + count: 4, + }, + }, }, FlV4ckTxQ0a78hmBkzzc9A: { count: 405, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js index 94f710d51cc350..5722228b602073 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js @@ -138,6 +138,23 @@ export function processResults( } } + const functionbeatState = get(hit, '_source.beats_state.state.functionbeat'); + if (functionbeatState !== undefined) { + if (!clusters[clusterUuid].hasOwnProperty('functionbeat')) { + clusters[clusterUuid].functionbeat = { + functions: { + count: 0, + }, + }; + } + + clusters[clusterUuid].functionbeat.functions.count += get( + functionbeatState, + 'functions.count', + 0 + ); + } + const stateHost = get(hit, '_source.beats_state.state.host'); if (stateHost !== undefined) { const hostMap = clusterArchitectureMaps[clusterUuid]; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx index ac47b352a6276e..cb40fd9d949134 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx @@ -15,7 +15,7 @@ import { EuiTextColor, EuiFilterButton, EuiFilterGroup, - EuiSpacer, + EuiPortal, } from '@elastic/eui'; import { Option } from '@elastic/eui/src/components/selectable/types'; import { isEmpty } from 'lodash/fp'; @@ -37,12 +37,24 @@ const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle` } `; -const MyEuiHighlight = styled(EuiHighlight)<{ selected: boolean }>` - padding-left: ${({ selected }) => (selected ? '3px' : '0px')}; +const MyEuiFlexItem = styled(EuiFlexItem)` + display: inline-block; + max-width: 296px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; -const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>` - padding-left: ${({ selected }) => (selected ? '20px' : '0px')}; +const EuiSelectableContainer = styled.div` + .euiSelectable { + .euiFormControlLayout__childrenWrapper { + display: flex; + } + } +`; + +const MyEuiFlexGroup = styled(EuiFlexGroup)` + padding 0px 4px; `; interface SearchTimelineSuperSelectProps { @@ -83,6 +95,7 @@ const SearchTimelineSuperSelectComponent: React.FC(null); const onSearchTimeline = useCallback(val => { setSearchTimelineValue(val); @@ -102,20 +115,37 @@ const SearchTimelineSuperSelectComponent: React.FC { return ( - <> - {option.checked === 'on' && } - - {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} - -
- - - {option.description != null && option.description.trim().length > 0 - ? option.description - : getEmptyTagValue()} - - - + + + + + + + + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + + + + + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + + + + + + ); }, []); @@ -187,6 +217,29 @@ const SearchTimelineSuperSelectComponent: React.FC + searchRef != null ? ( + + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + ) : null, + [searchRef, onlyFavorites, handleOnToggleOnlyFavorites] + ); + return ( {({ timelines, loading, totalCount }) => ( - <> - - - - - {i18nTimeline.ONLY_FAVORITES} - - - - - + { + setSearchRef(ref); + }, }} singleSelection={true} options={[ @@ -249,6 +290,7 @@ const SearchTimelineSuperSelectComponent: React.FC ({ description: t.description, + favorite: !isEmpty(t.favorite), label: t.title, id: t.savedObjectId, key: `${t.title}-${index}`, @@ -261,11 +303,12 @@ const SearchTimelineSuperSelectComponent: React.FC ( <> {search} + {favoritePortal} {list} )} - + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index 725c7eeeedcfe4..b3cc81b5cdfcfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -45,6 +45,7 @@ export const AddItem = ({ isDisabled, validate, }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); @@ -53,7 +54,8 @@ export const AddItem = ({ const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + field.setValue(newValues.length === 0 ? [''] : newValues); inputsRef.current = [ ...inputsRef.current.slice(0, index), ...inputsRef.current.slice(index + 1), @@ -70,11 +72,7 @@ export const AddItem = ({ const addItem = useCallback(() => { const values = field.value as string[]; - if (!isEmpty(values) && values[values.length - 1]) { - field.setValue([...values, '']); - } else if (isEmpty(values)) { - field.setValue(['']); - } + field.setValue([...values, '']); }, [field]); const updateItem = useCallback( @@ -82,22 +80,7 @@ export const AddItem = ({ event.persist(); const values = field.value as string[]; const value = event.target.value; - if (isEmpty(value)) { - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); - inputsRef.current = [ - ...inputsRef.current.slice(0, index), - ...inputsRef.current.slice(index + 1), - ]; - setHaveBeenKeyboardDeleted(inputsRef.current.length - 1); - inputsRef.current = inputsRef.current.map((ref, i) => { - if (i >= index && inputsRef.current[index] != null) { - ref.value = 're-render'; - } - return ref; - }); - } else { - field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); - } + field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); }, [field] ); @@ -131,8 +114,8 @@ export const AddItem = ({ - updateItem(e, index)} fullWidth {...euiFieldProps} /> + setShowValidation(true)} + onChange={e => updateItem(e, index)} + fullWidth + {...euiFieldProps} + /> removeItem(index)} aria-label={RuleI18n.DELETE} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 198756fc2336bc..af4f93c0fdbcd2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -143,6 +143,14 @@ const getDescriptionItem = ( description: timeline.title ?? DEFAULT_TIMELINE_TITLE, }, ]; + } else if (field === 'riskScore') { + const description: string = get(field, value); + return [ + { + title: label, + description, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx index 0995e0e9166527..9695fd21067eec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx @@ -11,7 +11,7 @@ export const FILTERS_LABEL = i18n.translate('xpack.siem.detectionEngine.createRu }); export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.QueryLabel', { - defaultMessage: 'Query', + defaultMessage: 'Custom query', }); export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 97c4c2fdd050aa..2c19e99e901140 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; @@ -41,6 +41,7 @@ interface AddItemProps { } export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const removeItem = useCallback( @@ -137,15 +138,16 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI t.tactics.includes(kebabCase(item.tactic.name)))} selectedOptions={item.techniques} onChange={updateTechniques.bind(null, index)} - isDisabled={disabled} + isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} - isInvalid={invalid} + isInvalid={showValidation && invalid} + onBlur={() => setShowValidation(true)} /> - {invalid && ( + {showValidation && invalid && (

{errorMessage}

@@ -155,7 +157,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI removeItem(index)} aria-label={Rulei18n.DELETE} /> @@ -186,7 +188,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {index === 0 ? ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index dd4c55c1503ec4..557e91691b6c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -18,7 +18,7 @@ export const TECHNIQUE = i18n.translate( ); export const ADD_MITRE_ATTACK = i18n.translate('xpack.siem.detectionEngine.mitreAttack.addTitle', { - defaultMessage: 'Add MITRE ATT&CK threat', + defaultMessage: 'Add MITRE ATT&CK\\u2122 threat', }); export const TECHNIQUES_PLACEHOLDER = i18n.translate( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 9355f1c8bfefa0..008a1b48610d69 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -136,7 +136,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives', + defaultMessage: 'False positives examples', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, @@ -145,7 +145,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldMitreThreatLabel', { - defaultMessage: 'MITRE ATT&CK', + defaultMessage: 'MITRE ATT&CK\\u2122', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 052986480e9abf..93237697657399 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ADD_REFERENCE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription', { - defaultMessage: 'Add reference', + defaultMessage: 'Add reference URL', } ); export const ADD_FALSE_POSITIVE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription', { - defaultMessage: 'Add false positive', + defaultMessage: 'Add false positive example', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index dbd7e3b3f96aab..079ec0dab4c5a6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -38,7 +38,7 @@ export const schema: FormSchema = { i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'Index patterns for signals is required.', + defaultMessage: 'A minimum of one index pattern is required.', } ) ), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index ecd6bef942bfb1..8d4407b9f73e84 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -207,15 +207,15 @@ export const COLUMN_ACTIVATE = i18n.translate( ); export const DEFINE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.defineRuleTitle', { - defaultMessage: 'Define Rule', + defaultMessage: 'Define rule', }); export const ABOUT_RULE = i18n.translate('xpack.siem.detectionEngine.rules.aboutRuleTitle', { - defaultMessage: 'About Rule', + defaultMessage: 'About rule', }); export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.scheduleRuleTitle', { - defaultMessage: 'Schedule Rule', + defaultMessage: 'Schedule rule', }); export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', { diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 080ce34c09f3c7..92e27d20323a76 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -30,8 +30,6 @@ export interface Query { /** Fetch the most recent event data for a monitor ID, date range, location. */ getLatestMonitors: Ping[]; - getFilterBar?: FilterBar | null; - /** Fetches the current state of Uptime monitors for the given parameters. */ getMonitorStates?: MonitorSummaryResult | null; /** Fetches details about the uptime index. */ @@ -467,21 +465,6 @@ export interface StatusData { /** The total down counts for this point. */ total?: number | null; } -/** The data used to enrich the filter bar. */ -export interface FilterBar { - /** A series of monitor IDs in the heartbeat indices. */ - ids?: string[] | null; - /** The location values users have configured for the agents. */ - locations?: string[] | null; - /** The ports of the monitored endpoints. */ - ports?: number[] | null; - /** The schemes used by the monitors. */ - schemes?: string[] | null; - /** The possible status values contained in the indices. */ - statuses?: string[] | null; - /** The list of URLs */ - urls?: string[] | null; -} /** The primary object returned for monitor states. */ export interface MonitorSummaryResult { diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index c74bc81ae2549b..58f79abcf91ecd 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -5,5 +5,6 @@ */ export * from './common'; -export * from './snapshot'; export * from './monitor'; +export * from './overview_filters'; +export * from './snapshot'; diff --git a/x-pack/test/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts similarity index 54% rename from x-pack/test/typings/encode_uri_query.d.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts index e1ab5f4a70abfe..a803a0720959a3 100644 --- a/x-pack/test/typings/encode_uri_query.d.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { OverviewFiltersType, OverviewFilters } from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts new file mode 100644 index 00000000000000..9b9241494f0012 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const OverviewFiltersType = t.type({ + locations: t.array(t.string), + ports: t.array(t.number), + schemes: t.array(t.string), + tags: t.array(t.string), +}); + +export type OverviewFilters = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap index 2022390d0e5d9c..0e6ea3662b97e5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap @@ -13,6 +13,7 @@ exports[`FilterPopover component does not show item list when loading 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -49,6 +50,7 @@ exports[`FilterPopover component renders without errors for valid props 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -83,6 +85,7 @@ exports[`FilterPopover component returns selected items on popover close 1`] = `
{ props = { fieldName: 'foo', id: 'test', - isLoading: false, + loading: false, items: ['first', 'second', 'third', 'fourth'], onFilterFieldChange: jest.fn(), selectedItems: ['first', 'third'], @@ -47,7 +47,7 @@ describe('FilterPopover component', () => { }); it('does not show item list when loading', () => { - props.isLoading = true; + props.loading = true; const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts new file mode 100644 index 00000000000000..8deee25377850f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { parseFiltersMap } from '../parse_filter_map'; + +describe('parseFiltersMap', () => { + it('provides values from valid filter string', () => { + expect( + parseFiltersMap( + '[["url.port",["5601","80"]],["observer.geo.name",["us-east-2"]],["monitor.type",["http","tcp"]]]' + ) + ).toMatchSnapshot(); + }); + + it('returns an empty object for invalid filter', () => { + expect(() => parseFiltersMap('some invalid string')).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx index f27514bf76a11a..351302fb383561 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx @@ -5,35 +5,49 @@ */ import { EuiFilterGroup } from '@elastic/eui'; -import React from 'react'; -import { get } from 'lodash'; +import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { FilterBar as FilterBarType } from '../../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { filterBarQuery } from '../../../queries'; +import { connect } from 'react-redux'; import { FilterPopoverProps, FilterPopover } from './filter_popover'; import { FilterStatusButton } from './filter_status_button'; +import { OverviewFilters } from '../../../../common/runtime_types'; +import { fetchOverviewFilters, GetOverviewFiltersPayload } from '../../../state/actions'; +import { AppState } from '../../../state'; +import { useUrlParams } from '../../../hooks'; +import { parseFiltersMap } from './parse_filter_map'; -interface FilterBarQueryResult { - filters?: FilterBarType; +interface OwnProps { + currentFilter: any; + onFilterUpdate: any; + dateRangeStart: string; + dateRangeEnd: string; + filters?: string; + statusFilter?: string; } -interface FilterBarDropdownsProps { - currentFilter: string; - onFilterUpdate: (kuery: string) => void; +interface StoreProps { + esKuery: string; + lastRefresh: number; + loading: boolean; + overviewFilters: OverviewFilters; } -type Props = UptimeGraphQLQueryProps & FilterBarDropdownsProps; +interface DispatchProps { + loadFilterGroup: typeof fetchOverviewFilters; +} + +type Props = OwnProps & StoreProps & DispatchProps; + +type PresentationalComponentProps = Pick & + Pick; -export const FilterGroupComponent = ({ - loading: isLoading, +export const PresentationalComponent: React.FC = ({ currentFilter, - data, + overviewFilters, + loading, onFilterUpdate, -}: Props) => { - const locations = get(data, 'filterBar.locations', []); - const ports = get(data, 'filterBar.ports', []); - const schemes = get(data, 'filterBar.schemes', []); +}) => { + const { locations, ports, schemes, tags } = overviewFilters; let filterKueries: Map; try { @@ -67,36 +81,50 @@ export const FilterGroupComponent = ({ const filterPopoverProps: FilterPopoverProps[] = [ { + loading, + onFilterFieldChange, fieldName: 'observer.geo.name', id: 'location', - isLoading, items: locations, - onFilterFieldChange, selectedItems: getSelectedItems('observer.geo.name'), title: i18n.translate('xpack.uptime.filterBar.options.location.name', { defaultMessage: 'Location', }), }, { + loading, + onFilterFieldChange, fieldName: 'url.port', id: 'port', - isLoading, - items: ports, - onFilterFieldChange, + disabled: ports.length === 0, + items: ports.map((p: number) => p.toString()), selectedItems: getSelectedItems('url.port'), title: i18n.translate('xpack.uptime.filterBar.options.portLabel', { defaultMessage: 'Port' }), }, { + loading, + onFilterFieldChange, fieldName: 'monitor.type', id: 'scheme', - isLoading, + disabled: schemes.length === 0, items: schemes, - onFilterFieldChange, selectedItems: getSelectedItems('monitor.type'), title: i18n.translate('xpack.uptime.filterBar.options.schemeLabel', { defaultMessage: 'Scheme', }), }, + { + loading, + onFilterFieldChange, + fieldName: 'tags', + id: 'tags', + disabled: tags.length === 0, + items: tags, + selectedItems: getSelectedItems('tags'), + title: i18n.translate('xpack.uptime.filterBar.options.tagsLabel', { + defaultMessage: 'Tags', + }), + }, ]; return ( @@ -124,7 +152,59 @@ export const FilterGroupComponent = ({ ); }; -export const FilterGroup = withUptimeGraphQL( - FilterGroupComponent, - filterBarQuery -); +export const Container: React.FC = ({ + currentFilter, + esKuery, + filters, + loading, + loadFilterGroup, + dateRangeStart, + dateRangeEnd, + overviewFilters, + statusFilter, + onFilterUpdate, +}: Props) => { + const [getUrlParams] = useUrlParams(); + const { filters: urlFilters } = getUrlParams(); + useEffect(() => { + const filterSelections = parseFiltersMap(urlFilters); + loadFilterGroup({ + dateRangeStart, + dateRangeEnd, + locations: filterSelections.locations ?? [], + ports: filterSelections.ports ?? [], + schemes: filterSelections.schemes ?? [], + search: esKuery, + statusFilter, + tags: filterSelections.tags ?? [], + }); + }, [dateRangeStart, dateRangeEnd, esKuery, filters, statusFilter, urlFilters, loadFilterGroup]); + return ( + + ); +}; + +const mapStateToProps = ({ + overviewFilters: { loading, filters }, + ui: { esKuery, lastRefresh }, +}: AppState): StoreProps => ({ + esKuery, + overviewFilters: filters, + lastRefresh, + loading, +}); + +const mapDispatchToProps = (dispatch: any): DispatchProps => ({ + loadFilterGroup: (payload: GetOverviewFiltersPayload) => dispatch(fetchOverviewFilters(payload)), +}); + +export const FilterGroup = connect( + // @ts-ignore connect is expecting null | undefined for some reason + mapStateToProps, + mapDispatchToProps +)(Container); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx index 6e73090782b042..f96fef609fe76d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx @@ -14,7 +14,8 @@ import { LocationLink } from '../monitor_list'; export interface FilterPopoverProps { fieldName: string; id: string; - isLoading: boolean; + loading: boolean; + disabled?: boolean; items: string[]; onFilterFieldChange: (fieldName: string, values: string[]) => void; selectedItems: string[]; @@ -27,7 +28,8 @@ const isItemSelected = (selectedItems: string[], item: string): 'on' | undefined export const FilterPopover = ({ fieldName, id, - isLoading, + disabled, + loading, items, onFilterFieldChange, selectedItems, @@ -48,10 +50,10 @@ export const FilterPopover = ({ }, [searchQuery, items]); return ( - // @ts-ignore zIndex prop is not described in the typing yet 0} numFilters={items.length} numActiveFilters={tempSelectedItems.length} @@ -66,6 +68,7 @@ export const FilterPopover = ({ setIsOpen(false); onFilterFieldChange(fieldName, tempSelectedItems); }} + data-test-subj={`filter-popover_${id}`} id={id} isOpen={isOpen} ownFocus={true} @@ -77,7 +80,7 @@ export const FilterPopover = ({ disabled={items.length === 0} onSearch={query => setSearchQuery(query)} placeholder={ - isLoading + loading ? i18n.translate('xpack.uptime.filterPopout.loadingMessage', { defaultMessage: 'Loading...', }) @@ -90,10 +93,11 @@ export const FilterPopover = ({ } /> - {!isLoading && + {!loading && itemsToDisplay.map(item => ( toggleSelectedItems(item, tempSelectedItems, setTempSelectedItems)} > diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx index 95f4c30337d627..abbe72530fd80a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx @@ -11,6 +11,7 @@ import { useUrlParams } from '../../../hooks'; export interface FilterStatusButtonProps { content: string; dataTestSubj: string; + isDisabled?: boolean; value: string; withNext: boolean; } @@ -18,6 +19,7 @@ export interface FilterStatusButtonProps { export const FilterStatusButton = ({ content, dataTestSubj, + isDisabled, value, withNext, }: FilterStatusButtonProps) => { @@ -27,6 +29,7 @@ export const FilterStatusButton = ({ { const nextFilter = { statusFilter: urlValue === value ? '' : value, pagination: '' }; setUrlParams(nextFilter); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts new file mode 100644 index 00000000000000..08766521799ea6 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.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. + */ + +interface FilterField { + name: string; + fieldName: string; +} + +/** + * These are the only filter fields we are looking to catch at the moment. + * If your code needs to support custom fields, introduce a second parameter to + * `parseFiltersMap` to take a list of FilterField objects. + */ +const filterWhitelist: FilterField[] = [ + { name: 'ports', fieldName: 'url.port' }, + { name: 'locations', fieldName: 'observer.geo.name' }, + { name: 'tags', fieldName: 'tags' }, + { name: 'schemes', fieldName: 'monitor.type' }, +]; + +export const parseFiltersMap = (filterMapString: string) => { + if (!filterMapString) { + return {}; + } + const filterSlices: { [key: string]: any } = {}; + try { + const map = new Map(JSON.parse(filterMapString)); + filterWhitelist.forEach(({ name, fieldName }) => { + filterSlices[name] = map.get(fieldName) ?? []; + }); + return filterSlices; + } catch { + throw new Error('Unable to parse invalid filter string'); + } +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx index fc0c6342bd6e90..0e05c17d57353a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx @@ -8,6 +8,7 @@ import { EuiFilterButton } from '@elastic/eui'; import React from 'react'; interface UptimeFilterButtonProps { + isDisabled?: boolean; isSelected: boolean; numFilters: number; numActiveFilters: number; @@ -16,6 +17,7 @@ interface UptimeFilterButtonProps { } export const UptimeFilterButton = ({ + isDisabled, isSelected, numFilters, numActiveFilters, @@ -25,6 +27,7 @@ export const UptimeFilterButton = ({ { + let params: URLSearchParams; + + beforeEach(() => { + params = new URLSearchParams(); + }); + + it('parameterizes the provided values for a given field name', () => { + parameterizeValues(params, { foo: ['bar', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('parameterizes provided values for multiple fields', () => { + parameterizeValues(params, { foo: ['bar', 'baz'], bar: ['foo', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('returns an empty string when there are no values provided', () => { + parameterizeValues(params, { foo: [] }); + expect(params.toString()).toBe(''); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts index a4cfb1c51b0ecb..ced06ce7a1d7b6 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts @@ -9,6 +9,7 @@ export { convertMicrosecondsToMilliseconds } from './convert_measurements'; export * from './observability_integration'; export { getApiPath } from './get_api_path'; export { getChartDateLabel } from './charts'; +export { parameterizeValues } from './parameterize_values'; export { seriesHasDownValues } from './series_has_down_values'; export { stringifyKueries } from './stringify_kueries'; export { toStaticIndexPattern } from './to_static_index_pattern'; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts new file mode 100644 index 00000000000000..4c9fa6838c2eda --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const parameterizeValues = ( + params: URLSearchParams, + obj: Record +): void => { + Object.keys(obj).forEach(key => { + obj[key].forEach(val => { + params.append(key, val); + }); + }); +}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index edf06242053a22..e7ef7f53afde41 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -22,6 +22,8 @@ import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../infra/public'; import { combineFiltersAndUserSearch, stringifyKueries, toStaticIndexPattern } from '../lib/helper'; import { AutocompleteProviderRegister, esKuery } from '../../../../../../src/plugins/data/public'; +import { store } from '../state'; +import { setEsKueryString } from '../state/actions'; import { PageHeader } from './page_header'; interface OverviewPageProps { @@ -64,7 +66,6 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - const filterQueryString = search || ''; let error: any; let kueryString: string = ''; try { @@ -76,6 +77,7 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { kueryString = ''; } + const filterQueryString = search || ''; let filters: any | undefined; try { if (filterQueryString || urlFilters) { @@ -85,6 +87,15 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { const ast = esKuery.fromKueryExpression(combinedFilterString); const elasticsearchQuery = esKuery.toElasticsearchQuery(ast, staticIndexPattern); filters = JSON.stringify(elasticsearchQuery); + const searchDSL: string = filterQueryString + ? JSON.stringify( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(filterQueryString), + staticIndexPattern + ) + ) + : ''; + store.dispatch(setEsKueryString(searchDSL)); } } } catch (e) { @@ -110,13 +121,13 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { { if (urlFilters !== filtersKuery) { updateUrl({ filters: filtersKuery, pagination: '' }); } }} - variables={sharedProps} /> {error && } diff --git a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts b/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts deleted file mode 100644 index a9b7e52c0f7938..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts +++ /dev/null @@ -1,23 +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 gql from 'graphql-tag'; - -export const filterBarQueryString = ` -query FilterBar($dateRangeStart: String!, $dateRangeEnd: String!) { - filterBar: getFilterBar(dateRangeStart: $dateRangeStart, dateRangeEnd: $dateRangeEnd) { - ids - locations - ports - schemes - urls - } -} -`; - -export const filterBarQuery = gql` - ${filterBarQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/queries/index.ts b/x-pack/legacy/plugins/uptime/public/queries/index.ts index f4947f53254508..02c9c7cb23403c 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/index.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/index.ts @@ -5,6 +5,5 @@ */ export { docCountQuery, docCountQueryString } from './doc_count_query'; -export { filterBarQuery, filterBarQueryString } from './filter_bar_query'; export { monitorChartsQuery, monitorChartsQueryString } from './monitor_charts_query'; export { pingsQuery, pingsQueryString } from './pings_query'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap new file mode 100644 index 00000000000000..6fe2c8eaa362d2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`overview filters action creators creates a fail action 1`] = ` +Object { + "payload": [Error: There was an error retrieving the overview filters], + "type": "FETCH_OVERVIEW_FILTERS_FAIL", +} +`; + +exports[`overview filters action creators creates a get action 1`] = ` +Object { + "payload": Object { + "dateRangeEnd": "now", + "dateRangeStart": "now-15m", + "locations": Array [ + "fairbanks", + "tokyo", + ], + "ports": Array [ + "80", + ], + "schemes": Array [ + "http", + "tcp", + ], + "search": "", + "statusFilter": "down", + "tags": Array [ + "api", + "dev", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS", +} +`; + +exports[`overview filters action creators creates a success action 1`] = ` +Object { + "payload": Object { + "locations": Array [ + "fairbanks", + "tokyo", + "london", + ], + "ports": Array [ + 80, + 443, + ], + "schemes": Array [ + "http", + "tcp", + ], + "tags": Array [ + "api", + "dev", + "prod", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS_SUCCESS", +} +`; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts new file mode 100644 index 00000000000000..4765e1327ce314 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { + fetchOverviewFilters, + fetchOverviewFiltersSuccess, + fetchOverviewFiltersFail, +} from '../overview_filters'; + +describe('overview filters action creators', () => { + it('creates a get action', () => { + expect( + fetchOverviewFilters({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + statusFilter: 'down', + search: '', + locations: ['fairbanks', 'tokyo'], + ports: ['80'], + schemes: ['http', 'tcp'], + tags: ['api', 'dev'], + }) + ).toMatchSnapshot(); + }); + + it('creates a success action', () => { + expect( + fetchOverviewFiltersSuccess({ + locations: ['fairbanks', 'tokyo', 'london'], + ports: [80, 443], + schemes: ['http', 'tcp'], + tags: ['api', 'dev', 'prod'], + }) + ).toMatchSnapshot(); + }); + + it('creates a fail action', () => { + expect( + fetchOverviewFiltersFail(new Error('There was an error retrieving the overview filters')) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts index 3174c0023ed927..9874da1839c2f3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './overview_filters'; export * from './snapshot'; export * from './ui'; export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts new file mode 100644 index 00000000000000..dbbd01e34b4d4e --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts @@ -0,0 +1,61 @@ +/* + * 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 { OverviewFilters } from '../../../common/runtime_types'; + +export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS'; +export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL'; +export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS'; + +export interface GetOverviewFiltersPayload { + dateRangeStart: string; + dateRangeEnd: string; + locations: string[]; + ports: string[]; + schemes: string[]; + search?: string; + statusFilter?: string; + tags: string[]; +} + +interface GetOverviewFiltersFetchAction { + type: typeof FETCH_OVERVIEW_FILTERS; + payload: GetOverviewFiltersPayload; +} + +interface GetOverviewFiltersSuccessAction { + type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS; + payload: OverviewFilters; +} + +interface GetOverviewFiltersFailAction { + type: typeof FETCH_OVERVIEW_FILTERS_FAIL; + payload: Error; +} + +export type OverviewFiltersAction = + | GetOverviewFiltersFetchAction + | GetOverviewFiltersSuccessAction + | GetOverviewFiltersFailAction; + +export const fetchOverviewFilters = ( + payload: GetOverviewFiltersPayload +): GetOverviewFiltersFetchAction => ({ + type: FETCH_OVERVIEW_FILTERS, + payload, +}); + +export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({ + type: FETCH_OVERVIEW_FILTERS_FAIL, + payload: error, +}); + +export const fetchOverviewFiltersSuccess = ( + filters: OverviewFilters +): GetOverviewFiltersSuccessAction => ({ + type: FETCH_OVERVIEW_FILTERS_SUCCESS, + payload: filters, +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts index fe87a6a5960eef..57d2b4ce38204f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts @@ -5,6 +5,7 @@ */ import { Snapshot } from '../../../common/runtime_types'; + export const FETCH_SNAPSHOT_COUNT = 'FETCH_SNAPSHOT_COUNT'; export const FETCH_SNAPSHOT_COUNT_FAIL = 'FETCH_SNAPSHOT_COUNT_FAIL'; export const FETCH_SNAPSHOT_COUNT_SUCCESS = 'FETCH_SNAPSHOT_COUNT_SUCCESS'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index fb38599495d845..d15d601737b2d6 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -16,6 +16,8 @@ export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); +export const setEsKueryString = createAction('SET ES KUERY STRING'); + export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index.ts b/x-pack/legacy/plugins/uptime/public/state/api/index.ts index 0bcd29cf3fbe91..1d0cac5f878543 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index.ts @@ -5,5 +5,6 @@ */ export * from './monitor'; +export * from './overview_filters'; export * from './snapshot'; export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts new file mode 100644 index 00000000000000..c3ef62fa88dcf4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts @@ -0,0 +1,52 @@ +/* + * 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 { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { GetOverviewFiltersPayload } from '../actions/overview_filters'; +import { getApiPath, parameterizeValues } from '../../lib/helper'; +import { OverviewFiltersType } from '../../../common/runtime_types'; + +type ApiRequest = GetOverviewFiltersPayload & { + basePath: string; +}; + +export const fetchOverviewFilters = async ({ + basePath, + dateRangeStart, + dateRangeEnd, + search, + schemes, + locations, + ports, + tags, +}: ApiRequest) => { + const url = getApiPath(`/api/uptime/filters`, basePath); + + const params = new URLSearchParams({ + dateRangeStart, + dateRangeEnd, + }); + + if (search) { + params.append('search', search); + } + + parameterizeValues(params, { schemes, locations, ports, tags }); + + const response = await fetch(`${url}?${params.toString()}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + const decoded = OverviewFiltersType.decode(responseData); + + ThrowReporter.report(decoded); + if (isRight(decoded)) { + return decoded.right; + } + throw new Error('`getOverviewFilters` response did not correspond to expected type'); +}; diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts new file mode 100644 index 00000000000000..d293cdbe451b54 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { call, put, select } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { getBasePath } from '../selectors'; + +/** + * Factory function for a fetch effect. It expects three action creators, + * one to call for a fetch, one to call for success, and one to handle failures. + * @param fetch creates a fetch action + * @param success creates a success action + * @param fail creates a failure action + * @template T the action type expected by the fetch action + * @template R the type that the API request should return on success + * @template S tye type of the success action + * @template F the type of the failure action + */ +export function fetchEffectFactory( + fetch: (request: T) => Promise, + success: (response: R) => Action, + fail: (error: Error) => Action +) { + return function*(action: Action) { + try { + if (!action.payload) { + yield put(fail(new Error('Cannot fetch snapshot for undefined parameters.'))); + return; + } + const { + payload: { ...params }, + } = action; + const basePath = yield select(getBasePath); + const response = yield call(fetch, { ...params, basePath }); + yield put(success(response)); + } catch (error) { + yield put(fail(error)); + } + }; +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts index 936b38a4d53451..41dda145edb4e2 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts @@ -6,11 +6,13 @@ import { fork } from 'redux-saga/effects'; import { fetchMonitorDetailsEffect } from './monitor'; -import { fetchSnapshotCountSaga } from './snapshot'; +import { fetchOverviewFiltersEffect } from './overview_filters'; +import { fetchSnapshotCountEffect } from './snapshot'; import { fetchMonitorStatusEffect } from './monitor_status'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); - yield fork(fetchSnapshotCountSaga); + yield fork(fetchSnapshotCountEffect); + yield fork(fetchOverviewFiltersEffect); yield fork(fetchMonitorStatusEffect); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts new file mode 100644 index 00000000000000..92b578bafed2dd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts @@ -0,0 +1,21 @@ +/* + * 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 { takeLatest } from 'redux-saga/effects'; +import { + FETCH_OVERVIEW_FILTERS, + fetchOverviewFiltersFail, + fetchOverviewFiltersSuccess, +} from '../actions'; +import { fetchOverviewFilters } from '../api'; +import { fetchEffectFactory } from './fetch_effect'; + +export function* fetchOverviewFiltersEffect() { + yield takeLatest( + FETCH_OVERVIEW_FILTERS, + fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail) + ); +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts index 23ac1016d22447..91df43dd9e8262 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts @@ -4,42 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, takeLatest, select } from 'redux-saga/effects'; -import { Action } from 'redux-actions'; +import { takeLatest } from 'redux-saga/effects'; import { FETCH_SNAPSHOT_COUNT, - GetSnapshotPayload, fetchSnapshotCountFail, fetchSnapshotCountSuccess, } from '../actions'; import { fetchSnapshotCount } from '../api'; -import { getBasePath } from '../selectors'; +import { fetchEffectFactory } from './fetch_effect'; -function* snapshotSaga(action: Action) { - try { - if (!action.payload) { - yield put( - fetchSnapshotCountFail(new Error('Cannot fetch snapshot for undefined parameters.')) - ); - return; - } - const { - payload: { dateRangeStart, dateRangeEnd, filters, statusFilter }, - } = action; - const basePath = yield select(getBasePath); - const response = yield call(fetchSnapshotCount, { - basePath, - dateRangeStart, - dateRangeEnd, - filters, - statusFilter, - }); - yield put(fetchSnapshotCountSuccess(response)); - } catch (error) { - yield put(fetchSnapshotCountFail(error)); - } -} - -export function* fetchSnapshotCountSaga() { - yield takeLatest(FETCH_SNAPSHOT_COUNT, snapshotSaga); +export function* fetchSnapshotCountEffect() { + yield takeLatest( + FETCH_SNAPSHOT_COUNT, + fetchEffectFactory(fetchSnapshotCount, fetchSnapshotCountSuccess, fetchSnapshotCountFail) + ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 155f7edbcbf33c..5d03c0058c3c1d 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -3,6 +3,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { "basePath": "", + "esKuery": "", "integrationsPopoverOpen": Object { "id": "popover-2", "open": true, @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { "basePath": "yyz", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } @@ -22,6 +24,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { "basePath": "abc", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index ff9b7c3f9e8a47..417095b64ba2d7 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -15,6 +15,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -32,6 +33,7 @@ describe('ui reducer', () => { uiReducer( { basePath: '', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -46,6 +48,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts index 7b4000adc2d123..5f915d970e5431 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts @@ -6,12 +6,14 @@ import { combineReducers } from 'redux'; import { monitorReducer } from './monitor'; +import { overviewFiltersReducer } from './overview_filters'; import { snapshotReducer } from './snapshot'; import { uiReducer } from './ui'; import { monitorStatusReducer } from './monitor_status'; export const rootReducer = combineReducers({ monitor: monitorReducer, + overviewFilters: overviewFiltersReducer, snapshot: snapshotReducer, ui: uiReducer, monitorStatus: monitorStatusReducer, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts new file mode 100644 index 00000000000000..b219421f4f4dc1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts @@ -0,0 +1,56 @@ +/* + * 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 { OverviewFilters } from '../../../common/runtime_types'; +import { + FETCH_OVERVIEW_FILTERS, + FETCH_OVERVIEW_FILTERS_FAIL, + FETCH_OVERVIEW_FILTERS_SUCCESS, + OverviewFiltersAction, +} from '../actions'; + +export interface OverviewFiltersState { + filters: OverviewFilters; + errors: Error[]; + loading: boolean; +} + +const initialState: OverviewFiltersState = { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, +}; + +export function overviewFiltersReducer( + state = initialState, + action: OverviewFiltersAction +): OverviewFiltersState { + switch (action.type) { + case FETCH_OVERVIEW_FILTERS: + return { + ...state, + loading: true, + }; + case FETCH_OVERVIEW_FILTERS_SUCCESS: + return { + ...state, + filters: action.payload, + loading: false, + }; + case FETCH_OVERVIEW_FILTERS_FAIL: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return state; + } +} diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index b23245aa65fcaa..bb5bd22085ac68 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -9,6 +9,7 @@ import { PopoverState, toggleIntegrationsPopover, setBasePath, + setEsKueryString, triggerAppRefresh, UiPayload, } from '../actions/ui'; @@ -16,12 +17,14 @@ import { export interface UiState { integrationsPopoverOpen: PopoverState | null; basePath: string; + esKuery: string; lastRefresh: number; } const initialState: UiState = { integrationsPopoverOpen: null, basePath: '', + esKuery: '', lastRefresh: Date.now(), }; @@ -41,6 +44,11 @@ export const uiReducer = handleActions( ...state, lastRefresh: action.payload as number, }), + + [String(setEsKueryString)]: (state, action: Action) => ({ + ...state, + esKuery: action.payload as string, + }), }, initialState ); diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index df8e640bcbab54..38fb3edea4768a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -9,6 +9,16 @@ import { AppState } from '../../../state'; describe('state selectors', () => { const state: AppState = { + overviewFilters: { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, + }, monitor: { monitorDetailsList: [], monitorLocationsList: new Map(), @@ -24,7 +34,12 @@ describe('state selectors', () => { errors: [], loading: false, }, - ui: { basePath: 'yyz', integrationsPopoverOpen: null, lastRefresh: 125 }, + ui: { + basePath: 'yyz', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, monitorStatus: { status: null, monitor: null, diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts index d53bf6b7015008..897d67dde807ec 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts @@ -7,7 +7,6 @@ import { UMGqlRange } from '../../../common/domain_types'; import { UMResolver } from '../../../common/graphql/resolver_types'; import { - FilterBar, GetFilterBarQueryArgs, GetMonitorChartsDataQueryArgs, MonitorChart, @@ -46,7 +45,6 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( Query: { getSnapshotHistogram: UMGetSnapshotHistogram; getMonitorChartsData: UMGetMonitorChartsResolver; - getFilterBar: UMGetFilterBarResolver; }; } => ({ Query: { @@ -77,16 +75,5 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( location, }); }, - async getFilterBar( - _resolver, - { dateRangeStart, dateRangeEnd }, - { APICaller } - ): Promise { - return await libs.monitors.getFilterBar({ - callES: APICaller, - dateRangeStart, - dateRangeEnd, - }); - }, }, }); diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts index d49681f1dcd0dc..8a86d97b4cd8e5 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts @@ -7,22 +7,6 @@ import gql from 'graphql-tag'; export const monitorsSchema = gql` - "The data used to enrich the filter bar." - type FilterBar { - "A series of monitor IDs in the heartbeat indices." - ids: [String!] - "The location values users have configured for the agents." - locations: [String!] - "The ports of the monitored endpoints." - ports: [Int!] - "The schemes used by the monitors." - schemes: [String!] - "The possible status values contained in the indices." - statuses: [String!] - "The list of URLs" - urls: [String!] - } - type HistogramDataPoint { upCount: Int downCount: Int @@ -136,19 +120,5 @@ export const monitorsSchema = gql` dateRangeEnd: String! location: String ): MonitorChart - - "Fetch the most recent event data for a monitor ID, date range, location." - getLatestMonitors( - "The lower limit of the date range." - dateRangeStart: String! - "The upper limit of the date range." - dateRangeEnd: String! - "Optional: a specific monitor ID filter." - monitorId: String - "Optional: a specific instance location filter." - location: String - ): [Ping!]! - - getFilterBar(dateRangeStart: String!, dateRangeEnd: String!): FilterBar } `; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap new file mode 100644 index 00000000000000..2f6d6e06f93e12 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractFilterAggsResults extracts the bucket values of the expected filter fields 1`] = ` +Object { + "locations": Array [ + "us-east-2", + "fairbanks", + ], + "ports": Array [ + 12349, + 80, + 5601, + 8200, + 9200, + 9292, + ], + "schemes": Array [ + "http", + "tcp", + "icmp", + ], + "tags": Array [ + "api", + "dev", + ], +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap new file mode 100644 index 00000000000000..0f7abf5050bca2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateFilterAggs generates expected aggregations object 1`] = ` +Object { + "locations": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "observer.geo.name", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "ports": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "url.port", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "schemes": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "monitor.type", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + ], + }, + }, + }, + "tags": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "tags", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts new file mode 100644 index 00000000000000..2075b3a8fbe0f6 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { combineRangeWithFilters } from '../elasticsearch_monitors_adapter'; + +describe('combineRangeWithFilters', () => { + it('combines filters that have no filter clause', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { should: [{ match: { 'url.port': 80 } }], minimum_should_match: 1 }, + }) + ).toEqual({ + bool: { + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + filter: [ + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + }, + }); + }); + + it('combines query with filter object', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: { term: { field: 'monitor.id' } }, + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('combines query with filter list', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: [{ field: 'monitor.id' }], + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts new file mode 100644 index 00000000000000..954cffd4c9522d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extractFilterAggsResults } from '../elasticsearch_monitors_adapter'; + +describe('extractFilterAggsResults', () => { + it('extracts the bucket values of the expected filter fields', () => { + expect( + extractFilterAggsResults( + { + locations: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'us-east-2', doc_count: 4050 }, + { key: 'fairbanks', doc_count: 4048 }, + ], + }, + }, + schemes: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'http', doc_count: 5055 }, + { key: 'tcp', doc_count: 2685 }, + { key: 'icmp', doc_count: 358 }, + ], + }, + }, + ports: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 12349, doc_count: 3571 }, + { key: 80, doc_count: 2985 }, + { key: 5601, doc_count: 358 }, + { key: 8200, doc_count: 358 }, + { key: 9200, doc_count: 358 }, + { key: 9292, doc_count: 110 }, + ], + }, + }, + tags: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'api', doc_count: 8098 }, + { key: 'dev', doc_count: 8098 }, + ], + }, + }, + }, + ['locations', 'ports', 'schemes', 'tags'] + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts new file mode 100644 index 00000000000000..4e285ec25a492f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { generateFilterAggs } from '../generate_filter_aggs'; + +describe('generateFilterAggs', () => { + it('generates expected aggregations object', () => { + expect( + generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + { + locations: ['fairbanks', 'us-east-2'], + ports: ['80', '5601'], + tags: ['api'], + schemes: ['http', 'tcp'], + } + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts index f844d6ecc1c4ab..8523d9c75f51fb 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts @@ -6,7 +6,11 @@ import { MonitorChart } from '../../../../common/graphql/types'; import { UMElasticsearchQueryFn } from '../framework'; -import { MonitorDetails, MonitorLocations } from '../../../../common/runtime_types'; +import { + MonitorDetails, + MonitorLocations, + OverviewFilters, +} from '../../../../common/runtime_types'; export interface GetMonitorChartsDataParams { /** @member monitorId ID value for the selected monitor */ @@ -20,9 +24,15 @@ export interface GetMonitorChartsDataParams { } export interface GetFilterBarParams { + /** @param dateRangeStart timestamp bounds */ dateRangeStart: string; /** @member dateRangeEnd timestamp bounds */ dateRangeEnd: string; + /** @member search this value should correspond to Elasticsearch DSL + * generated from KQL text the user provided. + */ + search?: Record; + filterOptions: Record; } export interface GetMonitorDetailsParams { @@ -48,10 +58,13 @@ export interface UMMonitorsAdapter { * Fetches data used to populate monitor charts */ getMonitorChartsData: UMElasticsearchQueryFn; - getFilterBar: UMElasticsearchQueryFn; + /** - * Fetch data for the monitor page title. + * Fetch options for the filter bar. */ + getFilterBar: UMElasticsearchQueryFn; + getMonitorDetails: UMElasticsearchQueryFn; + getMonitorLocations: UMElasticsearchQueryFn; } diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index a452f2364540a8..37a9e032cd442d 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -10,6 +10,52 @@ import { MonitorChart, LocationDurationLine } from '../../../../common/graphql/t import { getHistogramIntervalFormatted } from '../../helper'; import { MonitorError, MonitorLocation } from '../../../../common/runtime_types'; import { UMMonitorsAdapter } from './adapter_types'; +import { generateFilterAggs } from './generate_filter_aggs'; +import { OverviewFilters } from '../../../../common/runtime_types'; + +export const combineRangeWithFilters = ( + dateRangeStart: string, + dateRangeEnd: string, + filters?: Record +) => { + const range = { + range: { + '@timestamp': { + gte: dateRangeStart, + lte: dateRangeEnd, + }, + }, + }; + if (!filters) return range; + const clientFiltersList = Array.isArray(filters?.bool?.filter ?? {}) + ? // i.e. {"bool":{"filter":{ ...some nested filter objects }}} + filters.bool.filter + : // i.e. {"bool":{"filter":[ ...some listed filter objects ]}} + Object.keys(filters?.bool?.filter ?? {}).map(key => ({ + ...filters?.bool?.filter?.[key], + })); + filters.bool.filter = [...clientFiltersList, range]; + return filters; +}; + +type SupportedFields = 'locations' | 'ports' | 'schemes' | 'tags'; + +export const extractFilterAggsResults = ( + responseAggregations: Record, + keys: SupportedFields[] +): OverviewFilters => { + const values: OverviewFilters = { + locations: [], + ports: [], + schemes: [], + tags: [], + }; + keys.forEach(key => { + const buckets = responseAggregations[key]?.term?.buckets ?? []; + values[key] = buckets.map((item: { key: string | number }) => item.key); + }); + return values; +}; const formatStatusBuckets = (time: any, buckets: any, docCount: any) => { let up = null; @@ -160,39 +206,30 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { return monitorChartsData; }, - getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd }) => { - const fields: { [key: string]: string } = { - ids: 'monitor.id', - schemes: 'monitor.type', - urls: 'url.full', - ports: 'url.port', - locations: 'observer.geo.name', - }; + getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd, search, filterOptions }) => { + const aggs = generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + filterOptions + ); + const filters = combineRangeWithFilters(dateRangeStart, dateRangeEnd, search); const params = { index: INDEX_NAMES.HEARTBEAT, body: { size: 0, query: { - range: { - '@timestamp': { - gte: dateRangeStart, - lte: dateRangeEnd, - }, - }, + ...filters, }, - aggs: Object.values(fields).reduce((acc: { [key: string]: any }, field) => { - acc[field] = { terms: { field, size: 20 } }; - return acc; - }, {}), + aggs, }, }; - const { aggregations } = await callES('search', params); - return Object.keys(fields).reduce((acc: { [key: string]: any[] }, field) => { - const bucketName = fields[field]; - acc[field] = aggregations[bucketName].buckets.map((b: { key: string | number }) => b.key); - return acc; - }, {}); + const { aggregations } = await callES('search', params); + return extractFilterAggsResults(aggregations, ['tags', 'locations', 'ports', 'schemes']); }, getMonitorDetails: async ({ callES, monitorId, dateStart, dateEnd }) => { diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts new file mode 100644 index 00000000000000..26d412e33c8687 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +interface AggDefinition { + aggName: string; + filterName: string; + field: string; +} + +export const FIELD_MAPPINGS: Record = { + schemes: 'monitor.type', + ports: 'url.port', + locations: 'observer.geo.name', + tags: 'tags', +}; + +const getFilterAggConditions = (filterTerms: Record, except: string) => { + const filters: any[] = []; + + Object.keys(filterTerms).forEach((key: string) => { + if (key === except && FIELD_MAPPINGS[key]) return; + filters.push( + ...filterTerms[key].map(value => ({ + term: { + [FIELD_MAPPINGS[key]]: value, + }, + })) + ); + }); + + return filters; +}; + +export const generateFilterAggs = ( + aggDefinitions: AggDefinition[], + filterOptions: Record +) => + aggDefinitions + .map(({ aggName, filterName, field }) => ({ + [aggName]: { + filter: { + bool: { + should: [...getFilterAggConditions(filterOptions, filterName)], + }, + }, + aggs: { + term: { + terms: { + field, + }, + }, + }, + }, + })) + .reduce((parent: Record, agg: any) => ({ ...parent, ...agg }), {}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts index 4c88da7eca85aa..f9a8de81332d50 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts @@ -9,3 +9,4 @@ export { getHistogramInterval } from './get_histogram_interval'; export { getHistogramIntervalFormatted } from './get_histogram_interval_formatted'; export { parseFilterQuery } from './parse_filter_query'; export { assertCloseTo } from './assert_close_to'; +export { objectValuesToArrays } from './object_to_array'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts new file mode 100644 index 00000000000000..334c31c822eaac --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Converts the top-level fields of an object from an object to an array. + * @param record the obect to map + * @type T the type of the objects/arrays that will be mapped + */ +export const objectValuesToArrays = (record: Record): Record => { + const obj: Record = {}; + Object.keys(record).forEach((key: string) => { + const value = record[key]; + obj[key] = value ? (Array.isArray(value) ? value : [value]) : []; + }); + return obj; +}; diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts index 4bc6fe699a21a8..e64b317e67f988 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createGetOverviewFilters } from './overview_filters'; import { createGetAllRoute } from './pings'; import { createGetIndexPatternRoute } from './index_pattern'; import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; @@ -20,6 +21,7 @@ export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; export { uptimeRouteWrapper } from './uptime_route_wrapper'; export const restApiRoutes: UMRestApiRouteFactory[] = [ + createGetOverviewFilters, createGetAllRoute, createGetIndexPatternRoute, createGetMonitorRoute, diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts new file mode 100644 index 00000000000000..ef93253bb5b700 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts @@ -0,0 +1,61 @@ +/* + * 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 } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; +import { objectValuesToArrays } from '../../lib/helper'; + +const arrayOrStringType = schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) +); + +export const createGetOverviewFilters: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/filters', + validate: { + query: schema.object({ + dateRangeStart: schema.string(), + dateRangeEnd: schema.string(), + search: schema.maybe(schema.string()), + locations: arrayOrStringType, + schemes: arrayOrStringType, + ports: arrayOrStringType, + tags: arrayOrStringType, + }), + }, + + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response) => { + const { dateRangeStart, dateRangeEnd, locations, schemes, search, ports, tags } = request.query; + + let parsedSearch: Record | undefined; + if (search) { + try { + parsedSearch = JSON.parse(search); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } + } + + const filtersResponse = await libs.monitors.getFilterBar({ + callES, + dateRangeStart, + dateRangeEnd, + search: parsedSearch, + filterOptions: objectValuesToArrays({ + locations, + ports, + schemes, + tags, + }), + }); + + return response.ok({ body: { ...filtersResponse } }); + }, +}); diff --git a/x-pack/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts similarity index 54% rename from x-pack/typings/encode_uri_query.d.ts rename to x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts index e1ab5f4a70abfe..dc4e0c66a8183d 100644 --- a/x-pack/typings/encode_uri_query.d.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { createGetOverviewFilters } from './get_overview_filters'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts index 8cebe8ce262296..65648ae5f5a959 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts @@ -104,4 +104,5 @@ export type TestSubjects = | 'webhookPathInput' | 'webhookPortInput' | 'webhookMethodSelect' + | 'webhookSchemeSelect' | 'webhookUsernameInput'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 36a5c150eead73..2800b0107da240 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -257,9 +257,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -324,9 +326,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -387,9 +391,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -461,9 +467,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -487,6 +495,7 @@ describe(' create route', () => { const METHOD = 'put'; const HOST = 'localhost'; const PORT = '9200'; + const SCHEME = 'http'; const PATH = '/test'; const USERNAME = 'test_user'; const PASSWORD = 'test_password'; @@ -510,6 +519,7 @@ describe(' create route', () => { form.setInputValue('webhookMethodSelect', METHOD); form.setInputValue('webhookHostInput', HOST); form.setInputValue('webhookPortInput', PORT); + form.setInputValue('webhookSchemeSelect', SCHEME); form.setInputValue('webhookPathInput', PATH); form.setInputValue('webhookUsernameInput', USERNAME); form.setInputValue('webhookPasswordInput', PASSWORD); @@ -534,6 +544,7 @@ describe(' create route', () => { method: METHOD, host: HOST, port: Number(PORT), + scheme: SCHEME, path: PATH, body: '{\n "message": "Watch [{{ctx.metadata.name}}] has exceeded the threshold"\n}', // Default @@ -551,9 +562,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -639,9 +652,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -707,9 +722,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -759,9 +776,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index 1eee3d3b7e6ee6..131400a8702c46 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts @@ -168,6 +168,7 @@ describe('', () => { }); const latestRequest = server.requests[server.requests.length - 1]; + const { id, type, @@ -194,9 +195,11 @@ describe('', () => { triggerIntervalUnit, aggType, termSize, + termOrder: 'desc', thresholdComparator, timeWindowSize, timeWindowUnit, + hasTermsAgg: false, threshold: threshold && threshold[0], }) ); diff --git a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js index 54be8407f207dc..d6f921a75a9ea3 100644 --- a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js @@ -16,6 +16,7 @@ export class WebhookAction extends BaseAction { this.method = props.method; this.host = props.host; this.port = props.port; + this.scheme = props.scheme; this.path = props.path; this.body = props.body; this.contentType = props.contentType; @@ -30,6 +31,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, contentType: this.contentType, @@ -47,6 +49,7 @@ export class WebhookAction extends BaseAction { method: json.method, host: json.host, port: json.port, + scheme: json.scheme, path: json.path, body: json.body, contentType: json.contentType, @@ -72,6 +75,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = this.method; } + if (this.scheme) { + optionalFields.scheme = this.scheme; + } + if (this.body) { optionalFields.body = this.body; } @@ -108,7 +115,7 @@ export class WebhookAction extends BaseAction { const webhookJson = json && json.actionJson && json.actionJson.webhook; const { errors } = this.validateJson(json.actionJson); - const { path, method, body, auth, headers } = webhookJson; + const { path, method, scheme, body, auth, headers } = webhookJson; const optionalFields = {}; @@ -120,6 +127,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = method; } + if (scheme) { + optionalFields.scheme = scheme; + } + if (body) { optionalFields.body = body; } diff --git a/x-pack/legacy/plugins/watcher/common/types/action_types.ts b/x-pack/legacy/plugins/watcher/common/types/action_types.ts index 123bf0f58db9d5..918e9a933611bd 100644 --- a/x-pack/legacy/plugins/watcher/common/types/action_types.ts +++ b/x-pack/legacy/plugins/watcher/common/types/action_types.ts @@ -56,6 +56,7 @@ export interface WebhookAction extends BaseAction { method?: 'head' | 'get' | 'post' | 'put' | 'delete'; host: string; port: number; + scheme?: 'http' | 'https'; path?: string; body?: string; username?: string; diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js index d46d9aacb035bd..6f496dd9ee1381 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js @@ -11,23 +11,21 @@ import { i18n } from '@kbn/i18n'; export class WebhookAction extends BaseAction { constructor(props = {}) { super(props); - const defaultJson = JSON.stringify( { message: 'Watch [{{ctx.metadata.name}}] has exceeded the threshold' }, null, 2 ); this.body = get(props, 'body', props.ignoreDefaults ? null : defaultJson); - this.method = get(props, 'method'); this.host = get(props, 'host'); this.port = get(props, 'port'); + this.scheme = get(props, 'scheme', 'http'); this.path = get(props, 'path'); this.username = get(props, 'username'); this.password = get(props, 'password'); this.contentType = get(props, 'contentType'); - - this.fullPath = `${this.host}:${this.port}${this.path}`; + this.fullPath = `${this.host}:${this.port}${this.path ? '/' + this.path : ''}`; } validate() { @@ -112,6 +110,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, username: this.username, diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js index 7611f158fb962a..2383388dd89bf2 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js @@ -256,9 +256,11 @@ export class ThresholdWatch extends BaseWatch { aggField: this.aggField, termSize: this.termSize, termField: this.termField, + termOrder: this.termOrder, thresholdComparator: this.thresholdComparator, timeWindowSize: this.timeWindowSize, timeWindowUnit: this.timeWindowUnit, + hasTermsAgg: this.hasTermsAgg, threshold: comparators[this.thresholdComparator].requiredValues > 1 ? this.threshold diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx index bdc6f0bcbb7172..be0b551f4a39c8 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx @@ -29,13 +29,15 @@ interface Props { const HTTP_VERBS = ['head', 'get', 'post', 'put', 'delete']; +const SCHEME = ['http', 'https']; + export const WebhookActionFields: React.FunctionComponent = ({ action, editAction, errors, hasErrors, }) => { - const { method, host, port, path, body, username, password } = action; + const { method, host, port, scheme, path, body, username, password } = action; useEffect(() => { editAction({ key: 'contentType', value: 'application/json' }); // set content-type for threshold watch to json by default @@ -65,6 +67,27 @@ export const WebhookActionFields: React.FunctionComponent = ({ + + + ({ text: verb, value: verb }))} + onChange={e => { + editAction({ key: 'scheme', value: e.target.value }); + }} + /> + + + { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js index bf7889473b60d6..c83fbc0b4564c1 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js @@ -188,50 +188,6 @@ describe('BaseWatch', () => { }); }); - describe('upstreamJson getter method', () => { - let props; - beforeEach(() => { - props = { - id: 'foo', - name: 'bar', - type: 'json', - watchStatus: { - downstreamJson: { - prop1: 'prop1', - prop2: 'prop2', - }, - }, - actions: [ - { - downstreamJson: { - prop1: 'prop3', - prop2: 'prop4', - }, - }, - ], - }; - }); - - it('should return a valid object', () => { - const watch = new BaseWatch(props); - - const actual = watch.upstreamJson; - const expected = { - id: props.id, - watch: { - metadata: { - name: props.name, - xpack: { - type: props.type, - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('getPropsFromDownstreamJson method', () => { let downstreamJson; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js index e6d49f7adc19e4..2440d4ef338816 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js @@ -23,12 +23,6 @@ export class JsonWatch extends BaseWatch { return serializeJsonWatch(this.name, this.watch); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js index 56150667b609e0..0301c4c95be946 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js @@ -52,26 +52,6 @@ describe('JsonWatch', () => { }); }); - describe('upstreamJson getter method', () => { - it('should return the correct result', () => { - const watch = new JsonWatch({ watch: { foo: 'bar' } }); - const actual = watch.upstreamJson; - const expected = { - id: undefined, - watch: { - foo: 'bar', - metadata: { - xpack: { - type: 'json', - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('downstreamJson getter method', () => { let props; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js index 21909c488431fd..e5410588ab566c 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js @@ -55,12 +55,6 @@ export class ThresholdWatch extends BaseWatch { return formatVisualizeData(this, results); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/package.json b/x-pack/package.json index ffa593f5728ee3..1e20157831ba58 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -192,6 +192,7 @@ "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", + "angular": "^1.7.9", "angular-resource": "1.7.8", "angular-sanitize": "1.7.8", "angular-ui-ace": "0.2.3", @@ -342,7 +343,7 @@ "uuid": "3.3.2", "venn.js": "0.2.20", "vscode-languageserver": "^5.2.1", - "webpack": "4.33.0", + "webpack": "4.41.0", "wellknown": "^0.5.0", "xml2js": "^0.4.22", "xregexp": "4.2.4" diff --git a/x-pack/plugins/endpoint/server/routes/endpoints.ts b/x-pack/plugins/endpoint/server/routes/endpoints.ts index 59430947d97da7..9d2babc61f11f9 100644 --- a/x-pack/plugins/endpoint/server/routes/endpoints.ts +++ b/x-pack/plugins/endpoint/server/routes/endpoints.ts @@ -66,6 +66,7 @@ function mapToEndpointResultList( queryParams: Record, searchResponse: SearchResponse ): EndpointResultList { + const totalNumberOfEndpoints = searchResponse?.aggregations?.total?.value || 0; if (searchResponse.hits.hits.length > 0) { return { request_page_size: queryParams.size, @@ -74,13 +75,13 @@ function mapToEndpointResultList( .map(response => response.inner_hits.most_recent.hits.hits) .flatMap(data => data as HitSource) .map(entry => entry._source), - total: searchResponse.aggregations.total.value, + total: totalNumberOfEndpoints, }; } else { return { request_page_size: queryParams.size, request_index: queryParams.from, - total: 0, + total: totalNumberOfEndpoints, endpoints: [], }; } diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index 0a5b9f62f12a1c..125378891151b5 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -71,7 +71,11 @@ export function registerExploreRoute({ throw Boom.badRequest(relevantCause.reason); } - throw Boom.boomify(error); + return response.internalError({ + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/graph/server/routes/search.ts b/x-pack/plugins/graph/server/routes/search.ts index 400cdc4e82b6e6..91b404dc7cb915 100644 --- a/x-pack/plugins/graph/server/routes/search.ts +++ b/x-pack/plugins/graph/server/routes/search.ts @@ -6,7 +6,6 @@ import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { LicenseState, verifyApiAccess } from '../lib/license_state'; export function registerSearchRoute({ @@ -53,7 +52,12 @@ export function registerSearchRoute({ }, }); } catch (error) { - throw Boom.boomify(error, { statusCode: error.statusCode || 500 }); + return response.customError({ + statusCode: error.statusCode || 500, + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9712c7c3473036..cf11e387a8793d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -441,9 +441,6 @@ "common.ui.flotCharts.tueLabel": "火", "common.ui.flotCharts.wedLabel": "水", "common.ui.management.breadcrumb": "管理", - "management.connectDataDisplayName": "データに接続", - "management.displayName": "管理", - "management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", @@ -521,6 +518,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} と {lt} {to}", "management.connectDataDisplayName": "データに接続", "management.displayName": "管理", + "management.nav.menu": "管理メニュー", "management.editIndexPattern.createIndex.defaultButtonDescription": "すべてのデータに完全集約を実行", "management.editIndexPattern.createIndex.defaultButtonText": "標準インデックスパターン", "management.editIndexPattern.createIndex.defaultTypeName": "インデックスパターン", @@ -972,8 +970,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "{objectType} を保存", "kibana-react.savedObjects.saveModal.titleLabel": "タイトル", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", "inspector.closeButton": "インスペクターを閉じる", "inspector.reqTimestampDescription": "リクエストの開始が記録された時刻です", "inspector.reqTimestampKey": "リクエストのタイムスタンプ", @@ -8050,7 +8048,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "異常スコア", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下の境界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上の境界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses} 個の {plusSign}異常な{byFieldName}値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "複数バケットの影響", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "予定イベント {counter}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 21ec06c6a191ee..3a03bb383f2cbe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -441,9 +441,6 @@ "common.ui.flotCharts.tueLabel": "周二", "common.ui.flotCharts.wedLabel": "周三", "common.ui.management.breadcrumb": "管理", - "management.connectDataDisplayName": "连接数据", - "management.displayName": "管理", - "management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", @@ -522,6 +519,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} 且 {lt} {to}", "management.connectDataDisplayName": "连接数据", "management.displayName": "管理", + "management.nav.menu": "管理菜单", "management.editIndexPattern.createIndex.defaultButtonDescription": "对任何数据执行完全聚合", "management.editIndexPattern.createIndex.defaultButtonText": "标准索引模式", "management.editIndexPattern.createIndex.defaultTypeName": "索引模式", @@ -973,8 +971,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "保存 {objectType}", "kibana-react.savedObjects.saveModal.titleLabel": "标题", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", "inspector.closeButton": "关闭检查器", "inspector.reqTimestampDescription": "记录请求启动的时间", "inspector.reqTimestampKey": "请求时间戳", @@ -8139,7 +8137,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "异常分数", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下边界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上边界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses}{plusSign} 异常 {byFieldName} 值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "多存储桶影响", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "已计划事件{counter}", diff --git a/x-pack/test/api_integration/apis/endpoint/endpoints.ts b/x-pack/test/api_integration/apis/endpoint/endpoints.ts index 95c3678672da30..32864489d37867 100644 --- a/x-pack/test/api_integration/apis/endpoint/endpoints.ts +++ b/x-pack/test/api_integration/apis/endpoint/endpoints.ts @@ -10,9 +10,24 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); describe('test endpoints api', () => { - before(() => esArchiver.load('endpoint/endpoints')); - after(() => esArchiver.unload('endpoint/endpoints')); - describe('GET /api/endpoint/endpoints', () => { + describe('POST /api/endpoint/endpoints when index is empty', () => { + it('endpoints api should return empty result when index is empty', async () => { + await esArchiver.unload('endpoint/endpoints'); + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(0); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(0); + }); + }); + + describe('POST /api/endpoint/endpoints when index is not empty', () => { + before(() => esArchiver.load('endpoint/endpoints')); + after(() => esArchiver.unload('endpoint/endpoints')); it('endpoints api should return one entry for each endpoint with default paging', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') @@ -46,6 +61,30 @@ export default function({ getService }: FtrProviderContext) { expect(body.request_index).to.eql(1); }); + /* test that when paging properties produces no result, the total should reflect the actual number of endpoints + in the index. + */ + it('endpoints api should return accurate total endpoints if page index produces no result', async () => { + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 3, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(3); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(30); + }); + it('endpoints api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json index 04d56d5949d2c7..65094144d6ff0d 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json @@ -9,21 +9,6 @@ "totalShards": 10, "status": "green" }, - "logs": { - "enabled": false, - "limit": 10, - "reason": { - "clusterExists": false, - "indexPatternExists": false, - "indexPatternInTimeRangeExists": false, - "typeExistsAtAnyTime": false, - "usingStructuredLogs": false, - "nodeExists": null, - "indexExists": false, - "typeExists": false - }, - "logs": [] - }, "metrics": { "index_search_request_rate": [ { @@ -1104,93 +1089,108 @@ } ] }, + "logs": { + "enabled": false, + "logs": [], + "reason": { + "indexPatternExists": false, + "indexPatternInTimeRangeExists": false, + "typeExistsAtAnyTime": false, + "typeExists": false, + "usingStructuredLogs": false, + "clusterExists": false, + "nodeExists": null, + "indexExists": false + }, + "limit": 10 + }, "shards": [ { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" } ], "shardStats": { "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1198,29 +1198,20 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } } }, "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1228,22 +1219,13 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json index 0b8d26558e7fc5..32096b0b970674 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json @@ -1,293 +1,1518 @@ { "nodeSummary": { - "resolver": "jxcP6ue7eRCieNNitFTT0EA", - "node_ids": [], - "attributes": {}, - "transport_address": "", - "name": "jxcP6ue7eRCieNNitFTT0EA", - "type": "node", - "nodeTypeLabel": "Offline Node", - "status": "Offline", - "isOnline": false + "resolver": "jUT5KdxfRbORSCWkb5zjmA", + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], + "attributes": { + "ml.enabled": "true", + "ml.max_open_jobs": "10" + }, + "transport_address": "127.0.0.1:9300", + "name": "whatever-01", + "type": "master", + "nodeTypeLabel": "Master Node", + "nodeTypeClass": "starFilled", + "totalShards": 38, + "indexCount": 20, + "documents": 24830, + "dataSize": 52847579, + "freeSpace": 186755088384, + "totalSpace": 499065712640, + "usedHeap": 29, + "status": "Online", + "isOnline": true + }, + "metrics": { + "node_total_io": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.operations", + "metricAgg": "max", + "label": "Total I/O", + "title": "I/O Operations Rate", + "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.read_operations", + "metricAgg": "max", + "label": "Total Read I/O", + "title": "I/O Operations Rate", + "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.write_operations", + "metricAgg": "max", + "label": "Total Write I/O", + "title": "I/O Operations Rate", + "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + } + ], + "node_latency": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.search.query_total", + "metricAgg": "sum", + "label": "Search", + "title": "Latency", + "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0.33333333333333337 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.33333333333333337 + ], + [ + 1507235560000, + 0 + ], + [ + 1507235570000, + 0.33333333333333337 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0.33333333333333337 + ], + [ + 1507235610000, + 0 + ], + [ + 1507235620000, + 0 + ], + [ + 1507235630000, + 0.33333333333333337 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 0.2 + ], + [ + 1507235680000, + 0 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.indexing.index_total", + "metricAgg": "sum", + "label": "Indexing", + "title": "Latency", + "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.888888888888889 + ], + [ + 1507235560000, + 1.1666666666666667 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 1.3333333333333333 + ], + [ + 1507235620000, + 1.1666666666666667 + ], + [ + 1507235630000, + 0 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2.3333333333333335 + ], + [ + 1507235680000, + 2.8749999999999996 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_jvm_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_max_in_bytes", + "metricAgg": "max", + "label": "Max Heap", + "title": "JVM Heap", + "description": "Total heap available to Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 709623808 + ], + [ + 1507235530000, + 709623808 + ], + [ + 1507235540000, + 709623808 + ], + [ + 1507235550000, + 709623808 + ], + [ + 1507235560000, + 709623808 + ], + [ + 1507235570000, + 709623808 + ], + [ + 1507235580000, + 709623808 + ], + [ + 1507235590000, + 709623808 + ], + [ + 1507235600000, + 709623808 + ], + [ + 1507235610000, + 709623808 + ], + [ + 1507235620000, + 709623808 + ], + [ + 1507235630000, + 709623808 + ], + [ + 1507235640000, + 709623808 + ], + [ + 1507235650000, + 709623808 + ], + [ + 1507235660000, + 709623808 + ], + [ + 1507235670000, + 709623808 + ], + [ + 1507235680000, + 709623808 + ], + [ + 1507235690000, + 709623808 + ], + [ + 1507235700000, + 709623808 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_used_in_bytes", + "metricAgg": "max", + "label": "Used Heap", + "title": "JVM Heap", + "description": "Total heap used by Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 317052776 + ], + [ + 1507235530000, + 344014976 + ], + [ + 1507235540000, + 368593248 + ], + [ + 1507235550000, + 253850400 + ], + [ + 1507235560000, + 348095032 + ], + [ + 1507235570000, + 182919712 + ], + [ + 1507235580000, + 212395016 + ], + [ + 1507235590000, + 244004144 + ], + [ + 1507235600000, + 270412240 + ], + [ + 1507235610000, + 245052864 + ], + [ + 1507235620000, + 370270616 + ], + [ + 1507235630000, + 196944168 + ], + [ + 1507235640000, + 223491760 + ], + [ + 1507235650000, + 253878472 + ], + [ + 1507235660000, + 280811736 + ], + [ + 1507235670000, + 371931976 + ], + [ + 1507235680000, + 329874616 + ], + [ + 1507235690000, + 363869776 + ], + [ + 1507235700000, + 211045968 + ] + ] + } + ], + "node_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.memory_in_bytes", + "metricAgg": "max", + "label": "Lucene Total", + "title": "Index Memory", + "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 4797457 + ], + [ + 1507235530000, + 4797457 + ], + [ + 1507235540000, + 4797457 + ], + [ + 1507235550000, + 4797457 + ], + [ + 1507235560000, + 4823580 + ], + [ + 1507235570000, + 4823580 + ], + [ + 1507235580000, + 4823580 + ], + [ + 1507235590000, + 4823580 + ], + [ + 1507235600000, + 4823580 + ], + [ + 1507235610000, + 4838368 + ], + [ + 1507235620000, + 4741420 + ], + [ + 1507235630000, + 4741420 + ], + [ + 1507235640000, + 4741420 + ], + [ + 1507235650000, + 4741420 + ], + [ + 1507235660000, + 4741420 + ], + [ + 1507235670000, + 4757998 + ], + [ + 1507235680000, + 4787542 + ], + [ + 1507235690000, + 4787542 + ], + [ + 1507235700000, + 4787542 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.terms_memory_in_bytes", + "metricAgg": "max", + "label": "Terms", + "title": "Index Memory", + "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 3764438 + ], + [ + 1507235530000, + 3764438 + ], + [ + 1507235540000, + 3764438 + ], + [ + 1507235550000, + 3764438 + ], + [ + 1507235560000, + 3786762 + ], + [ + 1507235570000, + 3786762 + ], + [ + 1507235580000, + 3786762 + ], + [ + 1507235590000, + 3786762 + ], + [ + 1507235600000, + 3786762 + ], + [ + 1507235610000, + 3799306 + ], + [ + 1507235620000, + 3715996 + ], + [ + 1507235630000, + 3715996 + ], + [ + 1507235640000, + 3715996 + ], + [ + 1507235650000, + 3715996 + ], + [ + 1507235660000, + 3715996 + ], + [ + 1507235670000, + 3729890 + ], + [ + 1507235680000, + 3755528 + ], + [ + 1507235690000, + 3755528 + ], + [ + 1507235700000, + 3755528 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.points_memory_in_bytes", + "metricAgg": "max", + "label": "Points", + "title": "Index Memory", + "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 12171 + ], + [ + 1507235530000, + 12171 + ], + [ + 1507235540000, + 12171 + ], + [ + 1507235550000, + 12171 + ], + [ + 1507235560000, + 12198 + ], + [ + 1507235570000, + 12198 + ], + [ + 1507235580000, + 12198 + ], + [ + 1507235590000, + 12198 + ], + [ + 1507235600000, + 12198 + ], + [ + 1507235610000, + 12218 + ], + [ + 1507235620000, + 12120 + ], + [ + 1507235630000, + 12120 + ], + [ + 1507235640000, + 12120 + ], + [ + 1507235650000, + 12120 + ], + [ + 1507235660000, + 12120 + ], + [ + 1507235670000, + 12140 + ], + [ + 1507235680000, + 12166 + ], + [ + 1507235690000, + 12166 + ], + [ + 1507235700000, + 12166 + ] + ] + } + ], + "node_cpu_metric": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.process.cpu.percent", + "metricAgg": "max", + "label": "CPU Utilization", + "description": "Percentage of CPU usage for the Elasticsearch process.", + "units": "%", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 1 + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 1 + ], + [ + 1507235560000, + 2 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 2 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 3 + ], + [ + 1507235620000, + 2 + ], + [ + 1507235630000, + 2 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 1 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2 + ], + [ + 1507235680000, + 2 + ], + [ + 1507235690000, + 1 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_load_average": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.os.cpu.load_average.1m", + "metricAgg": "max", + "label": "1m", + "title": "System Load", + "description": "Load average over the last minute.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 2.876953125 + ], + [ + 1507235530000, + 2.66015625 + ], + [ + 1507235540000, + 2.40625 + ], + [ + 1507235550000, + 2.189453125 + ], + [ + 1507235560000, + 2.626953125 + ], + [ + 1507235570000, + 2.451171875 + ], + [ + 1507235580000, + 2.81640625 + ], + [ + 1507235590000, + 3.70703125 + ], + [ + 1507235600000, + 3.51171875 + ], + [ + 1507235610000, + 3.359375 + ], + [ + 1507235620000, + 3.076171875 + ], + [ + 1507235630000, + 2.990234375 + ], + [ + 1507235640000, + 2.904296875 + ], + [ + 1507235650000, + 2.84375 + ], + [ + 1507235660000, + 3.28125 + ], + [ + 1507235670000, + 5.30859375 + ], + [ + 1507235680000, + 7.63671875 + ], + [ + 1507235690000, + 9.4375 + ], + [ + 1507235700000, + 11.421875 + ] + ] + } + ], + "node_segment_count": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.count", + "metricAgg": "max", + "label": "Segment Count", + "description": "Maximum segment count for primary and replica shards on this node.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 128 + ], + [ + 1507235530000, + 128 + ], + [ + 1507235540000, + 128 + ], + [ + 1507235550000, + 128 + ], + [ + 1507235560000, + 131 + ], + [ + 1507235570000, + 131 + ], + [ + 1507235580000, + 131 + ], + [ + 1507235590000, + 131 + ], + [ + 1507235600000, + 131 + ], + [ + 1507235610000, + 133 + ], + [ + 1507235620000, + 126 + ], + [ + 1507235630000, + 126 + ], + [ + 1507235640000, + 126 + ], + [ + 1507235650000, + 126 + ], + [ + 1507235660000, + 126 + ], + [ + 1507235670000, + 128 + ], + [ + 1507235680000, + 130 + ], + [ + 1507235690000, + 130 + ], + [ + 1507235700000, + 130 + ] + ] + } + ] }, "logs": { "enabled": false, - "limit": 10, + "logs": [], "reason": { - "clusterExists": false, "indexPatternExists": false, "indexPatternInTimeRangeExists": false, "typeExistsAtAnyTime": false, + "typeExists": false, "usingStructuredLogs": false, + "clusterExists": false, "nodeExists": false, - "indexExists": null, - "typeExists": false + "indexExists": null }, - "logs": [] + "limit": 10 }, - "metrics": { - "node_latency": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.search.query_total", - "metricAgg": "sum", - "label": "Search", - "title": "Latency", - "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.indexing.index_total", - "metricAgg": "sum", - "label": "Indexing", - "title": "Latency", - "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }], - "node_jvm_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_max_in_bytes", - "metricAgg": "max", - "label": "Max Heap", - "title": "JVM Heap", - "description": "Total heap available to Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_used_in_bytes", - "metricAgg": "max", - "label": "Used Heap", - "title": "JVM Heap", - "description": "Total heap used by Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.memory_in_bytes", - "metricAgg": "max", - "label": "Lucene Total", - "title": "Index Memory", - "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.terms_memory_in_bytes", - "metricAgg": "max", - "label": "Terms", - "title": "Index Memory", - "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.points_memory_in_bytes", - "metricAgg": "max", - "label": "Points", - "title": "Index Memory", - "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_cpu_metric": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.process.cpu.percent", - "metricAgg": "max", - "label": "CPU Utilization", - "description": "Percentage of CPU usage for the Elasticsearch process.", - "units": "%", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_total_io": [{ - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }, - { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.read_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Read I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } + "shards": [ + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" }, { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.write_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Write I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }], - "node_load_average": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.os.cpu.load_average.1m", - "metricAgg": "max", - "label": "1m", - "title": "System Load", - "description": "Load average over the last minute.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_segment_count": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.count", - "metricAgg": "max", - "label": "Segment Count", - "description": "Maximum segment count for primary and replica shards on this node.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }] - }, - "shards": [], + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "relocation_test", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": "bwQWH-7IQY-mFPpfoaoFXQ", + "shard": 0, + "state": "RELOCATING" + } + ], "shardStats": { "indices": { "avocado-tweets-2017.09.30": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -295,8 +1520,8 @@ }, "avocado-tweets-2017.10.02": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -305,43 +1530,34 @@ "avocado-tweets-2017.10.03": { "status": "green", "primary": 5, - "replica": 5, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 } }, "phone-home": { - "status": "yellow", - "primary": 5, - "replica": 4, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, "watermelon-tweets-2017.10.05": { - "status": "yellow", - "primary": 5, - "replica": 4, - "unassigned": { - "primary": 0, - "replica": 1 - } - }, - ".security-v6": { - "status": "yellow", - "primary": 1, - "replica": 1, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, ".kibana": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -349,7 +1565,7 @@ }, ".monitoring-alerts-6": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -358,7 +1574,7 @@ }, ".monitoring-es-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, @@ -367,17 +1583,26 @@ }, ".monitoring-kibana-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, "replica": 1 } }, + ".security-v6": { + "status": "green", + "primary": 1, + "replica": 0, + "unassigned": { + "primary": 0, + "replica": 0 + } + }, ".triggered_watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -386,7 +1611,7 @@ ".watcher-history-7-2017.09.29": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -395,7 +1620,7 @@ ".watcher-history-7-2017.09.30": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -404,7 +1629,7 @@ ".watcher-history-7-2017.10.01": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -412,7 +1637,7 @@ }, ".watcher-history-7-2017.10.02": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -422,7 +1647,7 @@ ".watcher-history-7-2017.10.03": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -430,7 +1655,7 @@ }, ".watcher-history-7-2017.10.04": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -440,7 +1665,7 @@ ".watcher-history-7-2017.10.05": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -449,7 +1674,7 @@ ".watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -471,22 +1696,10 @@ "shardCount": 38, "indexCount": 20, "name": "whatever-01", - "node_ids": ["jUT5KdxfRbORSCWkb5zjmA"], + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], "type": "master" - }, - "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, - "name": "whatever-02", - "node_ids": ["xcP6ue7eRCieNNitFTT0EA"], - "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": ["bwQWH-7IQY-mFPpfoaoFXQ"], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js index 43b5f6d119d6bc..9fbf1e02c9c2ec 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js @@ -32,7 +32,7 @@ export default function({ getService }) { it('should summarize node with metrics', async () => { const { body } = await supertest .post( - '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jxcP6ue7eRCieNNitFTT0EA' + '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jUT5KdxfRbORSCWkb5zjmA' ) .set('kbn-xsrf', 'xxx') .send({ diff --git a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js b/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js deleted file mode 100644 index c21a602d7ba1a3..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js +++ /dev/null @@ -1,35 +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 { expectFixtureEql } from './helpers/expect_fixture_eql'; -import { filterBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; - -export default function({ getService }) { - describe('filterBar query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns the expected filters', async () => { - const getFilterBarQuery = { - operationName: 'FilterBar', - query: filterBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getFilterBarQuery }); - expectFixtureEql(data, 'filter_list'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json deleted file mode 100644 index f07e4163221057..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "filterBar": { - "ids": [ - "0000-intermittent", - "0001-up", - "0002-up", - "0003-up", - "0004-up", - "0005-up", - "0006-up", - "0007-up", - "0008-up", - "0009-up", - "0010-down", - "0011-up", - "0012-up", - "0013-up", - "0014-up", - "0015-intermittent", - "0016-up", - "0017-up", - "0018-up", - "0019-up" - ], - "locations": [ - "mpls" - ], - "ports": [ - 5678 - ], - "schemes": [ - "http" - ], - "urls": [ - "http://localhost:5678/pattern?r=200x1", - "http://localhost:5678/pattern?r=200x5,500x1", - "http://localhost:5678/pattern?r=400x1" - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json new file mode 100644 index 00000000000000..76e307f27d8411 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json @@ -0,0 +1,12 @@ +{ + "schemes": [ + "http" + ], + "ports": [ + 5678 + ], + "locations": [ + "mpls" + ], + "tags": [] +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.js b/x-pack/test/api_integration/apis/uptime/graphql/index.js index 5c93be83ab7d9a..64999761fde4ec 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/index.js +++ b/x-pack/test/api_integration/apis/uptime/graphql/index.js @@ -11,7 +11,6 @@ export default function({ loadTestFile }) { // verifying the pre-loaded documents are returned in a way that // matches the snapshots contained in './fixtures' loadTestFile(require.resolve('./doc_count')); - loadTestFile(require.resolve('./filter_bar')); loadTestFile(require.resolve('./monitor_charts')); loadTestFile(require.resolve('./monitor_states')); loadTestFile(require.resolve('./ping_list')); diff --git a/x-pack/test/api_integration/apis/uptime/rest/filters.ts b/x-pack/test/api_integration/apis/uptime/rest/filters.ts new file mode 100644 index 00000000000000..6cec6143a6d7c6 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/filters.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const getApiPath = (dateRangeStart: string, dateRangeEnd: string, filters?: string) => + `/api/uptime/filters?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}${ + filters ? `&filters=${filters}` : '' + }`; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('filter group endpoint', () => { + const dateRangeStart = '2019-01-28T17:40:08.078Z'; + const dateRangeEnd = '2025-01-28T19:00:16.078Z'; + + it('returns expected filters', async () => { + const resp = await supertest.get(getApiPath(dateRangeStart, dateRangeEnd)); + expectFixtureEql(resp.body, 'filters'); + }); + }); +} diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 0366d128083702..bcfb72967b75a1 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -29,6 +29,27 @@ export default ({ getPageObjects }: FtrProviderContext) => { await pageObjects.uptime.pageHasExpectedIds(['0000-intermittent']); }); + it('applies filters for multiple fields', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.selectFilterItems({ + location: ['mpls'], + port: ['5678'], + scheme: ['http'], + }); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + ]); + }); + it('pagination is cleared when filter criteria changes', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await pageObjects.uptime.changePage('next'); diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index 6018b7ceebfa52..f04f96148583fa 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -71,6 +71,17 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo } } + public async selectFilterItems(filters: Record) { + for (const key in filters) { + if (filters.hasOwnProperty(key)) { + const values = filters[key]; + for (let i = 0; i < values.length; i++) { + await uptimeService.selectFilterItem(key, values[i]); + } + } + } + } + public async getSnapshotCount() { return await uptimeService.getSnapshotCount(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 12f1a23f1e024c..ca38c2e9dd897b 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -49,6 +49,15 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async setStatusFilterDown() { await testSubjects.click('xpack.uptime.filterBar.filterStatusDown'); }, + async selectFilterItem(filterType: string, option: string) { + const popoverId = `filter-popover_${filterType}`; + const optionId = `filter-popover-item_${option}`; + await testSubjects.existOrFail(popoverId); + await testSubjects.click(popoverId); + await testSubjects.existOrFail(optionId); + await testSubjects.click(optionId); + await testSubjects.click(popoverId); + }, async getSnapshotCount() { return { up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'), diff --git a/yarn.lock b/yarn.lock index f1f5488d3dd483..3dd7dbe37b2e94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4759,11 +4759,6 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-dynamic-import@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" - integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== - acorn-globals@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" @@ -4838,7 +4833,7 @@ acorn@^6.0.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== -acorn@^6.0.5, acorn@^6.2.1: +acorn@^6.2.1: version "6.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== @@ -7984,7 +7979,7 @@ chroma-js@^1.4.1: resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.4.1.tgz#eb2d9c4d1ff24616be84b35119f4d26f8205f134" integrity sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ== -chrome-trace-event@^1.0.0, chrome-trace-event@^1.0.2: +chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== @@ -10963,11 +10958,6 @@ enabled@1.0.x: dependencies: env-variable "0.0.x" -encode-uri-query@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encode-uri-query/-/encode-uri-query-1.0.1.tgz#e9c70d3e1aab71b039e55b38a166013508803ba8" - integrity sha1-6ccNPhqrcbA55Vs4oWYBNQiAO6g= - encodeurl@^1.0.2, encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -11613,14 +11603,6 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -18297,16 +18279,11 @@ load-source-map@^1.0.0: semver "^5.3.0" source-map "^0.5.6" -loader-runner@^2.3.0, loader-runner@^2.4.0: +loader-runner@^2.3.1, loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== -loader-runner@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.1.tgz#026f12fe7c3115992896ac02ba022ba92971b979" - integrity sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw== - loader-utils@1.2.3, loader-utils@^1.0.4, loader-utils@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" @@ -19228,7 +19205,7 @@ memory-fs@^0.2.0: resolved "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" integrity sha1-8rslNovBIeORwlIN6Slpyu4KApA= -memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: +memory-fs@^0.4.0, memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= @@ -19332,7 +19309,7 @@ microevent.ts@~0.1.1: resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -20309,7 +20286,7 @@ node-jose@1.1.0: util "^0.11.0" vm-browserify "0.0.4" -node-libs-browser@^2.0.0, node-libs-browser@^2.2.1: +node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== @@ -27379,7 +27356,7 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@^1.1.0, terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: +terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== @@ -27408,35 +27385,16 @@ terser-webpack-plugin@^2.1.2: terser "^4.3.4" webpack-sources "^1.4.3" -terser@^4.1.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.2.0.tgz#4b1b5f4424b426a7a47e80d6aae45e0d7979aef0" - integrity sha512-6lPt7lZdZ/13icQJp8XasFOwZjFJkxFFIb/N1fhYEQNoNI3Ilo3KABZ9OocZvZoB39r6SiIk/0+v/bt8nZoSeA== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -terser@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.3.4.tgz#ad91bade95619e3434685d69efa621a5af5f877d" - integrity sha512-Kcrn3RiW8NtHBP0ssOAzwa2MsIRQ8lJWiBG/K7JgqPlomA3mtb2DEmp4/hrUA+Jujx+WZ02zqd7GYD+QRBB/2Q== +terser@^4.1.2, terser@^4.3.4: + version "4.6.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.1.tgz#913e35e0d38a75285a7913ba01d753c4089ebdbd" + integrity sha512-w0f2OWFD7ka3zwetgVAhNMeyzEbj39ht2Tb0qKflw9PmW9Qbo5tjTh01QJLkhO9t9RDDQYvk+WXqpECI2C6i2A== dependencies: commander "^2.20.0" source-map "~0.6.1" source-map-support "~0.5.12" -test-exclude@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" - integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== - dependencies: - arrify "^1.0.1" - minimatch "^3.0.4" - read-pkg-up "^4.0.0" - require-main-filename "^1.0.1" - -test-exclude@^5.2.3: +test-exclude@^5.0.0, test-exclude@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== @@ -29955,7 +29913,7 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" -watchpack@^1.5.0, watchpack@^1.6.0: +watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== @@ -30115,7 +30073,7 @@ webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== @@ -30123,37 +30081,7 @@ webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@4.33.0: - version "4.33.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.33.0.tgz#c30fc4307db432e5c5e3333aaa7c16a15a3b277e" - integrity sha512-ggWMb0B2QUuYso6FPZKUohOgfm+Z0sVFs8WwWuSH1IAvkWs428VDNmOlAxvHGTB9Dm/qOB/qtE5cRx5y01clxw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.0.5" - acorn-dynamic-import "^4.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" - json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^1.0.0" - tapable "^1.1.0" - terser-webpack-plugin "^1.1.0" - watchpack "^1.5.0" - webpack-sources "^1.3.0" - -webpack@4.41.0, webpack@^4.41.0: +webpack@4.41.0, webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.0.tgz#db6a254bde671769f7c14e90a1a55e73602fc70b" integrity sha512-yNV98U4r7wX1VJAj5kyMsu36T8RPPQntcb5fJLOsMz/pt/WrKC0Vp1bAlqPLkA1LegSwQwf6P+kAbyhRKVQ72g== @@ -30182,35 +30110,6 @@ webpack@4.41.0, webpack@^4.41.0: watchpack "^1.6.0" webpack-sources "^1.4.1" -webpack@^4.33.0, webpack@^4.38.0: - version "4.39.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.3.tgz#a02179d1032156b713b6ec2da7e0df9d037def50" - integrity sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" - chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.1" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.1" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - websocket-driver@>=0.5.1: version "0.7.0" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"