diff --git a/.eslintrc.js b/.eslintrc.js index b3d29c9866411..5a03552ba3a51 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -132,12 +132,6 @@ module.exports = { 'react-hooks/rules-of-hooks': 'off', }, }, - { - files: ['x-pack/plugins/lens/**/*.{js,mjs,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['x-pack/plugins/ml/**/*.{js,mjs,ts,tsx}'], rules: { diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.createkbnurlstatestorage.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.createkbnurlstatestorage.md index 22f70ce22b574..478ba2d409acd 100644 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.createkbnurlstatestorage.md +++ b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.createkbnurlstatestorage.md @@ -9,8 +9,10 @@ Creates [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_ Signature: ```typescript -createKbnUrlStateStorage: ({ useHash, history }?: { +createKbnUrlStateStorage: ({ useHash, history, onGetError, onSetError, }?: { useHash: boolean; history?: History | undefined; + onGetError?: ((error: Error) => void) | undefined; + onSetError?: ((error: Error) => void) | undefined; }) => IKbnUrlStateStorage ``` diff --git a/docs/drilldowns/explore-underlying-data.asciidoc b/docs/drilldowns/explore-underlying-data.asciidoc index e0f940f73e96e..c2bba599730d8 100644 --- a/docs/drilldowns/explore-underlying-data.asciidoc +++ b/docs/drilldowns/explore-underlying-data.asciidoc @@ -33,9 +33,9 @@ applies the filters and time range created by the events that triggered the acti [role="screenshot"] image::images/explore_data_in_chart.png[Explore underlying data from chart] -You can disable this action by adding the following line to your `kibana.yml` config. +To enable this action add the following line to your `kibana.yml` config. ["source","yml"] ----------- -xpack.discoverEnhanced.actions.exploreDataInChart.enabled: false +xpack.discoverEnhanced.actions.exploreDataInChart.enabled: true ----------- diff --git a/docs/user/monitoring/images/monitoring-kibana-alerts.png b/docs/user/monitoring/images/monitoring-kibana-alerts.png new file mode 100644 index 0000000000000..43edcb4504140 Binary files /dev/null and b/docs/user/monitoring/images/monitoring-kibana-alerts.png differ diff --git a/docs/user/monitoring/index.asciidoc b/docs/user/monitoring/index.asciidoc index ab773657073ba..514988792d214 100644 --- a/docs/user/monitoring/index.asciidoc +++ b/docs/user/monitoring/index.asciidoc @@ -2,6 +2,7 @@ include::xpack-monitoring.asciidoc[] include::beats-details.asciidoc[leveloffset=+1] include::cluster-alerts.asciidoc[leveloffset=+1] include::elasticsearch-details.asciidoc[leveloffset=+1] +include::kibana-alerts.asciidoc[leveloffset=+1] include::kibana-details.asciidoc[leveloffset=+1] include::logstash-details.asciidoc[leveloffset=+1] include::monitoring-troubleshooting.asciidoc[leveloffset=+1] diff --git a/docs/user/monitoring/kibana-alerts.asciidoc b/docs/user/monitoring/kibana-alerts.asciidoc new file mode 100644 index 0000000000000..1ac5c385f8ed5 --- /dev/null +++ b/docs/user/monitoring/kibana-alerts.asciidoc @@ -0,0 +1,36 @@ +[role="xpack"] +[[kibana-alerts]] += {kib} Alerts + +The {stack} {monitor-features} provide +<> out-of-the box to notify you of +potential issues in the {stack}. These alerts are preconfigured based on the +best practices recommended by Elastic. However, you can tailor them to meet your +specific needs. + +When you open *{stack-monitor-app}*, the preconfigured {kib} alerts are +created automatically. If you collect monitoring data from multiple clusters, +these alerts can search, detect, and notify on various conditions across the +clusters. The alerts are visible alongside your existing {watcher} cluster +alerts. You can view details about the alerts that are active and view health +and performance data for {es}, {ls}, and Beats in real time, as well as +analyze past performance. You can also modify active alerts. + +[role="screenshot"] +image::user/monitoring/images/monitoring-kibana-alerts.png["Kibana alerts in the Stack Monitoring app"] + +To review and modify all the available alerts, use +<> in *{stack-manage-app}*. + +[discrete] +[[kibana-alerts-cpu-threshold]] +== CPU threshold + +This alert is triggered when a node runs a consistently high CPU load. By +default, the trigger condition is set at 85% or more averaged over the last 5 +minutes. The alert is grouped across all the nodes of the cluster by running +checks on a schedule time of 1 minute with a re-notify internal of 1 day. + +NOTE: Some action types are subscription features, while others are free. +For a comparison of the Elastic subscription levels, see the alerting section of +the {subscriptions}[Subscriptions page]. diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js index 8b8fdcb7a76ac..abe04fb8bd7e3 100644 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ b/src/plugins/dashboard/public/application/legacy_app.js @@ -30,6 +30,7 @@ import { createKbnUrlStateStorage, redirectWhenMissing, SavedObjectNotFound, + withNotifyOnErrors, } from '../../../kibana_utils/public'; import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; @@ -65,6 +66,7 @@ export function initDashboardApp(app, deps) { createKbnUrlStateStorage({ history, useHash: deps.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(deps.core.notifications.toasts), }) ); diff --git a/src/plugins/discover/public/application/angular/context.js b/src/plugins/discover/public/application/angular/context.js index a6f591eebb52d..6223090aa9f97 100644 --- a/src/plugins/discover/public/application/angular/context.js +++ b/src/plugins/discover/public/application/angular/context.js @@ -83,6 +83,7 @@ function ContextAppRouteController($routeParams, $scope, $route) { timeFieldName: indexPattern.timeFieldName, storeInSessionStorage: getServices().uiSettings.get('state:storeInSessionStorage'), history: getServices().history(), + toasts: getServices().core.notifications.toasts, }); this.state = { ...appState.getState() }; this.anchorId = $routeParams.id; diff --git a/src/plugins/discover/public/application/angular/context_state.ts b/src/plugins/discover/public/application/angular/context_state.ts index 7a92a6ace125b..5b05d8729c41d 100644 --- a/src/plugins/discover/public/application/angular/context_state.ts +++ b/src/plugins/discover/public/application/angular/context_state.ts @@ -18,11 +18,13 @@ */ import _ from 'lodash'; import { History } from 'history'; +import { NotificationsStart } from 'kibana/public'; import { createStateContainer, createKbnUrlStateStorage, syncStates, BaseStateContainer, + withNotifyOnErrors, } from '../../../../kibana_utils/public'; import { esFilters, FilterManager, Filter, Query } from '../../../../data/public'; @@ -74,6 +76,13 @@ interface GetStateParams { * History instance to use */ history: History; + + /** + * Core's notifications.toasts service + * In case it is passed in, + * kbnUrlStateStorage will use it notifying about inner errors + */ + toasts?: NotificationsStart['toasts']; } interface GetStateReturn { @@ -123,10 +132,12 @@ export function getState({ timeFieldName, storeInSessionStorage = false, history, + toasts, }: GetStateParams): GetStateReturn { const stateStorage = createKbnUrlStateStorage({ useHash: storeInSessionStorage, history, + ...(toasts && withNotifyOnErrors(toasts)), }); const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4a27f261a6220..22da3e877054a 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -220,6 +220,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise defaultAppState: getStateDefaults(), storeInSessionStorage: config.get('state:storeInSessionStorage'), history, + toasts: core.notifications.toasts, }); if (appStateContainer.getState().index !== $scope.indexPattern.id) { //used index pattern is different than the given by url/state which is invalid diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index 46500d9fdf85e..ff8fb9f80a723 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -18,12 +18,14 @@ */ import { isEqual } from 'lodash'; import { History } from 'history'; +import { NotificationsStart } from 'kibana/public'; import { createStateContainer, createKbnUrlStateStorage, syncState, ReduxLikeStateContainer, IKbnUrlStateStorage, + withNotifyOnErrors, } from '../../../../kibana_utils/public'; import { esFilters, Filter, Query } from '../../../../data/public'; import { migrateLegacyQuery } from '../../../../kibana_legacy/public'; @@ -68,6 +70,13 @@ interface GetStateParams { * Browser history */ history: History; + + /** + * Core's notifications.toasts service + * In case it is passed in, + * kbnUrlStateStorage will use it notifying about inner errors + */ + toasts?: NotificationsStart['toasts']; } export interface GetStateReturn { @@ -122,10 +131,12 @@ export function getState({ defaultAppState = {}, storeInSessionStorage = false, history, + toasts, }: GetStateParams): GetStateReturn { const stateStorage = createKbnUrlStateStorage({ useHash: storeInSessionStorage, history, + ...(toasts && withNotifyOnErrors(toasts)), }); const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; diff --git a/src/plugins/kibana_utils/docs/state_sync/README.md b/src/plugins/kibana_utils/docs/state_sync/README.md index acfe6dcf76fe9..c84bf7f236330 100644 --- a/src/plugins/kibana_utils/docs/state_sync/README.md +++ b/src/plugins/kibana_utils/docs/state_sync/README.md @@ -58,3 +58,4 @@ To run them, start kibana with `--run-examples` flag. - [On-the-fly state migrations](./on_fly_state_migrations.md). - [syncStates helper](./sync_states.md). - [Helpers for Data plugin (syncing TimeRange, RefreshInterval and Filters)](./data_plugin_helpers.md). +- [Error handling](./error_handling.md) diff --git a/src/plugins/kibana_utils/docs/state_sync/error_handling.md b/src/plugins/kibana_utils/docs/state_sync/error_handling.md new file mode 100644 index 0000000000000..b12e1040af260 --- /dev/null +++ b/src/plugins/kibana_utils/docs/state_sync/error_handling.md @@ -0,0 +1,6 @@ +# Error handling + +State syncing util doesn't have specific api for handling errors. +It expects that errors are handled on storage level. + +see [KbnUrlStateStorage](./storages/kbn_url_storage.md#) error handling section for details. diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md index 3a31f5a326edb..ec27895eed666 100644 --- a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md +++ b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md @@ -65,7 +65,7 @@ To prevent bugs caused by missing history updates, make sure your app uses one i For example, if you use `react-router`: ```tsx -const App = props => { +const App = (props) => { useEffect(() => { const stateStorage = createKbnUrlStateStorage({ useHash: props.uiSettings.get('state:storeInSessionStorage'), @@ -160,3 +160,58 @@ const { start, stop } = syncStates([ ; ``` + +### Error handling + +Errors could occur both during `kbnUrlStateStorage.get()` and `kbnUrlStateStorage.set()` + +#### Handling kbnUrlStateStorage.get() errors + +Possible error scenarios during `kbnUrlStateStorage.get()`: + +1. Rison in URL is malformed. Parsing exception. +2. useHash is enabled and current hash is missing in `sessionStorage` + +In all the cases error is handled internally and `kbnUrlStateStorage.get()` returns `null`, just like if there is no state in the URL anymore + +You can pass callback to get notified about errors. Use it, for example, for notifying users + +```ts +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + onGetError: (error) => { + alert(error.message); + }, +}); +``` + +#### Handling kbnUrlStateStorage.set() errors + +Possible errors during `kbnUrlStateStorage.set()`: + +1. `useHash` is enabled and can't store state in `sessionStorage` (overflow or no access) + +In all the cases error is handled internally and URL update is skipped + +You can pass callback to get notified about errors. Use it, for example, for notifying users: + +```ts +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + onSetError: (error) => { + alert(error.message); + }, +}); +``` + +#### Helper to integrate with core.notifications.toasts + +The most common scenario is to notify users about issues with state syncing using toast service from core +There is a convenient helper for this: + +```ts +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + ...withNotifyOnErrors(core.notifications.toasts), +}); +``` diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index e2d6ae647abb1..d1c9eec0e9906 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -57,6 +57,7 @@ export { getStateFromKbnUrl, getStatesFromKbnUrl, setStateToKbnUrl, + withNotifyOnErrors, } from './state_management/url'; export { syncState, diff --git a/src/plugins/kibana_utils/public/state_management/url/errors.ts b/src/plugins/kibana_utils/public/state_management/url/errors.ts new file mode 100644 index 0000000000000..b8b6523e8070c --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/errors.ts @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; + +export const restoreUrlErrorTitle = i18n.translate( + 'kibana_utils.stateManagement.url.restoreUrlErrorTitle', + { + defaultMessage: `Error restoring state from URL`, + } +); + +export const saveStateInUrlErrorTitle = i18n.translate( + 'kibana_utils.stateManagement.url.saveStateInUrlErrorTitle', + { + defaultMessage: `Error saving state in URL`, + } +); + +/** + * Helper for configuring {@link IKbnUrlStateStorage} to notify about inner errors + * + * @example + * ```ts + * const kbnUrlStateStorage = createKbnUrlStateStorage({ + * history, + * ...withNotifyOnErrors(core.notifications.toast)) + * } + * ``` + * @param toast - toastApi from core.notifications.toasts + */ +export const withNotifyOnErrors = (toasts: NotificationsStart['toasts']) => { + return { + onGetError: (error: Error) => { + toasts.addError(error, { + title: restoreUrlErrorTitle, + }); + }, + onSetError: (error: Error) => { + toasts.addError(error, { + title: saveStateInUrlErrorTitle, + }); + }, + }; +}; 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 e28d183c6560a..66fecd723e3ba 100644 --- a/src/plugins/kibana_utils/public/state_management/url/index.ts +++ b/src/plugins/kibana_utils/public/state_management/url/index.ts @@ -27,3 +27,4 @@ export { } from './kbn_url_storage'; export { createKbnUrlTracker } from './kbn_url_tracker'; export { createUrlTracker } from './url_tracker'; +export { withNotifyOnErrors, saveStateInUrlErrorTitle, restoreUrlErrorTitle } from './errors'; 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 index d9149095a2fa2..fefd5f668c6b3 100644 --- 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 @@ -103,7 +103,7 @@ export function setStateToKbnUrl( export interface IKbnUrlControls { /** * Listen for url changes - * @param cb - get's called when url has been changed + * @param cb - called when url has been changed */ listen: (cb: () => void) => () => void; @@ -142,12 +142,12 @@ export interface IKbnUrlControls { */ cancel: () => void; } -export type UrlUpdaterFnType = (currentUrl: string) => string; +export type UrlUpdaterFnType = (currentUrl: string) => string | undefined; export const createKbnUrlControls = ( history: History = createBrowserHistory() ): IKbnUrlControls => { - const updateQueue: Array<(currentUrl: string) => string> = []; + const updateQueue: UrlUpdaterFnType[] = []; // if we should replace or push with next async update, // if any call in a queue asked to push, then we should push @@ -188,7 +188,7 @@ export const createKbnUrlControls = ( function getPendingUrl() { if (updateQueue.length === 0) return undefined; const resultUrl = updateQueue.reduce( - (url, nextUpdate) => nextUpdate(url), + (url, nextUpdate) => nextUpdate(url) ?? url, getCurrentUrl(history) ); @@ -201,7 +201,7 @@ export const createKbnUrlControls = ( cb(); }), update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), - updateAsync: (updater: (currentUrl: string) => string, replace = false) => { + updateAsync: (updater: UrlUpdaterFnType, replace = false) => { updateQueue.push(updater); if (shouldReplace) { shouldReplace = replace; diff --git a/src/plugins/kibana_utils/public/state_sync/public.api.md b/src/plugins/kibana_utils/public/state_sync/public.api.md index ae8c0e8e401b8..a4dfea82cdb59 100644 --- a/src/plugins/kibana_utils/public/state_sync/public.api.md +++ b/src/plugins/kibana_utils/public/state_sync/public.api.md @@ -8,9 +8,11 @@ import { History } from 'history'; import { Observable } from 'rxjs'; // @public -export const createKbnUrlStateStorage: ({ useHash, history }?: { +export const createKbnUrlStateStorage: ({ useHash, history, onGetError, onSetError, }?: { useHash: boolean; history?: History | undefined; + onGetError?: ((error: Error) => void) | undefined; + onSetError?: ((error: Error) => void) | undefined; }) => IKbnUrlStateStorage; // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "Storage" 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 index cc708d14ea8b5..e222af91d7729 100644 --- 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 @@ -16,12 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import '../../storage/hashed_item_store/mock'; +import { mockStorage } from '../../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'; import { ScopedHistory } from '../../../../../core/public'; +import { withNotifyOnErrors } from '../../state_management/url'; +import { coreMock } from '../../../../../core/public/mocks'; describe('KbnUrlStateStorage', () => { describe('useHash: false', () => { @@ -93,6 +95,37 @@ describe('KbnUrlStateStorage', () => { expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); }); + + it("shouldn't throw in case of parsing error", async () => { + const key = '_s'; + history.replace(`/#?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(urlStateStorage.get(key)).toBeNull(); + }); + + it('should notify about errors', () => { + const cb = jest.fn(); + urlStateStorage = createKbnUrlStateStorage({ useHash: false, history, onGetError: cb }); + const key = '_s'; + history.replace(`/#?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(cb).toBeCalledWith(expect.any(Error)); + }); + + describe('withNotifyOnErrors integration', () => { + test('toast is shown', () => { + const toasts = coreMock.createStart().notifications.toasts; + urlStateStorage = createKbnUrlStateStorage({ + useHash: true, + history, + ...withNotifyOnErrors(toasts), + }); + const key = '_s'; + history.replace(`/#?${key}=(ok:2,test:`); // malformed rison + expect(() => urlStateStorage.get(key)).not.toThrow(); + expect(toasts.addError).toBeCalled(); + }); + }); }); describe('useHash: true', () => { @@ -128,6 +161,44 @@ describe('KbnUrlStateStorage', () => { expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); }); + + describe('hashStorage overflow exception', () => { + let oldLimit: number; + beforeAll(() => { + oldLimit = mockStorage.getStubbedSizeLimit(); + mockStorage.clear(); + mockStorage.setStubbedSizeLimit(0); + }); + afterAll(() => { + mockStorage.setStubbedSizeLimit(oldLimit); + }); + + it("shouldn't throw in case of error", async () => { + expect(() => urlStateStorage.set('_s', { test: 'test' })).not.toThrow(); + await expect(urlStateStorage.set('_s', { test: 'test' })).resolves; // not rejects + expect(getCurrentUrl()).toBe('/'); // url wasn't updated with hash + }); + + it('should notify about errors', async () => { + const cb = jest.fn(); + urlStateStorage = createKbnUrlStateStorage({ useHash: true, history, onSetError: cb }); + await expect(urlStateStorage.set('_s', { test: 'test' })).resolves; // not rejects + expect(cb).toBeCalledWith(expect.any(Error)); + }); + + describe('withNotifyOnErrors integration', () => { + test('toast is shown', async () => { + const toasts = coreMock.createStart().notifications.toasts; + urlStateStorage = createKbnUrlStateStorage({ + useHash: true, + history, + ...withNotifyOnErrors(toasts), + }); + await expect(urlStateStorage.set('_s', { test: 'test' })).resolves; // not rejects + expect(toasts.addError).toBeCalled(); + }); + }); + }); }); describe('ScopedHistory integration', () => { 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 index 0c74e1eb9f421..460720b98e30f 100644 --- 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 @@ -17,8 +17,8 @@ * under the License. */ -import { Observable } from 'rxjs'; -import { map, share } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { catchError, map, share } from 'rxjs/operators'; import { History } from 'history'; import { IStateStorage } from './types'; import { @@ -68,7 +68,19 @@ export interface IKbnUrlStateStorage extends IStateStorage { * @public */ export const createKbnUrlStateStorage = ( - { useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false } + { + useHash = false, + history, + onGetError, + onSetError, + }: { + useHash: boolean; + history?: History; + onGetError?: (error: Error) => void; + onSetError?: (error: Error) => void; + } = { + useHash: false, + } ): IKbnUrlStateStorage => { const url = createKbnUrlControls(history); return { @@ -78,15 +90,23 @@ export const createKbnUrlStateStorage = ( { replace = false }: { replace: boolean } = { replace: false } ) => { // syncState() utils doesn't wait for this promise - return url.updateAsync( - (currentUrl) => setStateToKbnUrl(key, state, { useHash }, currentUrl), - replace - ); + return url.updateAsync((currentUrl) => { + try { + return setStateToKbnUrl(key, state, { useHash }, currentUrl); + } catch (error) { + if (onSetError) onSetError(error); + } + }, replace); }, get: (key) => { // if there is a pending url update, then state will be extracted from that pending url, // otherwise current url will be used to retrieve state from - return getStateFromKbnUrl(key, url.getPendingUrl()); + try { + return getStateFromKbnUrl(key, url.getPendingUrl()); + } catch (e) { + if (onGetError) onGetError(e); + return null; + } }, change$: (key: string) => new Observable((observer) => { @@ -99,6 +119,10 @@ export const createKbnUrlStateStorage = ( }; }).pipe( map(() => getStateFromKbnUrl(key)), + catchError((error) => { + if (onGetError) onGetError(error); + return of(null); + }), share() ), flush: ({ replace = false }: { replace?: boolean } = {}) => { diff --git a/src/plugins/timelion/public/app.js b/src/plugins/timelion/public/app.js index 0294e71084f98..614a7539de44c 100644 --- a/src/plugins/timelion/public/app.js +++ b/src/plugins/timelion/public/app.js @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { createHashHistory } from 'history'; -import { createKbnUrlStateStorage } from '../../kibana_utils/public'; +import { createKbnUrlStateStorage, withNotifyOnErrors } from '../../kibana_utils/public'; import { syncQueryStateWithUrl } from '../../data/public'; import { getSavedSheetBreadcrumbs, getCreateBreadcrumbs } from './breadcrumbs'; @@ -63,6 +63,7 @@ export function initTimelionApp(app, deps) { createKbnUrlStateStorage({ history, useHash: deps.core.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(deps.core.notifications.toasts), }) ); app.config(watchMultiDecorator); diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index a462e488c6732..c1730e6a15435 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -119,6 +119,10 @@ export const metricsItems = schema.object({ type: stringRequired, value: stringOptionalNullable, values: schema.maybe(schema.nullable(schema.arrayOf(schema.nullable(schema.string())))), + size: stringOptionalNullable, + agg_with: stringOptionalNullable, + order: stringOptionalNullable, + order_by: stringOptionalNullable, }); const splitFiltersItems = schema.object({ diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index fd9a67599414f..3299319e613a0 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -31,7 +31,12 @@ import { ScopedHistory, } from 'kibana/public'; -import { Storage, createKbnUrlTracker, createKbnUrlStateStorage } from '../../kibana_utils/public'; +import { + Storage, + createKbnUrlTracker, + createKbnUrlStateStorage, + withNotifyOnErrors, +} from '../../kibana_utils/public'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; import { SharePluginStart } from '../../share/public'; @@ -150,6 +155,7 @@ export class VisualizePlugin kbnUrlStateStorage: createKbnUrlStateStorage({ history, useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), }), kibanaLegacy: pluginsStart.kibanaLegacy, pluginInitializerContext: this.initializerContext, diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index 5c6a70450a0aa..94409a94e9257 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -26,6 +26,7 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'discover', 'share', 'timePicker']); const browser = getService('browser'); + const toasts = getService('toasts'); describe('shared links', function describeIndexTests() { let baseUrl; @@ -132,28 +133,47 @@ export default function ({ getService, getPageObjects }) { await teardown(); }); - describe('permalink', function () { - it('should allow for copying the snapshot URL as a short URL and should open it', async function () { - const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); - await PageObjects.share.checkShortenUrl(); - let actualUrl; - await retry.try(async () => { - actualUrl = await PageObjects.share.getSharedUrl(); - expect(actualUrl).to.match(re); - }); + it('should allow for copying the snapshot URL as a short URL and should open it', async function () { + const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); + await PageObjects.share.checkShortenUrl(); + let actualUrl; + await retry.try(async () => { + actualUrl = await PageObjects.share.getSharedUrl(); + expect(actualUrl).to.match(re); + }); - const actualTime = await PageObjects.timePicker.getTimeConfig(); - - await browser.clearSessionStorage(); - await browser.get(actualUrl, false); - await retry.waitFor('shortUrl resolves and opens', async () => { - const resolvedUrl = await browser.getCurrentUrl(); - expect(resolvedUrl).to.match(/discover/); - const resolvedTime = await PageObjects.timePicker.getTimeConfig(); - expect(resolvedTime.start).to.equal(actualTime.start); - expect(resolvedTime.end).to.equal(actualTime.end); - return true; - }); + const actualTime = await PageObjects.timePicker.getTimeConfig(); + + await browser.clearSessionStorage(); + await browser.get(actualUrl, false); + await retry.waitFor('shortUrl resolves and opens', async () => { + const resolvedUrl = await browser.getCurrentUrl(); + expect(resolvedUrl).to.match(/discover/); + const resolvedTime = await PageObjects.timePicker.getTimeConfig(); + expect(resolvedTime.start).to.equal(actualTime.start); + expect(resolvedTime.end).to.equal(actualTime.end); + return true; + }); + }); + + it("sharing hashed url shouldn't crash the app", async () => { + const currentUrl = await browser.getCurrentUrl(); + const timeBeforeReload = await PageObjects.timePicker.getTimeConfig(); + await browser.clearSessionStorage(); + await browser.get(currentUrl, false); + await retry.waitFor('discover to open', async () => { + const resolvedUrl = await browser.getCurrentUrl(); + expect(resolvedUrl).to.match(/discover/); + const { message } = await toasts.getErrorToast(); + expect(message).to.contain( + 'Unable to completely restore the URL, be sure to use the share functionality.' + ); + await toasts.dismissAllToasts(); + const timeAfterReload = await PageObjects.timePicker.getTimeConfig(); + expect(timeBeforeReload.start).not.to.be(timeAfterReload.start); + expect(timeBeforeReload.end).not.to.be(timeAfterReload.end); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + return true; }); }); }); diff --git a/test/functional/services/toasts.ts b/test/functional/services/toasts.ts index 92f1f726fa039..a70e4ba464ae8 100644 --- a/test/functional/services/toasts.ts +++ b/test/functional/services/toasts.ts @@ -53,6 +53,16 @@ export function ToastsProvider({ getService }: FtrProviderContext) { await dismissButton.click(); } + public async dismissAllToasts() { + const list = await this.getGlobalToastList(); + const toasts = await list.findAllByCssSelector(`.euiToast`); + for (const toast of toasts) { + await toast.moveMouseTo(); + const dismissButton = await testSubjects.findDescendant('toastCloseButton', toast); + await dismissButton.click(); + } + } + private async getToastElement(index: number) { const list = await this.getGlobalToastList(); return await list.findByCssSelector(`.euiToast:nth-child(${index})`); diff --git a/x-pack/plugins/discover_enhanced/server/config.ts b/x-pack/plugins/discover_enhanced/server/config.ts index becbdee1bfe40..3e5e29e8c7de7 100644 --- a/x-pack/plugins/discover_enhanced/server/config.ts +++ b/x-pack/plugins/discover_enhanced/server/config.ts @@ -10,7 +10,7 @@ import { PluginConfigDescriptor } from '../../../../src/core/server'; export const configSchema = schema.object({ actions: schema.object({ exploreDataInChart: schema.object({ - enabled: schema.boolean({ defaultValue: true }), + enabled: schema.boolean({ defaultValue: false }), }), }), }); diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx index 9e4917856d8b2..fc82f4bf6cb00 100644 --- a/x-pack/plugins/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -4,19 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { CoreStart } from 'kibana/public'; import { ApolloClient } from 'apollo-client'; -import { - useUiSetting$, - KibanaContextProvider, -} from '../../../../../src/plugins/kibana_react/public'; -import { TriggersActionsProvider } from '../utils/triggers_actions_context'; -import { InfraClientStartDeps } from '../types'; +import { CoreStart } from 'kibana/public'; +import React, { useMemo } from 'react'; +import { useUiSetting$ } from '../../../../../src/plugins/kibana_react/public'; +import { EuiThemeProvider } from '../../../observability/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; +import { createKibanaContextForPlugin } from '../hooks/use_kibana'; +import { InfraClientStartDeps } from '../types'; import { ApolloClientContext } from '../utils/apollo_context'; -import { EuiThemeProvider } from '../../../observability/public'; import { NavigationWarningPromptProvider } from '../utils/navigation_warning_prompt'; +import { TriggersActionsProvider } from '../utils/triggers_actions_context'; export const CommonInfraProviders: React.FC<{ apolloClient: ApolloClient<{}>; @@ -39,9 +37,14 @@ export const CoreProviders: React.FC<{ core: CoreStart; plugins: InfraClientStartDeps; }> = ({ children, core, plugins }) => { + const { Provider: KibanaContextProviderForPlugin } = useMemo( + () => createKibanaContextForPlugin(core, plugins), + [core, plugins] + ); + return ( - + {children} - + ); }; diff --git a/x-pack/plugins/infra/public/components/loading_page.tsx b/x-pack/plugins/infra/public/components/loading_page.tsx index 9d37fed45b583..c410f37e7bf6b 100644 --- a/x-pack/plugins/infra/public/components/loading_page.tsx +++ b/x-pack/plugins/infra/public/components/loading_page.tsx @@ -11,12 +11,12 @@ import { EuiPageBody, EuiPageContent, } from '@elastic/eui'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { FlexPage } from './page'; interface LoadingPageProps { - message?: string | JSX.Element; + message?: ReactNode; } export const LoadingPage = ({ message }: LoadingPageProps) => ( diff --git a/x-pack/plugins/infra/public/hooks/use_kibana.ts b/x-pack/plugins/infra/public/hooks/use_kibana.ts new file mode 100644 index 0000000000000..24511014d1a06 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_kibana.ts @@ -0,0 +1,25 @@ +/* + * 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 { CoreStart } from '../../../../../src/core/public'; +import { + createKibanaReactContext, + KibanaReactContextValue, + useKibana, +} from '../../../../../src/plugins/kibana_react/public'; +import { InfraClientStartDeps } from '../types'; + +export type PluginKibanaContextValue = CoreStart & InfraClientStartDeps; + +export const createKibanaContextForPlugin = (core: CoreStart, pluginsStart: InfraClientStartDeps) => + createKibanaReactContext({ + ...core, + ...pluginsStart, + }); + +export const useKibanaContextForPlugin = useKibana as () => KibanaReactContextValue< + PluginKibanaContextValue +>; diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_space.ts b/x-pack/plugins/infra/public/hooks/use_kibana_space.ts new file mode 100644 index 0000000000000..1c06263008961 --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_kibana_space.ts @@ -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 { useAsync } from 'react-use'; +import { useKibanaContextForPlugin } from '../hooks/use_kibana'; +import type { Space } from '../../../spaces/public'; + +export type ActiveSpace = + | { isLoading: true; error: undefined; space: undefined } + | { isLoading: false; error: Error; space: undefined } + | { isLoading: false; error: undefined; space: Space }; + +export const useActiveKibanaSpace = (): ActiveSpace => { + const kibana = useKibanaContextForPlugin(); + + const asyncActiveSpace = useAsync(kibana.services.spaces.getActiveSpace); + + if (asyncActiveSpace.loading) { + return { + isLoading: true, + error: undefined, + space: undefined, + }; + } else if (asyncActiveSpace.error) { + return { + isLoading: false, + error: asyncActiveSpace.error, + space: undefined, + }; + } else { + return { + isLoading: false, + error: undefined, + space: asyncActiveSpace.value!, + }; + } +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx index 48ad156714ccf..723d833799e29 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx @@ -7,18 +7,25 @@ import React from 'react'; import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogSourceContext } from '../../../containers/logs/log_source'; -import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; +import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ children }) => { - const { sourceId, sourceConfiguration } = useLogSourceContext(); - const spaceId = useKibanaSpaceId(); + const { sourceConfiguration, sourceId } = useLogSourceContext(); + const { space } = useActiveKibanaSpace(); + + // This is a rather crude way of guarding the dependent providers against + // arguments that are only made available asynchronously. Ideally, we'd use + // React concurrent mode and Suspense in order to handle that more gracefully. + if (sourceConfiguration?.configuration.logAlias == null || space == null) { + return null; + } return ( {children} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index ac11260d2075d..e986fa37c2b2c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -9,23 +9,30 @@ import { LogAnalysisSetupFlyoutStateProvider } from '../../../components/logging import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; -import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; +import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); - const spaceId = useKibanaSpaceId(); + const { space } = useActiveKibanaSpace(); + + // This is a rather crude way of guarding the dependent providers against + // arguments that are only made available asynchronously. Ideally, we'd use + // React concurrent mode and Suspense in order to handle that more gracefully. + if (sourceConfiguration?.configuration.logAlias == null || space == null) { + return null; + } return ( {children} diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 357f07265ac6e..a1494a023201f 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; -import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; -import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import { +import type { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; +import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import type { UsageCollectionSetup, UsageCollectionStart, } from '../../../../src/plugins/usage_collection/public'; -import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; -import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; -import { ObservabilityPluginSetup, ObservabilityPluginStart } from '../../observability/public'; +import type { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; +import type { + ObservabilityPluginSetup, + ObservabilityPluginStart, +} from '../../observability/public'; +import type { SpacesPluginStart } from '../../spaces/public'; // Our own setup and start contract values export type InfraClientSetupExports = void; @@ -31,6 +35,7 @@ export interface InfraClientStartDeps { data: DataPublicPluginStart; dataEnhanced: DataEnhancedStart; observability: ObservabilityPluginStart; + spaces: SpacesPluginStart; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionStart; } diff --git a/x-pack/plugins/infra/public/utils/use_kibana_space_id.ts b/x-pack/plugins/infra/public/utils/use_kibana_space_id.ts deleted file mode 100644 index 86597f52928d5..0000000000000 --- a/x-pack/plugins/infra/public/utils/use_kibana_space_id.ts +++ /dev/null @@ -1,31 +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 { fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import * as rt from 'io-ts'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; - -export const useKibanaSpaceId = (): string => { - const kibana = useKibana(); - // NOTE: The injectedMetadata service will be deprecated at some point. We should migrate - // this to the client side Spaces plugin when it becomes available. - const activeSpace = kibana.services.injectedMetadata?.getInjectedVar('activeSpace'); - - return pipe( - activeSpaceRT.decode(activeSpace), - fold( - () => 'default', - (decodedActiveSpace) => decodedActiveSpace.space.id - ) - ); -}; - -const activeSpaceRT = rt.type({ - space: rt.type({ - id: rt.string, - }), -}); diff --git a/x-pack/plugins/ingest_manager/common/constants/package_config.ts b/x-pack/plugins/ingest_manager/common/constants/package_config.ts index e7d5ef67f7253..48fee967a3d3d 100644 --- a/x-pack/plugins/ingest_manager/common/constants/package_config.ts +++ b/x-pack/plugins/ingest_manager/common/constants/package_config.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const PACKAGE_CONFIG_SAVED_OBJECT_TYPE = 'ingest-package-configs'; +export const PACKAGE_CONFIG_SAVED_OBJECT_TYPE = 'ingest-package-policies'; diff --git a/x-pack/plugins/lens/layout.png b/x-pack/plugins/lens/layout.png new file mode 100644 index 0000000000000..170324a2ba393 Binary files /dev/null and b/x-pack/plugins/lens/layout.png differ diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index ab4c4820315ac..4a6dbd4a91fbf 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -19,6 +19,7 @@ import { import { createKbnUrlStateStorage, IStorageWrapper, + withNotifyOnErrors, } from '../../../../../src/plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { @@ -152,6 +153,7 @@ export function App({ const kbnUrlStateStorage = createKbnUrlStateStorage({ history, useHash: core.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(core.notifications.toasts), }); const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( data.query, @@ -163,7 +165,14 @@ export function App({ filterSubscription.unsubscribe(); timeSubscription.unsubscribe(); }; - }, [data.query.filterManager, data.query.timefilter.timefilter]); + }, [ + data.query.filterManager, + data.query.timefilter.timefilter, + core.notifications.toasts, + core.uiSettings, + data.query, + history, + ]); useEffect(() => { onAppLeave((actions) => { @@ -210,57 +219,61 @@ export function App({ ]); }, [core.application, core.chrome, core.http.basePath, state.persistedDoc]); - useEffect(() => { - if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) { - setState((s) => ({ ...s, isLoading: true })); - docStorage - .load(docId) - .then((doc) => { - getAllIndexPatterns( - doc.state.datasourceMetaData.filterableIndexPatterns, - data.indexPatterns, - core.notifications - ) - .then((indexPatterns) => { - // Don't overwrite any pinned filters - data.query.filterManager.setAppFilters(doc.state.filters); - setState((s) => ({ - ...s, - isLoading: false, - persistedDoc: doc, - lastKnownDoc: doc, - query: doc.state.query, - indexPatternsForTopNav: indexPatterns, - })); - }) - .catch(() => { - setState((s) => ({ ...s, isLoading: false })); - - redirectTo(); - }); - }) - .catch(() => { - setState((s) => ({ ...s, isLoading: false })); - - core.notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docLoadingError', { - defaultMessage: 'Error loading saved document', - }) - ); - - redirectTo(); - }); - } - }, [ - core.notifications, - data.indexPatterns, - data.query.filterManager, - docId, - // TODO: These dependencies are changing too often - // docStorage, - // redirectTo, - // state.persistedDoc, - ]); + useEffect( + () => { + if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) { + setState((s) => ({ ...s, isLoading: true })); + docStorage + .load(docId) + .then((doc) => { + getAllIndexPatterns( + doc.state.datasourceMetaData.filterableIndexPatterns, + data.indexPatterns, + core.notifications + ) + .then((indexPatterns) => { + // Don't overwrite any pinned filters + data.query.filterManager.setAppFilters(doc.state.filters); + setState((s) => ({ + ...s, + isLoading: false, + persistedDoc: doc, + lastKnownDoc: doc, + query: doc.state.query, + indexPatternsForTopNav: indexPatterns, + })); + }) + .catch(() => { + setState((s) => ({ ...s, isLoading: false })); + + redirectTo(); + }); + }) + .catch(() => { + setState((s) => ({ ...s, isLoading: false })); + + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docLoadingError', { + defaultMessage: 'Error loading saved document', + }) + ); + + redirectTo(); + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + core.notifications, + data.indexPatterns, + data.query.filterManager, + docId, + // TODO: These dependencies are changing too often + // docStorage, + // redirectTo, + // state.persistedDoc, + ] + ); const runSave = async ( saveProps: Omit & { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 143bec227ebee..02186ecf09b4b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -160,6 +160,7 @@ export function DatatableComponent(props: DatatableRenderProps) { formatters[column.id] = props.formatFactory(column.formatHint); }); + const { onClickValue } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; @@ -180,9 +181,9 @@ export function DatatableComponent(props: DatatableRenderProps) { ], timeFieldName, }; - props.onClickValue(desanitizeFilterContext(data)); + onClickValue(desanitizeFilterContext(data)); }, - [firstTable] + [firstTable, onClickValue] ); const bucketColumns = firstTable.columns diff --git a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx index 08f55850b119e..0e148798cdf75 100644 --- a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx +++ b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx @@ -17,13 +17,11 @@ export function debouncedComponent(component: FunctionComponent, return (props: TProps) => { const [cachedProps, setCachedProps] = useState(props); - const debouncePropsChange = debounce(setCachedProps, delay); - const delayRender = useMemo(() => debouncePropsChange, []); + const debouncePropsChange = useMemo(() => debounce(setCachedProps, delay), [setCachedProps]); // cancel debounced prop change if component has been unmounted in the meantime - useEffect(() => () => debouncePropsChange.cancel(), []); - - delayRender(props); + useEffect(() => () => debouncePropsChange.cancel(), [debouncePropsChange]); + debouncePropsChange(props); return React.createElement(MemoizedComponent, cachedProps); }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 73126b814f256..5f041a8d8562f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -39,29 +39,29 @@ function LayerPanels( } = props; const setVisualizationState = useMemo( () => (newState: unknown) => { - props.dispatch({ + dispatch({ type: 'UPDATE_VISUALIZATION_STATE', visualizationId: activeVisualization.id, newState, clearStagedPreview: false, }); }, - [props.dispatch, activeVisualization] + [dispatch, activeVisualization] ); const updateDatasource = useMemo( () => (datasourceId: string, newState: unknown) => { - props.dispatch({ + dispatch({ type: 'UPDATE_DATASOURCE_STATE', updater: () => newState, datasourceId, clearStagedPreview: false, }); }, - [props.dispatch] + [dispatch] ); const updateAll = useMemo( () => (datasourceId: string, newDatasourceState: unknown, newVisualizationState: unknown) => { - props.dispatch({ + dispatch({ type: 'UPDATE_STATE', subType: 'UPDATE_ALL_STATES', updater: (prevState) => { @@ -83,7 +83,7 @@ function LayerPanels( }, }); }, - [props.dispatch] + [dispatch] ); const layerIds = activeVisualization.getLayerIds(visualizationState); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index 0f74abe97c418..5a92f7b5ed524 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -27,16 +27,17 @@ interface DataPanelWrapperProps { } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { + const { dispatch, activeDatasource } = props; const setDatasourceState: StateSetter = useMemo( () => (updater) => { - props.dispatch({ + dispatch({ type: 'UPDATE_DATASOURCE_STATE', updater, - datasourceId: props.activeDatasource!, + datasourceId: activeDatasource!, clearStagedPreview: true, }); }, - [props.dispatch, props.activeDatasource] + [dispatch, activeDatasource] ); const datasourceProps: DatasourceDataPanelProps = { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index bcceb1222ce03..48a3511a8f359 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -62,34 +62,38 @@ export function EditorFrame(props: EditorFrameProps) { ); // Initialize current datasource and all active datasources - useEffect(() => { - // prevents executing dispatch on unmounted component - let isUnmounted = false; - if (!allLoaded) { - Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { - if ( - state.datasourceStates[datasourceId] && - state.datasourceStates[datasourceId].isLoading - ) { - datasource - .initialize(state.datasourceStates[datasourceId].state || undefined) - .then((datasourceState) => { - if (!isUnmounted) { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceState, - datasourceId, - }); - } - }) - .catch(onError); - } - }); - } - return () => { - isUnmounted = true; - }; - }, [allLoaded]); + useEffect( + () => { + // prevents executing dispatch on unmounted component + let isUnmounted = false; + if (!allLoaded) { + Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { + if ( + state.datasourceStates[datasourceId] && + state.datasourceStates[datasourceId].isLoading + ) { + datasource + .initialize(state.datasourceStates[datasourceId].state || undefined) + .then((datasourceState) => { + if (!isUnmounted) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceState, + datasourceId, + }); + } + }) + .catch(onError); + } + }); + } + return () => { + isUnmounted = true; + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [allLoaded, onError] + ); const datasourceLayers: Record = {}; Object.keys(props.datasourceMap) @@ -156,83 +160,95 @@ export function EditorFrame(props: EditorFrameProps) { }, }; - useEffect(() => { - if (props.doc) { - dispatch({ - type: 'VISUALIZATION_LOADED', - doc: props.doc, - }); - } else { - dispatch({ - type: 'RESET', - state: getInitialState(props), - }); - } - }, [props.doc]); + useEffect( + () => { + if (props.doc) { + dispatch({ + type: 'VISUALIZATION_LOADED', + doc: props.doc, + }); + } else { + dispatch({ + type: 'RESET', + state: getInitialState(props), + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.doc] + ); // Initialize visualization as soon as all datasources are ready - useEffect(() => { - if (allLoaded && state.visualization.state === null && activeVisualization) { - const initialVisualizationState = activeVisualization.initialize(framePublicAPI); - dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - visualizationId: activeVisualization.id, - newState: initialVisualizationState, - }); - } - }, [allLoaded, activeVisualization, state.visualization.state]); + useEffect( + () => { + if (allLoaded && state.visualization.state === null && activeVisualization) { + const initialVisualizationState = activeVisualization.initialize(framePublicAPI); + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + newState: initialVisualizationState, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [allLoaded, activeVisualization, state.visualization.state] + ); // The frame needs to call onChange every time its internal state changes - useEffect(() => { - const activeDatasource = - state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading - ? props.datasourceMap[state.activeDatasourceId] - : undefined; + useEffect( + () => { + const activeDatasource = + state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading + ? props.datasourceMap[state.activeDatasourceId] + : undefined; - if (!activeDatasource || !activeVisualization) { - return; - } + if (!activeDatasource || !activeVisualization) { + return; + } - const indexPatterns: DatasourceMetaData['filterableIndexPatterns'] = []; - Object.entries(props.datasourceMap) - .filter(([id, datasource]) => { - const stateWrapper = state.datasourceStates[id]; - return ( - stateWrapper && - !stateWrapper.isLoading && - datasource.getLayers(stateWrapper.state).length > 0 - ); - }) - .forEach(([id, datasource]) => { - indexPatterns.push( - ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns - ); - }); + const indexPatterns: DatasourceMetaData['filterableIndexPatterns'] = []; + Object.entries(props.datasourceMap) + .filter(([id, datasource]) => { + const stateWrapper = state.datasourceStates[id]; + return ( + stateWrapper && + !stateWrapper.isLoading && + datasource.getLayers(stateWrapper.state).length > 0 + ); + }) + .forEach(([id, datasource]) => { + indexPatterns.push( + ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns + ); + }); - const doc = getSavedObjectFormat({ - activeDatasources: Object.keys(state.datasourceStates).reduce( - (datasourceMap, datasourceId) => ({ - ...datasourceMap, - [datasourceId]: props.datasourceMap[datasourceId], - }), - {} - ), - visualization: activeVisualization, - state, - framePublicAPI, - }); + const doc = getSavedObjectFormat({ + activeDatasources: Object.keys(state.datasourceStates).reduce( + (datasourceMap, datasourceId) => ({ + ...datasourceMap, + [datasourceId]: props.datasourceMap[datasourceId], + }), + {} + ), + visualization: activeVisualization, + state, + framePublicAPI, + }); - props.onChange({ filterableIndexPatterns: indexPatterns, doc }); - }, [ - activeVisualization, - state.datasourceStates, - state.visualization, - props.query, - props.dateRange, - props.filters, - props.savedQuery, - state.title, - ]); + props.onChange({ filterableIndexPatterns: indexPatterns, doc }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + activeVisualization, + state.datasourceStates, + state.visualization, + props.query, + props.dateRange, + props.filters, + props.savedQuery, + state.title, + ] + ); return ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 7efaecb125c8e..aba8b70945129 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -205,6 +205,7 @@ export function SuggestionPanel({ return { suggestions: newSuggestions, currentStateExpression: newStateExpression }; }, [ + frame, currentDatasourceStates, currentVisualizationState, currentVisualizationId, @@ -217,7 +218,7 @@ export function SuggestionPanel({ return (props: ReactExpressionRendererProps) => ( ); - }, [plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$, ExpressionRendererComponent]); + }, [plugins.data.query.timefilter.timefilter]); const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState(-1); @@ -228,6 +229,7 @@ export function SuggestionPanel({ if (!stagedPreview && lastSelectedSuggestion !== -1) { setLastSelectedSuggestion(-1); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [stagedPreview]); if (!activeDatasourceId) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index a0d803d05d98b..88b791a7875ef 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -188,6 +188,7 @@ export function ChartSwitch(props: Props) { ...visualizationType, selection: getSelection(visualizationType.visualizationId, visualizationType.id), })), + // eslint-disable-next-line react-hooks/exhaustive-deps [ flyoutOpen, props.visualizationMap, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 9f5b6665b31d3..b3a12271f377b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -85,29 +85,33 @@ export function InnerWorkspacePanel({ const dragDropContext = useContext(DragContext); - const suggestionForDraggedField = useMemo(() => { - if (!dragDropContext.dragging || !activeDatasourceId) { - return; - } + const suggestionForDraggedField = useMemo( + () => { + if (!dragDropContext.dragging || !activeDatasourceId) { + return; + } - const hasData = Object.values(framePublicAPI.datasourceLayers).some( - (datasource) => datasource.getTableSpec().length > 0 - ); + const hasData = Object.values(framePublicAPI.datasourceLayers).some( + (datasource) => datasource.getTableSpec().length > 0 + ); - const suggestions = getSuggestions({ - datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] }, - datasourceStates, - visualizationMap: - hasData && activeVisualizationId - ? { [activeVisualizationId]: visualizationMap[activeVisualizationId] } - : visualizationMap, - activeVisualizationId, - visualizationState, - field: dragDropContext.dragging, - }); + const suggestions = getSuggestions({ + datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] }, + datasourceStates, + visualizationMap: + hasData && activeVisualizationId + ? { [activeVisualizationId]: visualizationMap[activeVisualizationId] } + : visualizationMap, + activeVisualizationId, + visualizationState, + field: dragDropContext.dragging, + }); - return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; - }, [dragDropContext.dragging]); + return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dragDropContext.dragging] + ); const [localState, setLocalState] = useState({ expressionBuildError: undefined as string | undefined, @@ -117,28 +121,32 @@ export function InnerWorkspacePanel({ const activeVisualization = activeVisualizationId ? visualizationMap[activeVisualizationId] : null; - const expression = useMemo(() => { - try { - return buildExpression({ - visualization: activeVisualization, - visualizationState, - datasourceMap, - datasourceStates, - framePublicAPI, - }); - } catch (e) { - // Most likely an error in the expression provided by a datasource or visualization - setLocalState((s) => ({ ...s, expressionBuildError: e.toString() })); - } - }, [ - activeVisualization, - visualizationState, - datasourceMap, - datasourceStates, - framePublicAPI.dateRange, - framePublicAPI.query, - framePublicAPI.filters, - ]); + const expression = useMemo( + () => { + try { + return buildExpression({ + visualization: activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, + }); + } catch (e) { + // Most likely an error in the expression provided by a datasource or visualization + setLocalState((s) => ({ ...s, expressionBuildError: e.toString() })); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI.dateRange, + framePublicAPI.query, + framePublicAPI.filters, + ] + ); const onEvent = useCallback( (event: ExpressionRendererEvent) => { @@ -162,7 +170,7 @@ export function InnerWorkspacePanel({ const autoRefreshFetch$ = useMemo( () => plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$(), - [plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$] + [plugins.data.query.timefilter.timefilter] ); useEffect(() => { @@ -173,7 +181,7 @@ export function InnerWorkspacePanel({ expressionBuildError: undefined, })); } - }, [expression]); + }, [expression, localState.expressionBuildError]); function onDrop() { if (suggestionForDraggedField) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index bb564214e4fab..bdcce52314634 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -409,7 +409,16 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ filters, chartsThemeService: charts.theme, }), - [core, data, currentIndexPattern, dateRange, query, filters, localState.nameFilter] + [ + core, + data, + currentIndexPattern, + dateRange, + query, + filters, + localState.nameFilter, + charts.theme, + ] ); return ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index b8f868a8694dd..4c85a55ad6011 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -139,10 +139,10 @@ export function FieldSelect({ }, [ incompatibleSelectedOperationType, selectedColumnOperationType, - selectedColumnSourceField, - operationFieldSupportMatrix, currentIndexPattern, fieldMap, + operationByField, + existingFields, ]); return ( diff --git a/x-pack/plugins/lens/public/loader.tsx b/x-pack/plugins/lens/public/loader.tsx index ebbb006d837b0..f6e179e9a6aa6 100644 --- a/x-pack/plugins/lens/public/loader.tsx +++ b/x-pack/plugins/lens/public/loader.tsx @@ -16,28 +16,32 @@ export function Loader(props: { load: () => Promise; loadDeps: unknown[ const prevRequest = useRef | undefined>(undefined); const nextRequest = useRef<(() => void) | undefined>(undefined); - useEffect(function performLoad() { - if (prevRequest.current) { - nextRequest.current = performLoad; - return; - } + useEffect( + function performLoad() { + if (prevRequest.current) { + nextRequest.current = performLoad; + return; + } - setIsProcessing(true); - prevRequest.current = props - .load() - .catch(() => {}) - .then(() => { - const reload = nextRequest.current; - prevRequest.current = undefined; - nextRequest.current = undefined; + setIsProcessing(true); + prevRequest.current = props + .load() + .catch(() => {}) + .then(() => { + const reload = nextRequest.current; + prevRequest.current = undefined; + nextRequest.current = undefined; - if (reload) { - reload(); - } else { - setIsProcessing(false); - } - }); - }, props.loadDeps); + if (reload) { + reload(); + } else { + setIsProcessing(false); + } + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + props.loadDeps + ); if (!isProcessing) { return null; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 59c4b393df467..6d5bc7808a678 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -379,7 +379,7 @@ const ColorPicker = ({ } setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); }, 256), - [state, layer, accessor, index] + [state, setState, layer, accessor, index] ); const colorPicker = ( diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 871b626d62560..a3468e109e75b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -180,7 +180,7 @@ export function XYChartReportable(props: XYChartRenderProps) { // reporting from printing a blank chart placeholder. useEffect(() => { setState({ isReady: true }); - }, []); + }, [setState]); return ( diff --git a/x-pack/plugins/lens/readme.md b/x-pack/plugins/lens/readme.md index 70d7f16b0f7fc..e69ba9ec9506d 100644 --- a/x-pack/plugins/lens/readme.md +++ b/x-pack/plugins/lens/readme.md @@ -12,3 +12,31 @@ Run all tests from the `x-pack` root directory - API Functional tests: - Run `node scripts/functional_tests_server` - Run `node ../scripts/functional_test_runner.js --config ./test/api_integration/config.ts --grep=Lens` + + +## UI Terminology + +Lens has a lot of UI elements – to make it easier to refer to them in issues or bugs, this is a hopefully complete list: + +* **Top nav** Navigation menu on top of the app (contains Save button) + * **Query bar** Input to enter KQL or Lucene query below the top nav + * **Filter bar** Row of filter pills below the query bar + * **Time picker** Global time range configurator right to the query bar +* **Data panel** Panel to the left showing the field list + * **Field list** List of fields separated by available and empty fields in the data panel + * **Index pattern chooser** Select element switching between index patterns + * **Field filter** Search and dropdown to filter down the field list + * **Field information popover** Popover showing data distribution; opening when clicking a field in the field list +* **Config panel** Panel to the right showing configuration of the current chart, separated by layers + * **Layer panel** One of multiple panels in the config panel, holding configuration for separate layers + * **Dimension trigger** Chart dimension like "X axis", "Break down by" or "Slice by" in the config panel + * **Dimension popover** Popover shown when clicking a dimension trigger + * **Layer settings popover** Popover shown when clicking the button in the top left of a layer panel +* **Workspace panel** Center panel containing the chart preview, title and toolbar + * **Chart preview** Full-sized rendered chart in the center of the screen + * **Toolbar** Bar on top of the chart preview, containing the chart switcher to the left with chart specific settings right to it + * **Chart switch** Select to change the chart type in the top left above the chart preview + * **Chart settings popover** Popover shown when clicking the "Settings" button above the chart preview +* **Suggestion panel** Panel to the bottom showing previews for suggestions on how to change the current chart + +![Layout](./layout.png "Layout") diff --git a/x-pack/plugins/maps/public/routing/maps_router.js b/x-pack/plugins/maps/public/routing/maps_router.js index 11f7d01549108..9b7900d032f5a 100644 --- a/x-pack/plugins/maps/public/routing/maps_router.js +++ b/x-pack/plugins/maps/public/routing/maps_router.js @@ -7,8 +7,11 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Router, Switch, Route, Redirect } from 'react-router-dom'; -import { getCoreI18n, getEmbeddableService } from '../kibana_services'; -import { createKbnUrlStateStorage } from '../../../../../src/plugins/kibana_utils/public'; +import { getCoreI18n, getToasts, getEmbeddableService } from '../kibana_services'; +import { + createKbnUrlStateStorage, + withNotifyOnErrors, +} from '../../../../../src/plugins/kibana_utils/public'; import { getStore } from './store_operations'; import { Provider } from 'react-redux'; import { LoadListAndRender } from './routes/list/load_list_and_render'; @@ -19,7 +22,11 @@ export let kbnUrlStateStorage; export async function renderApp(context, { appBasePath, element, history, onAppLeave }) { goToSpecifiedPath = (path) => history.push(path); - kbnUrlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + kbnUrlStateStorage = createKbnUrlStateStorage({ + useHash: false, + history, + ...withNotifyOnErrors(getToasts()), + }); render(, element); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx index 86b1c879417bb..14b743997f30a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx @@ -133,7 +133,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } onClose={closeFlyout} hideCloseButton aria-labelledby="analyticsEditFlyoutTitle" - data-test-subj="analyticsEditFlyout" + data-test-subj="mlAnalyticsEditFlyout" > @@ -297,7 +297,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } = ({ coreStart }) => { +export const JobsListPage: FC<{ + coreStart: CoreStart; + history: ManagementAppMountParams['history']; +}> = ({ coreStart, history }) => { const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); @@ -128,46 +137,51 @@ export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { return ( - - - - -

- {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { - defaultMessage: 'Machine Learning Jobs', - })} -

-
- - - {currentTabId === 'anomaly_detection_jobs' - ? anomalyDetectionDocsLabel - : analyticsDocsLabel} - - -
-
- - - - {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { - defaultMessage: 'View machine learning analytics and anomaly detection jobs.', - })} - - - - {renderTabs()} -
+ + + + + +

+ {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { + defaultMessage: 'Machine Learning Jobs', + })} +

+
+ + + {currentTabId === 'anomaly_detection_jobs' + ? anomalyDetectionDocsLabel + : analyticsDocsLabel} + + +
+
+ + + + {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { + defaultMessage: 'View machine learning analytics and anomaly detection jobs.', + })} + + + + {renderTabs()} +
+
); diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 81190a412abc0..afea5a573b8b5 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -14,8 +14,12 @@ import { getJobsListBreadcrumbs } from '../breadcrumbs'; import { setDependencyCache, clearCache } from '../../util/dependency_cache'; import './_index.scss'; -const renderApp = (element: HTMLElement, coreStart: CoreStart) => { - ReactDOM.render(React.createElement(JobsListPage, { coreStart }), element); +const renderApp = ( + element: HTMLElement, + history: ManagementAppMountParams['history'], + coreStart: CoreStart +) => { + ReactDOM.render(React.createElement(JobsListPage, { coreStart, history }), element); return () => { unmountComponentAtNode(element); clearCache(); @@ -37,5 +41,5 @@ export async function mountApp( params.setBreadcrumbs(getJobsListBreadcrumbs()); - return renderApp(params.element, coreStart); + return renderApp(params.element, params.history, coreStart); } diff --git a/x-pack/plugins/monitoring/public/angular/app_modules.ts b/x-pack/plugins/monitoring/public/angular/app_modules.ts index f3d77b196b26e..499610045d771 100644 --- a/x-pack/plugins/monitoring/public/angular/app_modules.ts +++ b/x-pack/plugins/monitoring/public/angular/app_modules.ts @@ -23,7 +23,7 @@ import { GlobalState } from '../url_state'; import { getSafeForExternalLink } from '../lib/get_safe_for_external_link'; // @ts-ignore -import { formatNumber, formatMetric } from '../lib/format_number'; +import { formatMetric, formatNumber } from '../lib/format_number'; // @ts-ignore import { extractIp } from '../lib/extract_ip'; // @ts-ignore @@ -65,7 +65,7 @@ export const localAppModule = ({ createLocalPrivateModule(); createLocalStorage(); createLocalConfigModule(core); - createLocalStateModule(query); + createLocalStateModule(query, core.notifications.toasts); createLocalTopNavModule(navigation); createHrefModule(core); createMonitoringAppServices(); @@ -97,7 +97,10 @@ function createMonitoringAppConfigConstants( keys.map(([key, value]) => (constantsModule = constantsModule.constant(key as string, value))); } -function createLocalStateModule(query: any) { +function createLocalStateModule( + query: MonitoringStartPluginDependencies['data']['query'], + toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts'] +) { angular .module('monitoring/State', ['monitoring/Private']) .service('globalState', function ( @@ -106,7 +109,7 @@ function createLocalStateModule(query: any) { $location: ng.ILocationService ) { function GlobalStateProvider(this: any) { - const state = new GlobalState(query, $rootScope, $location, this); + const state = new GlobalState(query, toasts, $rootScope, $location, this); const initialState: any = state.getState(); for (const key in initialState) { if (!initialState.hasOwnProperty(key)) { diff --git a/x-pack/plugins/monitoring/public/url_state.ts b/x-pack/plugins/monitoring/public/url_state.ts index e53497d751f9b..65e48223d7a64 100644 --- a/x-pack/plugins/monitoring/public/url_state.ts +++ b/x-pack/plugins/monitoring/public/url_state.ts @@ -23,6 +23,7 @@ import { IKbnUrlStateStorage, ISyncStateRef, syncState, + withNotifyOnErrors, } from '../../../../src/plugins/kibana_utils/public'; interface Route { @@ -71,6 +72,7 @@ export class GlobalState { constructor( queryService: MonitoringStartPluginDependencies['data']['query'], + toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts'], rootScope: ng.IRootScopeService, ngLocation: ng.ILocationService, externalState: RawObject @@ -78,7 +80,11 @@ export class GlobalState { this.timefilterRef = queryService.timefilter.timefilter; const history: History = createHashHistory(); - this.stateStorage = createKbnUrlStateStorage({ useHash: false, history }); + this.stateStorage = createKbnUrlStateStorage({ + useHash: false, + history, + ...withNotifyOnErrors(toasts), + }); const initialStateFromUrl = this.stateStorage.get(GLOBAL_STATE_KEY) as MonitoringAppState; diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index 602e4cf2bdd13..cff6726e47df9 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -11,15 +11,12 @@ const allowedConsumers = ['apm', 'uptime', 'logs', 'metrics', 'alerts']; export async function getObservabilityAlerts({ core }: { core: AppMountContext['core'] }) { try { - const { data = [] }: { data: Alert[] } = await core.http.get( - core.http.basePath.prepend('/api/alerts/_find'), - { - query: { - page: 1, - per_page: 20, - }, - } - ); + const { data = [] }: { data: Alert[] } = await core.http.get('/api/alerts/_find', { + query: { + page: 1, + per_page: 20, + }, + }); return data.filter(({ consumer }) => allowedConsumers.includes(consumer)); } catch (e) { diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index c74cf888a2db6..0fc42895050a5 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -140,6 +140,13 @@ export const UNAUTHENTICATED_USER = 'Unauthenticated'; */ export const MINIMUM_ML_LICENSE = 'platinum'; +/* + Machine Learning constants + */ +export const ML_GROUP_ID = 'security'; +export const LEGACY_ML_GROUP_ID = 'siem'; +export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; + /* Rule notifications options */ diff --git a/x-pack/plugins/security_solution/common/endpoint/models/ecs_safety_helpers.ts b/x-pack/plugins/security_solution/common/endpoint/models/ecs_safety_helpers.ts new file mode 100644 index 0000000000000..8b419e90a6ee9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/models/ecs_safety_helpers.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 { ECSField } from '../types'; + +/** + * Use these functions to accecss information held in `ECSField`s. + */ + +/** + * True if the field contains `expected`. If the field contains an array, this will be true if the array contains `expected`. + */ +export function hasValue(valueOrCollection: ECSField, expected: T): boolean { + if (Array.isArray(valueOrCollection)) { + return valueOrCollection.includes(expected); + } else { + return valueOrCollection === expected; + } +} + +/** + * Return first non-null value. If the field contains an array, this will return the first value that isn't null. If the field isn't an array it'll be returned unless it's null. + */ +export function firstNonNullValue(valueOrCollection: ECSField): T | undefined { + if (valueOrCollection === null) { + return undefined; + } else if (Array.isArray(valueOrCollection)) { + for (const value of valueOrCollection) { + if (value !== null) { + return value; + } + } + } else { + return valueOrCollection; + } +} + +/* + * Get an array of all non-null values. If there is just 1 value, return it wrapped in an array. If there are multiple values, return the non-null ones. + * Use this when you want to consistently access the value(s) as an array. + */ +export function values(valueOrCollection: ECSField): T[] { + if (Array.isArray(valueOrCollection)) { + const nonNullValues: T[] = []; + for (const value of valueOrCollection) { + if (value !== null) { + nonNullValues.push(value); + } + } + return nonNullValues; + } else if (valueOrCollection !== null) { + // if there is a single non-null value, wrap it in an array and return it. + return [valueOrCollection]; + } else { + // if the value was null, return `[]`. + return []; + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 1168b5edb6ffd..b1a8524a9f9e7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -3,8 +3,26 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { LegacyEndpointEvent, ResolverEvent } from '../types'; +import { + LegacyEndpointEvent, + ResolverEvent, + SafeResolverEvent, + SafeLegacyEndpointEvent, +} from '../types'; +import { firstNonNullValue } from './ecs_safety_helpers'; +/* + * Determine if a `ResolverEvent` is the legacy variety. Can be used to narrow `ResolverEvent` to `LegacyEndpointEvent`. + */ +export function isLegacyEventSafeVersion( + event: SafeResolverEvent +): event is SafeLegacyEndpointEvent { + return 'endgame' in event && event.endgame !== undefined; +} + +/* + * Determine if a `ResolverEvent` is the legacy variety. Can be used to narrow `ResolverEvent` to `LegacyEndpointEvent`. See `isLegacyEventSafeVersion` + */ export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEvent { return (event as LegacyEndpointEvent).endgame !== undefined; } @@ -31,6 +49,12 @@ export function isProcessRunning(event: ResolverEvent): boolean { ); } +export function timestampSafeVersion(event: SafeResolverEvent): string | undefined | number { + return isLegacyEventSafeVersion(event) + ? firstNonNullValue(event.endgame?.timestamp_utc) + : firstNonNullValue(event?.['@timestamp']); +} + export function eventTimestamp(event: ResolverEvent): string | undefined | number { if (isLegacyEvent(event)) { return event.endgame.timestamp_utc; @@ -47,6 +71,14 @@ export function eventName(event: ResolverEvent): string { } } +export function processNameSafeVersion(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + return firstNonNullValue(event.endgame.process_name); + } else { + return firstNonNullValue(event.process?.name); + } +} + export function eventId(event: ResolverEvent): number | undefined | string { if (isLegacyEvent(event)) { return event.endgame.serial_event_id; @@ -54,6 +86,12 @@ export function eventId(event: ResolverEvent): number | undefined | string { return event.event.id; } +export function eventIDSafeVersion(event: SafeResolverEvent): number | undefined | string { + return firstNonNullValue( + isLegacyEventSafeVersion(event) ? event.endgame?.serial_event_id : event.event?.id + ); +} + export function entityId(event: ResolverEvent): string { if (isLegacyEvent(event)) { return event.endgame.unique_pid ? String(event.endgame.unique_pid) : ''; @@ -61,6 +99,16 @@ export function entityId(event: ResolverEvent): string { return event.process.entity_id; } +export function entityIDSafeVersion(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + return event.endgame?.unique_pid === undefined + ? undefined + : String(firstNonNullValue(event.endgame.unique_pid)); + } else { + return firstNonNullValue(event.process?.entity_id); + } +} + export function parentEntityId(event: ResolverEvent): string | undefined { if (isLegacyEvent(event)) { return event.endgame.unique_ppid ? String(event.endgame.unique_ppid) : undefined; @@ -68,6 +116,13 @@ export function parentEntityId(event: ResolverEvent): string | undefined { return event.process.parent?.entity_id; } +export function parentEntityIDSafeVersion(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + return String(firstNonNullValue(event.endgame.unique_ppid)); + } + return firstNonNullValue(event.process?.parent?.entity_id); +} + export function ancestryArray(event: ResolverEvent): string[] | undefined { if (isLegacyEvent(event)) { return undefined; diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 1c24e1abe5a57..61ce672405fd5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -508,6 +508,8 @@ export interface EndpointEvent { ecs: { version: string; }; + // A legacy has `endgame` and an `EndpointEvent` (AKA ECS event) will never have it. This helps TS narrow `SafeResolverEvent`. + endgame?: never; event: { category: string | string[]; type: string | string[]; @@ -559,6 +561,130 @@ export interface EndpointEvent { export type ResolverEvent = EndpointEvent | LegacyEndpointEvent; +/** + * All mappings in Elasticsearch support arrays. They can also return null values or be missing. For example, a `keyword` mapping could return `null` or `[null]` or `[]` or `'hi'`, or `['hi', 'there']`. We need to handle these cases in order to avoid throwing an error. + * When dealing with an value that comes from ES, wrap the underlying type in `ECSField`. For example, if you have a `keyword` or `text` value coming from ES, cast it to `ECSField`. + */ +export type ECSField = T | null | Array; + +/** + * A more conservative version of `ResolverEvent` that treats fields as optional and use `ECSField` to type all ECS fields. + * Prefer this over `ResolverEvent`. + */ +export type SafeResolverEvent = SafeEndpointEvent | SafeLegacyEndpointEvent; + +/** + * Safer version of ResolverEvent. Please use this going forward. + */ +export type SafeEndpointEvent = Partial<{ + '@timestamp': ECSField; + agent: Partial<{ + id: ECSField; + version: ECSField; + type: ECSField; + }>; + ecs: Partial<{ + version: ECSField; + }>; + event: Partial<{ + category: ECSField; + type: ECSField; + id: ECSField; + kind: ECSField; + }>; + host: Partial<{ + id: ECSField; + hostname: ECSField; + name: ECSField; + ip: ECSField; + mac: ECSField; + architecture: ECSField; + os: Partial<{ + full: ECSField; + name: ECSField; + version: ECSField; + platform: ECSField; + family: ECSField; + Ext: Partial<{ + variant: ECSField; + }>; + }>; + }>; + network: Partial<{ + direction: ECSField; + forwarded_ip: ECSField; + }>; + dns: Partial<{ + question: Partial<{ name: ECSField }>; + }>; + process: Partial<{ + entity_id: ECSField; + name: ECSField; + executable: ECSField; + args: ECSField; + code_signature: Partial<{ + status: ECSField; + subject_name: ECSField; + }>; + pid: ECSField; + hash: Partial<{ + md5: ECSField; + }>; + parent: Partial<{ + entity_id: ECSField; + name: ECSField; + pid: ECSField; + }>; + /* + * The array has a special format. The entity_ids towards the beginning of the array are closer ancestors and the + * values towards the end of the array are more distant ancestors (grandparents). Therefore + * ancestry_array[0] == process.parent.entity_id and ancestry_array[1] == process.parent.parent.entity_id + */ + Ext: Partial<{ + ancestry: ECSField; + }>; + }>; + user: Partial<{ + domain: ECSField; + name: ECSField; + }>; + file: Partial<{ path: ECSField }>; + registry: Partial<{ path: ECSField; key: ECSField }>; +}>; + +export interface SafeLegacyEndpointEvent { + '@timestamp'?: ECSField; + /** + * 'legacy' events must have an `endgame` key. + */ + endgame: Partial<{ + pid: ECSField; + ppid: ECSField; + event_type_full: ECSField; + event_subtype_full: ECSField; + event_timestamp: ECSField; + event_type: ECSField; + unique_pid: ECSField; + unique_ppid: ECSField; + machine_id: ECSField; + process_name: ECSField; + process_path: ECSField; + timestamp_utc: ECSField; + serial_event_id: ECSField; + }>; + agent: Partial<{ + id: ECSField; + type: ECSField; + version: ECSField; + }>; + event: Partial<{ + action: ECSField; + type: ECSField; + category: ECSField; + id: ECSField; + }>; +} + /** * The response body for the resolver '/entity' index API */ diff --git a/x-pack/plugins/security_solution/common/machine_learning/is_security_job.test.ts b/x-pack/plugins/security_solution/common/machine_learning/is_security_job.test.ts new file mode 100644 index 0000000000000..abb0c790584af --- /dev/null +++ b/x-pack/plugins/security_solution/common/machine_learning/is_security_job.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MlSummaryJob } from '../../../ml/common/types/anomaly_detection_jobs'; +import { isSecurityJob } from './is_security_job'; + +describe('isSecurityJob', () => { + it('counts a job with a group of "siem"', () => { + const job = { groups: ['siem', 'other'] } as MlSummaryJob; + expect(isSecurityJob(job)).toEqual(true); + }); + + it('counts a job with a group of "security"', () => { + const job = { groups: ['security', 'other'] } as MlSummaryJob; + expect(isSecurityJob(job)).toEqual(true); + }); + + it('counts a job in both "security" and "siem"', () => { + const job = { groups: ['siem', 'security'] } as MlSummaryJob; + expect(isSecurityJob(job)).toEqual(true); + }); + + it('does not count a job in a related group', () => { + const job = { groups: ['auditbeat', 'process'] } as MlSummaryJob; + expect(isSecurityJob(job)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/common/machine_learning/is_security_job.ts b/x-pack/plugins/security_solution/common/machine_learning/is_security_job.ts new file mode 100644 index 0000000000000..43cfa4ad59964 --- /dev/null +++ b/x-pack/plugins/security_solution/common/machine_learning/is_security_job.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MlSummaryJob } from '../../../ml/common/types/anomaly_detection_jobs'; +import { ML_GROUP_IDS } from '../constants'; + +export const isSecurityJob = (job: MlSummaryJob): boolean => + job.groups.some((group) => ML_GROUP_IDS.includes(group)); diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index 8c7acfc18ece6..c4702e915c076 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import deepEqual from 'fast-deep-equal'; import { isEmpty } from 'lodash/fp'; import { useEffect, useMemo, useState, useRef } from 'react'; @@ -34,7 +35,6 @@ export const useQuery = ({ } return configIndex; }, [configIndex, indexToAdd]); - const [, dispatchToaster] = useStateToaster(); const refetch = useRef(); const [loading, setLoading] = useState(false); @@ -43,20 +43,54 @@ export const useQuery = ({ const [totalCount, setTotalCount] = useState(-1); const apolloClient = useApolloClient(); + const [matrixHistogramVariables, setMatrixHistogramVariables] = useState< + GetMatrixHistogramQuery.Variables + >({ + filterQuery: createFilter(filterQuery), + sourceId: 'default', + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + defaultIndex, + inspect: isInspected, + stackByField, + histogramType, + }); + + useEffect(() => { + setMatrixHistogramVariables((prevVariables) => { + const localVariables = { + filterQuery: createFilter(filterQuery), + sourceId: 'default', + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + defaultIndex, + inspect: isInspected, + stackByField, + histogramType, + }; + if (!deepEqual(prevVariables, localVariables)) { + return localVariables; + } + return prevVariables; + }); + }, [ + defaultIndex, + filterQuery, + histogramType, + indexToAdd, + isInspected, + stackByField, + startDate, + endDate, + ]); + useEffect(() => { - const matrixHistogramVariables: GetMatrixHistogramQuery.Variables = { - filterQuery: createFilter(filterQuery), - sourceId: 'default', - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - defaultIndex, - inspect: isInspected, - stackByField, - histogramType, - }; let isSubscribed = true; const abortCtrl = new AbortController(); const abortSignal = abortCtrl.signal; @@ -102,19 +136,7 @@ export const useQuery = ({ isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - defaultIndex, - errorMessage, - filterQuery, - histogramType, - indexToAdd, - isInspected, - stackByField, - startDate, - endDate, - data, - ]); + }, [apolloClient, dispatchToaster, errorMessage, matrixHistogramVariables]); return { data, loading, inspect, totalCount, refetch: refetch.current }; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index a415ab75f13ea..ab9f12a67fe89 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -8,7 +8,13 @@ import { FilterStateStore } from '../../../../../../src/plugins/data/common/es_q import { TimelineType, TimelineStatus } from '../../../common/types/timeline'; import { OpenTimelineResult } from '../../timelines/components/open_timeline/types'; -import { GetAllTimeline, SortFieldTimeline, TimelineResult, Direction } from '../../graphql/types'; +import { + GetAllTimeline, + SortFieldTimeline, + TimelineResult, + Direction, + DetailItem, +} from '../../graphql/types'; import { allTimelinesQuery } from '../../timelines/containers/all/index.gql_query'; import { CreateTimelineProps } from '../../detections/components/alerts_table/types'; import { TimelineModel } from '../../timelines/store/timeline/model'; @@ -2252,5 +2258,32 @@ export const defaultTimelineProps: CreateTimelineProps = { width: 1100, }, to: '2018-11-05T19:03:25.937Z', + notes: null, ruleNote: '# this is some markdown documentation', }; + +export const mockTimelineDetails: DetailItem[] = [ + { + field: 'host.name', + values: ['apache'], + originalValue: 'apache', + }, + { + field: 'user.id', + values: ['1'], + originalValue: 1, + }, +]; + +export const mockTimelineDetailsApollo = { + data: { + source: { + TimelineDetails: { + data: mockTimelineDetails, + }, + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index c2b51e29c230d..e8015f601cb18 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -3,6 +3,8 @@ * 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/fp'; import sinon from 'sinon'; import moment from 'moment'; @@ -12,6 +14,7 @@ import { defaultTimelineProps, apolloClient, mockTimelineApolloResult, + mockTimelineDetailsApollo, } from '../../../common/mock/'; import { CreateTimeline, UpdateTimelineLoading } from './types'; import { Ecs } from '../../../graphql/types'; @@ -37,7 +40,13 @@ describe('alert actions', () => { createTimeline = jest.fn() as jest.Mocked; updateTimelineIsLoading = jest.fn() as jest.Mocked; - jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResult); + jest.spyOn(apolloClient, 'query').mockImplementation((obj) => { + const id = get('variables.id', obj); + if (id != null) { + return Promise.resolve(mockTimelineApolloResult); + } + return Promise.resolve(mockTimelineDetailsApollo); + }); clock = sinon.useFakeTimers(unix); }); @@ -71,6 +80,7 @@ describe('alert actions', () => { }); const expected = { from: '2018-11-05T18:58:25.937Z', + notes: null, timeline: { columns: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 7bebc9efbee15..34c0537a6d7d2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -19,6 +19,8 @@ import { Ecs, TimelineStatus, TimelineType, + GetTimelineDetailsQuery, + DetailItem, } from '../../../graphql/types'; import { oneTimelineQuery } from '../../../timelines/containers/one/index.gql_query'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -34,6 +36,7 @@ import { } from './helpers'; import { KueryFilterQueryKind } from '../../../common/store'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { timelineDetailsQuery } from '../../../timelines/containers/details/index.gql_query'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { @@ -153,35 +156,45 @@ export const sendAlertToTimelineAction = async ({ if (timelineId !== '' && apolloClient != null) { try { updateTimelineIsLoading({ id: 'timeline-1', isLoading: true }); - const responseTimeline = await apolloClient.query< - GetOneTimeline.Query, - GetOneTimeline.Variables - >({ - query: oneTimelineQuery, - fetchPolicy: 'no-cache', - variables: { - id: timelineId, - }, - }); + const [responseTimeline, eventDataResp] = await Promise.all([ + apolloClient.query({ + query: oneTimelineQuery, + fetchPolicy: 'no-cache', + variables: { + id: timelineId, + }, + }), + apolloClient.query({ + query: timelineDetailsQuery, + fetchPolicy: 'no-cache', + variables: { + defaultIndex: [], + docValueFields: [], + eventId: ecsData._id, + indexName: ecsData._index ?? '', + sourceId: 'default', + }, + }), + ]); const resultingTimeline: TimelineResult = getOr({}, 'data.getOneTimeline', responseTimeline); - + const eventData: DetailItem[] = getOr([], 'data.source.TimelineDetails.data', eventDataResp); if (!isEmpty(resultingTimeline)) { const timelineTemplate: TimelineResult = omitTypenameInTimeline(resultingTimeline); openAlertInBasicTimeline = false; - const { timeline } = formatTimelineResultToModel( + const { timeline, notes } = formatTimelineResultToModel( timelineTemplate, true, timelineTemplate.timelineType ?? TimelineType.default ); const query = replaceTemplateFieldFromQuery( timeline.kqlQuery?.filterQuery?.kuery?.expression ?? '', - ecsData, + eventData, timeline.timelineType ); - const filters = replaceTemplateFieldFromMatchFilters(timeline.filters ?? [], ecsData); + const filters = replaceTemplateFieldFromMatchFilters(timeline.filters ?? [], eventData); const dataProviders = replaceTemplateFieldFromDataProviders( timeline.dataProviders ?? [], - ecsData, + eventData, timeline.timelineType ); @@ -213,10 +226,12 @@ export const sendAlertToTimelineAction = async ({ expression: query, }, }, + noteIds: notes?.map((n) => n.noteId) ?? [], show: true, }, to, ruleNote: noteContent, + notes: notes ?? null, }); } } catch { @@ -232,6 +247,7 @@ export const sendAlertToTimelineAction = async ({ ) { return createTimeline({ from, + notes: null, timeline: { ...timelineDefaults, dataProviders: [ @@ -282,6 +298,7 @@ export const sendAlertToTimelineAction = async ({ } else { return createTimeline({ from, + notes: null, timeline: { ...timelineDefaults, dataProviders: [ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts index 4decddd6b8886..7ac254f2e84f7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.test.ts @@ -3,10 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep } from 'lodash/fp'; import { TimelineType } from '../../../../common/types/timeline'; -import { mockEcsData } from '../../../common/mock/mock_ecs'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { DataProvider, @@ -20,31 +18,40 @@ import { replaceTemplateFieldFromMatchFilters, reformatDataProviderWithNewValue, } from './helpers'; +import { mockTimelineDetails } from '../../../common/mock'; describe('helpers', () => { - let mockEcsDataClone = cloneDeep(mockEcsData); - beforeEach(() => { - mockEcsDataClone = cloneDeep(mockEcsData); - }); describe('getStringOrStringArray', () => { test('it should correctly return a string array', () => { - const value = getStringArray('x', { - x: 'The nickname of the developer we all :heart:', - }); + const value = getStringArray('x', [ + { + field: 'x', + values: ['The nickname of the developer we all :heart:'], + originalValue: 'The nickname of the developer we all :heart:', + }, + ]); expect(value).toEqual(['The nickname of the developer we all :heart:']); }); test('it should correctly return a string array with a single element', () => { - const value = getStringArray('x', { - x: ['The nickname of the developer we all :heart:'], - }); + const value = getStringArray('x', [ + { + field: 'x', + values: ['The nickname of the developer we all :heart:'], + originalValue: 'The nickname of the developer we all :heart:', + }, + ]); expect(value).toEqual(['The nickname of the developer we all :heart:']); }); test('it should correctly return a string array with two elements of strings', () => { - const value = getStringArray('x', { - x: ['The nickname of the developer we all :heart:', 'We are all made of stars'], - }); + const value = getStringArray('x', [ + { + field: 'x', + values: ['The nickname of the developer we all :heart:', 'We are all made of stars'], + originalValue: 'The nickname of the developer we all :heart:', + }, + ]); expect(value).toEqual([ 'The nickname of the developer we all :heart:', 'We are all made of stars', @@ -52,22 +59,40 @@ describe('helpers', () => { }); test('it should correctly return a string array with deep elements', () => { - const value = getStringArray('x.y.z', { - x: { y: { z: 'zed' } }, - }); + const value = getStringArray('x.y.z', [ + { + field: 'x.y.z', + values: ['zed'], + originalValue: 'zed', + }, + ]); expect(value).toEqual(['zed']); }); test('it should correctly return a string array with a non-existent value', () => { - const value = getStringArray('non.existent', { - x: { y: { z: 'zed' } }, - }); + const value = getStringArray('non.existent', [ + { + field: 'x.y.z', + values: ['zed'], + originalValue: 'zed', + }, + ]); expect(value).toEqual([]); }); test('it should trace an error if the value is not a string', () => { const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console; - const value = getStringArray('a', { a: 5 }, mockConsole); + const value = getStringArray( + 'a', + [ + { + field: 'a', + values: (5 as unknown) as string[], + originalValue: 'zed', + }, + ], + mockConsole + ); expect(value).toEqual([]); expect( mockConsole.trace @@ -77,13 +102,23 @@ describe('helpers', () => { 'when trying to access field:', 'a', 'from data object of:', - { a: 5 } + [{ field: 'a', originalValue: 'zed', values: 5 }] ); }); test('it should trace an error if the value is an array of mixed values', () => { const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console; - const value = getStringArray('a', { a: ['hi', 5] }, mockConsole); + const value = getStringArray( + 'a', + [ + { + field: 'a', + values: (['hi', 5] as unknown) as string[], + originalValue: 'zed', + }, + ], + mockConsole + ); expect(value).toEqual([]); expect( mockConsole.trace @@ -93,7 +128,7 @@ describe('helpers', () => { 'when trying to access field:', 'a', 'from data object of:', - { a: ['hi', 5] } + [{ field: 'a', originalValue: 'zed', values: ['hi', 5] }] ); }); }); @@ -103,7 +138,7 @@ describe('helpers', () => { test('given an empty query string this returns an empty query string', () => { const replacement = replaceTemplateFieldFromQuery( '', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual(''); @@ -112,7 +147,7 @@ describe('helpers', () => { test('given a query string with spaces this returns an empty query string', () => { const replacement = replaceTemplateFieldFromQuery( ' ', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual(''); @@ -121,17 +156,21 @@ describe('helpers', () => { test('it should replace a query with a template value such as apache from a mock template', () => { const replacement = replaceTemplateFieldFromQuery( 'host.name: placeholdertext', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual('host.name: apache'); }); test('it should replace a template field with an ECS value that is not an array', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const dupTimelineDetails = [...mockTimelineDetails]; + dupTimelineDetails[0] = { + ...dupTimelineDetails[0], + values: ('apache' as unknown) as string[], + }; // very unsafe cast for this test case const replacement = replaceTemplateFieldFromQuery( 'host.name: *', - mockEcsDataClone[0], + dupTimelineDetails, TimelineType.default ); expect(replacement).toEqual('host.name: *'); @@ -140,7 +179,7 @@ describe('helpers', () => { test('it should NOT replace a query with a template value that is not part of the template fields array', () => { const replacement = replaceTemplateFieldFromQuery( 'user.id: placeholdertext', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual('user.id: placeholdertext'); @@ -151,7 +190,7 @@ describe('helpers', () => { test('given an empty query string this returns an empty query string', () => { const replacement = replaceTemplateFieldFromQuery( '', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual(''); @@ -160,7 +199,7 @@ describe('helpers', () => { test('given a query string with spaces this returns an empty query string', () => { const replacement = replaceTemplateFieldFromQuery( ' ', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual(''); @@ -169,17 +208,21 @@ describe('helpers', () => { test('it should NOT replace a query with a template value such as apache from a mock template', () => { const replacement = replaceTemplateFieldFromQuery( 'host.name: placeholdertext', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual('host.name: placeholdertext'); }); test('it should NOT replace a template field with an ECS value that is not an array', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const dupTimelineDetails = [...mockTimelineDetails]; + dupTimelineDetails[0] = { + ...dupTimelineDetails[0], + values: ('apache' as unknown) as string[], + }; // very unsafe cast for this test case const replacement = replaceTemplateFieldFromQuery( 'host.name: *', - mockEcsDataClone[0], + dupTimelineDetails, TimelineType.default ); expect(replacement).toEqual('host.name: *'); @@ -188,7 +231,7 @@ describe('helpers', () => { test('it should NOT replace a query with a template value that is not part of the template fields array', () => { const replacement = replaceTemplateFieldFromQuery( 'user.id: placeholdertext', - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual('user.id: placeholdertext'); @@ -198,7 +241,7 @@ describe('helpers', () => { describe('replaceTemplateFieldFromMatchFilters', () => { test('given an empty query filter this will return an empty filter', () => { - const replacement = replaceTemplateFieldFromMatchFilters([], mockEcsDataClone[0]); + const replacement = replaceTemplateFieldFromMatchFilters([], mockTimelineDetails); expect(replacement).toEqual([]); }); @@ -216,7 +259,7 @@ describe('helpers', () => { query: { match_phrase: { 'host.name': 'Braden' } }, }, ]; - const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]); + const replacement = replaceTemplateFieldFromMatchFilters(filters, mockTimelineDetails); const expected: Filter[] = [ { meta: { @@ -247,7 +290,7 @@ describe('helpers', () => { query: { match_phrase: { 'user.id': 'Evan' } }, }, ]; - const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]); + const replacement = replaceTemplateFieldFromMatchFilters(filters, mockTimelineDetails); const expected: Filter[] = [ { meta: { @@ -275,7 +318,7 @@ describe('helpers', () => { mockDataProvider.queryMatch.value = 'Braden'; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual({ @@ -297,7 +340,11 @@ describe('helpers', () => { }); test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const dupTimelineDetails = [...mockTimelineDetails]; + dupTimelineDetails[0] = { + ...dupTimelineDetails[0], + values: ('apache' as unknown) as string[], + }; // very unsafe cast for this test case const mockDataProvider: DataProvider = mockDataProviders[0]; mockDataProvider.queryMatch.field = 'host.name'; mockDataProvider.id = 'Braden'; @@ -305,7 +352,7 @@ describe('helpers', () => { mockDataProvider.queryMatch.value = 'Braden'; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + dupTimelineDetails, TimelineType.default ); expect(replacement).toEqual({ @@ -334,7 +381,7 @@ describe('helpers', () => { mockDataProvider.queryMatch.value = 'Rebecca'; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.default ); expect(replacement).toEqual({ @@ -366,7 +413,7 @@ describe('helpers', () => { mockDataProvider.type = DataProviderType.template; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual({ @@ -396,7 +443,7 @@ describe('helpers', () => { mockDataProvider.type = DataProviderType.default; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual({ @@ -418,7 +465,11 @@ describe('helpers', () => { }); test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { - mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const dupTimelineDetails = [...mockTimelineDetails]; + dupTimelineDetails[0] = { + ...dupTimelineDetails[0], + values: ('apache' as unknown) as string[], + }; // very unsafe cast for this test case const mockDataProvider: DataProvider = mockDataProviders[0]; mockDataProvider.queryMatch.field = 'host.name'; mockDataProvider.id = 'Braden'; @@ -427,7 +478,7 @@ describe('helpers', () => { mockDataProvider.type = DataProviderType.template; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + dupTimelineDetails, TimelineType.template ); expect(replacement).toEqual({ @@ -457,7 +508,7 @@ describe('helpers', () => { mockDataProvider.type = DataProviderType.default; const replacement = reformatDataProviderWithNewValue( mockDataProvider, - mockEcsDataClone[0], + mockTimelineDetails, TimelineType.template ); expect(replacement).toEqual({ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts index 084e4bff7e0ac..20c233a03a8cf 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/helpers.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, isEmpty } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { Filter, esKuery, KueryNode } from '../../../../../../../src/plugins/data/public'; import { DataProvider, DataProviderType, DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { Ecs, TimelineType } from '../../../graphql/types'; +import { DetailItem, TimelineType } from '../../../graphql/types'; interface FindValueToChangeInQuery { field: string; @@ -47,8 +47,12 @@ const templateFields = [ * @param data The unknown data that is typically a ECS value to get the value * @param localConsole The local console which can be sent in to make this pure (for tests) or use the default console */ -export const getStringArray = (field: string, data: unknown, localConsole = console): string[] => { - const value: unknown | undefined = get(field, data); +export const getStringArray = ( + field: string, + data: DetailItem[], + localConsole = console +): string[] => { + const value: unknown | undefined = data.find((d) => d.field === field)?.values ?? null; if (value == null) { return []; } else if (typeof value === 'string') { @@ -104,14 +108,14 @@ export const findValueToChangeInQuery = ( export const replaceTemplateFieldFromQuery = ( query: string, - ecsData: Ecs, + eventData: DetailItem[], timelineType: TimelineType = TimelineType.default ): string => { if (timelineType === TimelineType.default) { if (query.trim() !== '') { const valueToChange = findValueToChangeInQuery(esKuery.fromKueryExpression(query)); return valueToChange.reduce((newQuery, vtc) => { - const newValue = getStringArray(vtc.field, ecsData); + const newValue = getStringArray(vtc.field, eventData); if (newValue.length) { return newQuery.replace(vtc.valueToChange, newValue[0]); } else { @@ -126,14 +130,17 @@ export const replaceTemplateFieldFromQuery = ( return query.trim(); }; -export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs): Filter[] => +export const replaceTemplateFieldFromMatchFilters = ( + filters: Filter[], + eventData: DetailItem[] +): Filter[] => filters.map((filter) => { if ( filter.meta.type === 'phrase' && filter.meta.key != null && templateFields.includes(filter.meta.key) ) { - const newValue = getStringArray(filter.meta.key, ecsData); + const newValue = getStringArray(filter.meta.key, eventData); if (newValue.length) { filter.meta.params = { query: newValue[0] }; filter.query = { match_phrase: { [filter.meta.key]: newValue[0] } }; @@ -144,13 +151,13 @@ export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: export const reformatDataProviderWithNewValue = ( dataProvider: T, - ecsData: Ecs, + eventData: DetailItem[], timelineType: TimelineType = TimelineType.default ): T => { // Support for legacy "template-like" timeline behavior that is using hardcoded list of templateFields if (timelineType !== TimelineType.template) { if (templateFields.includes(dataProvider.queryMatch.field)) { - const newValue = getStringArray(dataProvider.queryMatch.field, ecsData); + const newValue = getStringArray(dataProvider.queryMatch.field, eventData); if (newValue.length) { dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue[0]); dataProvider.name = newValue[0]; @@ -168,7 +175,7 @@ export const reformatDataProviderWithNewValue = dataProviders.map((dataProvider) => { - const newDataProvider = reformatDataProviderWithNewValue(dataProvider, ecsData, timelineType); + const newDataProvider = reformatDataProviderWithNewValue(dataProvider, eventData, timelineType); if (newDataProvider.and != null && !isEmpty(newDataProvider.and)) { newDataProvider.and = newDataProvider.and.map((andDataProvider) => - reformatDataProviderWithNewValue(andDataProvider, ecsData, timelineType) + reformatDataProviderWithNewValue(andDataProvider, eventData, timelineType) ); } return newDataProvider; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index d93bad29f3348..66423259ec155 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -147,13 +147,14 @@ export const AlertsTableComponent: React.FC = ({ // Callback for creating a new timeline -- utilized by row/batch actions const createTimelineCallback = useCallback( - ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { + ({ from: fromTimeline, timeline, to: toTimeline, ruleNote, notes }: CreateTimelineProps) => { updateTimelineIsLoading({ id: 'timeline-1', isLoading: false }); updateTimeline({ duplicate: true, + forceNotes: true, from: fromTimeline, id: 'timeline-1', - notes: [], + notes, timeline: { ...timeline, show: true, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index ebf1a6d3ed533..2e77e77f6b3d5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -7,7 +7,7 @@ import ApolloClient from 'apollo-client'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; -import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; +import { Ecs, NoteResult, TimelineNonEcsData } from '../../../graphql/types'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { inputsModel } from '../../../common/store'; @@ -63,6 +63,7 @@ export interface CreateTimelineProps { from: string; timeline: TimelineModel; to: string; + notes: NoteResult[] | null; ruleNote?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index b22ff406a1605..69dabeeb616a0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -70,7 +70,12 @@ export const HostDetailsFlyout = memo(() => { }, [error, toasts]); return ( - +

diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts index be0bc1b812a0b..94c176d343d17 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts @@ -10,7 +10,10 @@ import { ResolverEntityIndex, } from '../../../../common/endpoint/types'; import { mockEndpointEvent } from '../../store/mocks/endpoint_event'; -import { mockTreeWithNoAncestorsAnd2Children } from '../../store/mocks/resolver_tree'; +import { + mockTreeWithNoAncestorsAnd2Children, + withRelatedEventsOnOrigin, +} from '../../store/mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; interface Metadata { @@ -40,11 +43,24 @@ interface Metadata { /** * A simple mock dataAccessLayer possible that returns a tree with 0 ancestors and 2 direct children. 1 related event is returned. The parameter to `entities` is ignored. */ -export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { +export function oneAncestorTwoChildren( + { withRelatedEvents }: { withRelatedEvents: Iterable<[string, string]> | null } = { + withRelatedEvents: null, + } +): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { const metadata: Metadata = { databaseDocumentID: '_id', entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, }; + const baseTree = mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }); + const composedTree = withRelatedEvents + ? withRelatedEventsOnOrigin(baseTree, withRelatedEvents) + : baseTree; + return { metadata, dataAccessLayer: { @@ -54,13 +70,17 @@ export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; me relatedEvents(entityID: string): Promise { return Promise.resolve({ entityID, - events: [ - mockEndpointEvent({ - entityID, - name: 'event', - timestamp: 0, - }), - ], + events: + /* Respond with the mocked related events when the origin's related events are fetched*/ withRelatedEvents && + entityID === metadata.entityIDs.origin + ? composedTree.relatedEvents.events + : [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], nextEvent: null, }); }, @@ -69,13 +89,7 @@ export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; me * Fetch a ResolverTree for a entityID */ resolverTree(): Promise { - return Promise.resolve( - mockTreeWithNoAncestorsAnd2Children({ - originID: metadata.entityIDs.origin, - firstChildID: metadata.entityIDs.firstChild, - secondChildID: metadata.entityIDs.secondChild, - }) - ); + return Promise.resolve(composedTree); }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index 6f26bfe063c05..db8d047c2ce86 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -182,7 +182,7 @@ Object { "edgeLineSegments": Array [ Object { "metadata": Object { - "uniqueId": "parentToMid", + "uniqueId": "parentToMidedge:0:1", }, "points": Array [ Array [ @@ -197,7 +197,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midway", + "uniqueId": "midwayedge:0:1", }, "points": Array [ Array [ @@ -212,7 +212,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "", + "uniqueId": "edge:0:1", }, "points": Array [ Array [ @@ -227,7 +227,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "", + "uniqueId": "edge:0:2", }, "points": Array [ Array [ @@ -242,7 +242,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "", + "uniqueId": "edge:0:8", }, "points": Array [ Array [ @@ -257,7 +257,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "parentToMid13", + "uniqueId": "parentToMidedge:1:3", }, "points": Array [ Array [ @@ -272,7 +272,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midway13", + "uniqueId": "midwayedge:1:3", }, "points": Array [ Array [ @@ -287,7 +287,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "13", + "uniqueId": "edge:1:3", }, "points": Array [ Array [ @@ -302,7 +302,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "14", + "uniqueId": "edge:1:4", }, "points": Array [ Array [ @@ -317,7 +317,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "parentToMid25", + "uniqueId": "parentToMidedge:2:5", }, "points": Array [ Array [ @@ -332,7 +332,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midway25", + "uniqueId": "midwayedge:2:5", }, "points": Array [ Array [ @@ -347,7 +347,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "25", + "uniqueId": "edge:2:5", }, "points": Array [ Array [ @@ -362,7 +362,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "26", + "uniqueId": "edge:2:6", }, "points": Array [ Array [ @@ -377,7 +377,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "67", + "uniqueId": "edge:6:7", }, "points": Array [ Array [ @@ -584,7 +584,7 @@ Object { "edgeLineSegments": Array [ Object { "metadata": Object { - "uniqueId": "", + "uniqueId": "edge:0:1", }, "points": Array [ Array [ diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index 060a014b8730f..f6b893ba25b78 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event'; +import { orderByTime } from '../process_event'; import { IndexedProcessTree } from '../../types'; -import { ResolverEvent } from '../../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; +import * as eventModel from '../../../../common/endpoint/models/event'; /** * Create a new IndexedProcessTree from an array of ProcessEvents. @@ -15,24 +16,25 @@ import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; */ export function factory( // Array of processes to index as a tree - processes: ResolverEvent[] + processes: SafeResolverEvent[] ): IndexedProcessTree { - const idToChildren = new Map(); - const idToValue = new Map(); + const idToChildren = new Map(); + const idToValue = new Map(); for (const process of processes) { - const uniqueProcessPid = uniquePidForProcess(process); - idToValue.set(uniqueProcessPid, process); + const entityID: string | undefined = eventModel.entityIDSafeVersion(process); + if (entityID !== undefined) { + idToValue.set(entityID, process); - // NB: If the value was null or undefined, use `undefined` - const uniqueParentPid: string | undefined = uniqueParentPidForProcess(process) ?? undefined; + const uniqueParentPid: string | undefined = eventModel.parentEntityIDSafeVersion(process); - let childrenWithTheSameParent = idToChildren.get(uniqueParentPid); - if (!childrenWithTheSameParent) { - childrenWithTheSameParent = []; - idToChildren.set(uniqueParentPid, childrenWithTheSameParent); + let childrenWithTheSameParent = idToChildren.get(uniqueParentPid); + if (!childrenWithTheSameParent) { + childrenWithTheSameParent = []; + idToChildren.set(uniqueParentPid, childrenWithTheSameParent); + } + childrenWithTheSameParent.push(process); } - childrenWithTheSameParent.push(process); } // sort the children of each node @@ -49,7 +51,10 @@ export function factory( /** * Returns an array with any children `ProcessEvent`s of the passed in `process` */ -export function children(tree: IndexedProcessTree, parentID: string | undefined): ResolverEvent[] { +export function children( + tree: IndexedProcessTree, + parentID: string | undefined +): SafeResolverEvent[] { const currentProcessSiblings = tree.idToChildren.get(parentID); return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } @@ -57,7 +62,7 @@ export function children(tree: IndexedProcessTree, parentID: string | undefined) /** * Get the indexed process event for the ID */ -export function processEvent(tree: IndexedProcessTree, entityID: string): ResolverEvent | null { +export function processEvent(tree: IndexedProcessTree, entityID: string): SafeResolverEvent | null { return tree.idToProcess.get(entityID) ?? null; } @@ -66,9 +71,9 @@ export function processEvent(tree: IndexedProcessTree, entityID: string): Resolv */ export function parent( tree: IndexedProcessTree, - childProcess: ResolverEvent -): ResolverEvent | undefined { - const uniqueParentPid = uniqueParentPidForProcess(childProcess); + childProcess: SafeResolverEvent +): SafeResolverEvent | undefined { + const uniqueParentPid = eventModel.parentEntityIDSafeVersion(childProcess); if (uniqueParentPid === undefined) { return undefined; } else { @@ -91,7 +96,7 @@ export function root(tree: IndexedProcessTree) { return null; } // any node will do - let current: ResolverEvent = tree.idToProcess.values().next().value; + let current: SafeResolverEvent = tree.idToProcess.values().next().value; // iteratively swap current w/ its parent while (parent(tree, current) !== undefined) { @@ -106,8 +111,8 @@ export function root(tree: IndexedProcessTree) { export function* levelOrder(tree: IndexedProcessTree) { const rootNode = root(tree); if (rootNode !== null) { - yield* baseLevelOrder(rootNode, (parentNode: ResolverEvent): ResolverEvent[] => - children(tree, uniquePidForProcess(parentNode)) + yield* baseLevelOrder(rootNode, (parentNode: SafeResolverEvent): SafeResolverEvent[] => + children(tree, eventModel.entityIDSafeVersion(parentNode)) ); } } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 1fc2ea0150aee..f0880fa635a24 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -14,12 +14,11 @@ import { Matrix3, IsometricTaxiLayout, } from '../../types'; -import * as event from '../../../../common/endpoint/models/event'; -import { ResolverEvent } from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; import * as vector2 from '../vector2'; import * as indexedProcessTreeModel from './index'; import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date'; -import { uniquePidForProcess } from '../process_event'; /** * Graph the process tree @@ -30,25 +29,29 @@ export function isometricTaxiLayoutFactory( /** * Walk the tree in reverse level order, calculating the 'width' of subtrees. */ - const widths = widthsOfProcessSubtrees(indexedProcessTree); + const widths: Map = widthsOfProcessSubtrees(indexedProcessTree); /** * Walk the tree in level order. Using the precalculated widths, calculate the position of nodes. * Nodes are positioned relative to their parents and preceding siblings. */ - const positions = processPositions(indexedProcessTree, widths); + const positions: Map = processPositions(indexedProcessTree, widths); /** * With the widths and positions precalculated, we calculate edge line segments (arrays of vector2s) * which connect them in a 'pitchfork' design. */ - const edgeLineSegments = processEdgeLineSegments(indexedProcessTree, widths, positions); + const edgeLineSegments: EdgeLineSegment[] = processEdgeLineSegments( + indexedProcessTree, + widths, + positions + ); /** * Transform the positions of nodes and edges so they seem like they are on an isometric grid. */ const transformedEdgeLineSegments: EdgeLineSegment[] = []; - const transformedPositions = new Map(); + const transformedPositions = new Map(); for (const [processEvent, position] of positions) { transformedPositions.set( @@ -83,8 +86,8 @@ export function isometricTaxiLayoutFactory( /** * Calculate a level (starting at 1) for each node. */ -function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { - const map: Map = new Map(); +function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { + const map: Map = new Map(); for (const node of indexedProcessTreeModel.levelOrder(indexedProcessTree)) { const parentNode = indexedProcessTreeModel.parent(indexedProcessTree, node); if (parentNode === undefined) { @@ -143,20 +146,20 @@ function ariaLevels(indexedProcessTree: IndexedProcessTree): Map(); + const widths = new Map(); if (indexedProcessTreeModel.size(indexedProcessTree) === 0) { return widths; } - const processesInReverseLevelOrder: ResolverEvent[] = [ + const processesInReverseLevelOrder: SafeResolverEvent[] = [ ...indexedProcessTreeModel.levelOrder(indexedProcessTree), ].reverse(); for (const process of processesInReverseLevelOrder) { const children = indexedProcessTreeModel.children( indexedProcessTree, - uniquePidForProcess(process) + eventModel.entityIDSafeVersion(process) ); const sumOfWidthOfChildren = function sumOfWidthOfChildren() { @@ -167,7 +170,7 @@ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): Proces * Therefore a parent can always find a width for its children, since all of its children * will have been handled already. */ - return currentValue + widths.get(child)!; + return currentValue + (widths.get(child) ?? 0); }, 0); }; @@ -178,6 +181,9 @@ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): Proces return widths; } +/** + * Layout the graph. Note: if any process events are missing the `entity_id`, this will throw an Error. + */ function processEdgeLineSegments( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths, @@ -196,9 +202,13 @@ function processEdgeLineSegments( const { process, parent, parentWidth } = metadata; const position = positions.get(process); const parentPosition = positions.get(parent); - const parentId = event.entityId(parent); - const processEntityId = event.entityId(process); - const edgeLineId = parentId ? parentId + processEntityId : parentId; + const parentID = eventModel.entityIDSafeVersion(parent); + const processEntityID = eventModel.entityIDSafeVersion(process); + + if (processEntityID === undefined) { + throw new Error('tried to graph a Resolver that had a process with no `process.entity_id`'); + } + const edgeLineID = `edge:${parentID ?? 'undefined'}:${processEntityID}`; if (position === undefined || parentPosition === undefined) { /** @@ -207,12 +217,12 @@ function processEdgeLineSegments( throw new Error(); } - const parentTime = event.eventTimestamp(parent); - const processTime = event.eventTimestamp(process); + const parentTime = eventModel.timestampSafeVersion(parent); + const processTime = eventModel.timestampSafeVersion(process); if (parentTime && processTime) { edgeLineMetadata.elapsedTime = elapsedTime(parentTime, processTime) ?? undefined; } - edgeLineMetadata.uniqueId = edgeLineId; + edgeLineMetadata.uniqueId = edgeLineID; /** * The point halfway between the parent and child on the y axis, we sometimes have a hard angle here in the edge line @@ -236,7 +246,7 @@ function processEdgeLineSegments( const siblings = indexedProcessTreeModel.children( indexedProcessTree, - uniquePidForProcess(parent) + eventModel.entityIDSafeVersion(parent) ); const isFirstChild = process === siblings[0]; @@ -260,7 +270,7 @@ function processEdgeLineSegments( const lineFromParentToMidwayLine: EdgeLineSegment = { points: [parentPosition, [parentPosition[0], midwayY]], - metadata: { uniqueId: `parentToMid${edgeLineId}` }, + metadata: { uniqueId: `parentToMid${edgeLineID}` }, }; const widthOfMidline = parentWidth - firstChildWidth / 2 - lastChildWidth / 2; @@ -281,7 +291,7 @@ function processEdgeLineSegments( midwayY, ], ], - metadata: { uniqueId: `midway${edgeLineId}` }, + metadata: { uniqueId: `midway${edgeLineID}` }, }; edgeLineSegments.push( @@ -303,13 +313,13 @@ function processPositions( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths ): ProcessPositions { - const positions = new Map(); + const positions = new Map(); /** * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and * reset the counters. */ - let lastProcessedParentNode: ResolverEvent | undefined; + let lastProcessedParentNode: SafeResolverEvent | undefined; /** * Nodes are positioned relative to their siblings. We walk this in level order, so we handle * children left -> right. @@ -431,7 +441,10 @@ function* levelOrderWithWidths( parentWidth, }; - const siblings = indexedProcessTreeModel.children(tree, uniquePidForProcess(parent)); + const siblings = indexedProcessTreeModel.children( + tree, + eventModel.entityIDSafeVersion(parent) + ); if (siblings.length === 1) { metadata.isOnlyChild = true; metadata.lastChildWidth = width; @@ -488,7 +501,10 @@ const distanceBetweenNodesInUnits = 2; */ const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; -export function nodePosition(model: IsometricTaxiLayout, node: ResolverEvent): Vector2 | undefined { +export function nodePosition( + model: IsometricTaxiLayout, + node: SafeResolverEvent +): Vector2 | undefined { return model.processNodePositions.get(node); } diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts index 4b1d555d0a7c3..4d48b34fb2841 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts @@ -6,7 +6,11 @@ import { eventType, orderByTime, userInfoForProcess } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types'; +import { + LegacyEndpointEvent, + ResolverEvent, + SafeResolverEvent, +} from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -42,7 +46,7 @@ describe('process event', () => { }); describe('orderByTime', () => { let mock: (time: number, eventID: string) => ResolverEvent; - let events: ResolverEvent[]; + let events: SafeResolverEvent[]; beforeEach(() => { mock = (time, eventID) => { return { @@ -56,14 +60,14 @@ describe('process event', () => { // each event has a unique id, a through h // order is arbitrary events = [ - mock(-1, 'a'), - mock(0, 'c'), - mock(1, 'e'), - mock(NaN, 'g'), - mock(-1, 'b'), - mock(0, 'd'), - mock(1, 'f'), - mock(NaN, 'h'), + mock(-1, 'a') as SafeResolverEvent, + mock(0, 'c') as SafeResolverEvent, + mock(1, 'e') as SafeResolverEvent, + mock(NaN, 'g') as SafeResolverEvent, + mock(-1, 'b') as SafeResolverEvent, + mock(0, 'd') as SafeResolverEvent, + mock(1, 'f') as SafeResolverEvent, + mock(NaN, 'h') as SafeResolverEvent, ]; }); it('sorts events as expected', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 1a5c67f6a6f2f..ea588731a55c8 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -5,7 +5,7 @@ */ import * as event from '../../../common/endpoint/models/event'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; import { ResolverProcessType } from '../types'; /** @@ -32,8 +32,8 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { * ms since Unix epoc, based on timestamp. * may return NaN if the timestamp wasn't present or was invalid. */ -export function datetime(passedEvent: ResolverEvent): number | null { - const timestamp = event.eventTimestamp(passedEvent); +export function datetime(passedEvent: SafeResolverEvent): number | null { + const timestamp = event.timestampSafeVersion(passedEvent); const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); @@ -178,13 +178,15 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { /** * used to sort events */ -export function orderByTime(first: ResolverEvent, second: ResolverEvent): number { +export function orderByTime(first: SafeResolverEvent, second: SafeResolverEvent): number { const firstDatetime: number | null = datetime(first); const secondDatetime: number | null = datetime(second); if (firstDatetime === secondDatetime) { // break ties using an arbitrary (stable) comparison of `eventId` (which should be unique) - return String(event.eventId(first)).localeCompare(String(event.eventId(second))); + return String(event.eventIDSafeVersion(first)).localeCompare( + String(event.eventIDSafeVersion(second)) + ); } else if (firstDatetime === null || secondDatetime === null) { // sort `null`'s as higher than numbers return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0); diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index 418eb0d837276..29c03215e9ff4 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { CameraAction } from './camera'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; import { DataAction } from './data/action'; /** @@ -96,7 +96,7 @@ interface UserSelectedResolverNode { interface UserSelectedRelatedEventCategory { readonly type: 'userSelectedRelatedEventCategory'; readonly payload: { - subject: ResolverEvent; + subject: SafeResolverEvent; category?: string; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 272d0aae7eef4..569a24bb8537e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -28,10 +28,11 @@ import { ResolverTree, ResolverNodeStats, ResolverRelatedEvents, + SafeResolverEvent, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; -import { allEventCategories } from '../../../../common/endpoint/models/event'; +import * as eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; /** @@ -145,7 +146,7 @@ export const tree = createSelector(graphableProcesses, function indexedTree( graphableProcesses /* eslint-enable no-shadow */ ) { - return indexedProcessTreeModel.factory(graphableProcesses); + return indexedProcessTreeModel.factory(graphableProcesses as SafeResolverEvent[]); }); /** @@ -194,7 +195,9 @@ export const relatedEventsByCategory: ( } return relatedById.events.reduce( (eventsByCategory: ResolverEvent[], candidate: ResolverEvent) => { - if ([candidate && allEventCategories(candidate)].flat().includes(ecsCategory)) { + if ( + [candidate && eventModel.allEventCategories(candidate)].flat().includes(ecsCategory) + ) { eventsByCategory.push(candidate); } return eventsByCategory; @@ -280,7 +283,7 @@ export const relatedEventInfoByEntityId: ( return []; } return eventsResponseForThisEntry.events.filter((resolverEvent) => { - for (const category of [allEventCategories(resolverEvent)].flat()) { + for (const category of [eventModel.allEventCategories(resolverEvent)].flat()) { if (category === eventCategory) { return true; } @@ -404,7 +407,7 @@ export const processEventForID: ( ) => (nodeID: string) => ResolverEvent | null = createSelector( tree, (indexedProcessTree) => (nodeID: string) => - indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID) + indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID) as ResolverEvent ); /** @@ -415,7 +418,7 @@ export const ariaLevel: (state: DataState) => (nodeID: string) => number | null processEventForID, ({ ariaLevels }, processEventGetter) => (nodeID: string) => { const node = processEventGetter(nodeID); - return node ? ariaLevels.get(node) ?? null : null; + return node ? ariaLevels.get(node as SafeResolverEvent) ?? null : null; } ); @@ -468,10 +471,10 @@ export const ariaFlowtoCandidate: ( for (const child of children) { if (previousChild !== null) { // Set the `child` as the following sibling of `previousChild`. - memo.set(uniquePidForProcess(previousChild), uniquePidForProcess(child)); + memo.set(uniquePidForProcess(previousChild), uniquePidForProcess(child as ResolverEvent)); } // Set the child as the previous child. - previousChild = child; + previousChild = child as ResolverEvent; } if (previousChild) { @@ -553,7 +556,7 @@ export const nodesAndEdgelines: ( maxX, maxY, }); - const visibleProcessNodePositions = new Map( + const visibleProcessNodePositions = new Map( entities .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') .map((node) => [node.entity, node.position]) diff --git a/x-pack/plugins/security_solution/public/resolver/store/methods.ts b/x-pack/plugins/security_solution/public/resolver/store/methods.ts index ad06ddf36161a..8dd15b1a44d0c 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/methods.ts @@ -7,7 +7,7 @@ import { animatePanning } from './camera/methods'; import { layout } from './selectors'; import { ResolverState } from '../types'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; const animationDuration = 1000; @@ -20,7 +20,7 @@ export function animateProcessIntoView( process: ResolverEvent ): ResolverState { const { processNodePositions } = layout(state); - const position = processNodePositions.get(process); + const position = processNodePositions.get(process as SafeResolverEvent); if (position) { return { ...state, diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts new file mode 100644 index 0000000000000..1e0c460a3a711 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts @@ -0,0 +1,36 @@ +/* + * 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 { EndpointEvent } from '../../../../common/endpoint/types'; + +/** + * Simple mock related event. + */ +export function mockRelatedEvent({ + entityID, + timestamp, + category, + type, + id, +}: { + entityID: string; + timestamp: number; + category: string; + type: string; + id?: string; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + kind: 'event', + type, + category, + id: id ?? 'xyz', + }, + process: { + entity_id: entityID, + }, + } as EndpointEvent; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts index 6a8ab61ccf9b6..21d0309501aa8 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -5,6 +5,7 @@ */ import { mockEndpointEvent } from './endpoint_event'; +import { mockRelatedEvent } from './related_event'; import { ResolverTree, ResolverEvent } from '../../../../common/endpoint/types'; export function mockTreeWith2AncestorsAndNoChildren({ @@ -109,6 +110,58 @@ export function mockTreeWithAllProcessesTerminated({ } as unknown) as ResolverTree; } +/** + * A valid category for a related event. E.g. "registry", "network", "file" + */ +type RelatedEventCategory = string; +/** + * A valid type for a related event. E.g. "start", "end", "access" + */ +type RelatedEventType = string; + +/** + * Add/replace related event info (on origin node) for any mock ResolverTree + * + * @param treeToAddRelatedEventsTo the ResolverTree to modify + * @param relatedEventsToAddByCategoryAndType Iterable of `[category, type]` pairs describing related events. e.g. [['dns','info'],['registry','access']] + */ +export function withRelatedEventsOnOrigin( + treeToAddRelatedEventsTo: ResolverTree, + relatedEventsToAddByCategoryAndType: Iterable<[RelatedEventCategory, RelatedEventType]> +): ResolverTree { + const events = []; + const byCategory: Record = {}; + const stats = { + totalAlerts: 0, + events: { + total: 0, + byCategory, + }, + }; + for (const [category, type] of relatedEventsToAddByCategoryAndType) { + events.push( + mockRelatedEvent({ + entityID: treeToAddRelatedEventsTo.entityID, + timestamp: 1, + category, + type, + }) + ); + stats.events.total++; + stats.events.byCategory[category] = stats.events.byCategory[category] + ? stats.events.byCategory[category] + 1 + : 1; + } + return { + ...treeToAddRelatedEventsTo, + stats, + relatedEvents: { + events, + nextEvent: null, + }, + }; +} + export function mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index df365a078b27f..dfbc6bd290686 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -13,6 +13,7 @@ import { mockTreeWith2AncestorsAndNoChildren, mockTreeWithNoAncestorsAnd2Children, } from './mocks/resolver_tree'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; describe('resolver selectors', () => { const actions: ResolverAction[] = []; @@ -114,7 +115,9 @@ describe('resolver selectors', () => { // find the position of the second child const secondChild = selectors.processEventForID(state())(secondChildID); - const positionOfSecondChild = layout.processNodePositions.get(secondChild!)!; + const positionOfSecondChild = layout.processNodePositions.get( + secondChild as SafeResolverEvent + )!; // the child is indexed by an AABB that extends -720/2 to the left const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2; @@ -130,19 +133,25 @@ describe('resolver selectors', () => { it('the origin should be in view', () => { const origin = selectors.processEventForID(state())(originID)!; expect( - selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(origin) + selectors + .visibleNodesAndEdgeLines(state())(0) + .processNodePositions.has(origin as SafeResolverEvent) ).toBe(true); }); it('the first child should be in view', () => { const firstChild = selectors.processEventForID(state())(firstChildID)!; expect( - selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(firstChild) + selectors + .visibleNodesAndEdgeLines(state())(0) + .processNodePositions.has(firstChild as SafeResolverEvent) ).toBe(true); }); it('the second child should not be in view', () => { const secondChild = selectors.processEventForID(state())(secondChildID)!; expect( - selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(secondChild) + selectors + .visibleNodesAndEdgeLines(state())(0) + .processNodePositions.has(secondChild as SafeResolverEvent) ).toBe(false); }); it('should return nothing as the flowto for the first child', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 87ef8d5d095ef..70a461909a99b 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -9,8 +9,8 @@ import * as cameraSelectors from './camera/selectors'; import * as dataSelectors from './data/selectors'; import * as uiSelectors from './ui/selectors'; import { ResolverState, IsometricTaxiLayout } from '../types'; -import { uniquePidForProcess } from '../models/process_event'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; +import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; /** * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. @@ -271,9 +271,14 @@ export const ariaFlowtoNodeID: ( const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time); // get a `Set` containing their node IDs - const nodesVisibleAtTime: Set = new Set( - [...processNodePositions.keys()].map(uniquePidForProcess) - ); + const nodesVisibleAtTime: Set = new Set(); + // NB: in practice, any event that has been graphed is guaranteed to have an entity_id + for (const visibleEvent of processNodePositions.keys()) { + const nodeID = entityIDSafeVersion(visibleEvent); + if (nodeID !== undefined) { + nodesVisibleAtTime.add(nodeID); + } + } // return the ID of `nodeID`'s following sibling, if it is visible return (nodeID: string): string | null => { diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 2a2354921a3d4..ed30643ed871e 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -220,6 +220,28 @@ export class Simulator { ); } + /** + * Dump all contents of the outer ReactWrapper (to be `console.log`ged as appropriate) + * This will include both DOM (div, span, etc.) and React/JSX (MyComponent, MyGrid, etc.) + */ + public debugWrapper() { + return this.wrapper.debug(); + } + + /** + * Return an Enzyme ReactWrapper that includes the Related Events host button for a given process node + * + * @param entityID The entity ID of the proocess node to select in + */ + public processNodeRelatedEventButton(entityID: string): ReactWrapper { + return this.processNodeElements({ entityID }).findWhere( + (wrapper) => + // Filter out React components + typeof wrapper.type() === 'string' && + wrapper.prop('data-test-subj') === 'resolver:submenu:button' + ); + } + /** * Return the selected node query string values. */ diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index c2871fdceb20a..30634e722050f 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -11,10 +11,10 @@ import { Middleware, Dispatch } from 'redux'; import { BBox } from 'rbush'; import { ResolverAction } from './store/actions'; import { - ResolverEvent, ResolverRelatedEvents, ResolverTree, ResolverEntityIndex, + SafeResolverEvent, } from '../../common/endpoint/types'; /** @@ -155,7 +155,7 @@ export interface IndexedEdgeLineSegment extends BBox { */ export interface IndexedProcessNode extends BBox { type: 'processNode'; - entity: ResolverEvent; + entity: SafeResolverEvent; position: Vector2; } @@ -280,21 +280,21 @@ export interface IndexedProcessTree { /** * Map of ID to a process's ordered children */ - idToChildren: Map; + idToChildren: Map; /** * Map of ID to process */ - idToProcess: Map; + idToProcess: Map; } /** * A map of `ProcessEvents` (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` */ -export type ProcessWidths = Map; +export type ProcessWidths = Map; /** * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `processPositions` */ -export type ProcessPositions = Map; +export type ProcessPositions = Map; export type DurationTypes = | 'millisecond' @@ -346,11 +346,11 @@ export interface EdgeLineSegment { * Used to provide pre-calculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { - process: ResolverEvent; + process: SafeResolverEvent; width: number; } & ( | { - parent: ResolverEvent; + parent: SafeResolverEvent; parentWidth: number; isOnlyChild: boolean; firstChildWidth: number; @@ -433,7 +433,7 @@ export interface IsometricTaxiLayout { /** * A map of events to position. Each event represents its own node. */ - processNodePositions: Map; + processNodePositions: Map; /** * A map of edge-line segments, which graphically connect nodes. */ @@ -442,7 +442,7 @@ export interface IsometricTaxiLayout { /** * defines the aria levels for nodes. */ - ariaLevels: Map; + ariaLevels: Map; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index f339d128944cc..c819491dd28f0 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -9,14 +9,14 @@ import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher import '../test_utilities/extend_jest'; -describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { - let simulator: Simulator; - let databaseDocumentID: string; - let entityIDs: { origin: string; firstChild: string; secondChild: string }; +let simulator: Simulator; +let databaseDocumentID: string; +let entityIDs: { origin: string; firstChild: string; secondChild: string }; - // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances - const resolverComponentInstanceID = 'resolverComponentInstanceID'; +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'resolverComponentInstanceID'; +describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { beforeEach(async () => { // create a mock data access layer const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren(); @@ -79,6 +79,7 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( simulator .processNodeElements({ entityID: entityIDs.secondChild }) .find('button') + .first() .simulate('click'); }); it('should render the second child node as selected, and the first child not as not selected, and the query string should indicate that the second child is selected', async () => { @@ -107,3 +108,52 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( }); }); }); + +describe('Resolver, when analyzing a tree that has some related events', () => { + beforeEach(async () => { + // create a mock data access layer with related events + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren({ + withRelatedEvents: [ + ['registry', 'access'], + ['registry', 'access'], + ], + }); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + describe('when it has loaded', () => { + beforeEach(async () => { + await expect( + simulator.mapStateTransitions(() => ({ + graphElements: simulator.graphElement().length, + graphLoadingElements: simulator.graphLoadingElement().length, + graphErrorElements: simulator.graphErrorElement().length, + originNode: simulator.processNodeElements({ entityID: entityIDs.origin }).length, + })) + ).toYieldEqualTo({ + graphElements: 1, + graphLoadingElements: 0, + graphErrorElements: 0, + originNode: 1, + }); + }); + + it('should render a related events button', async () => { + await expect( + simulator.mapStateTransitions(() => ({ + relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length, + })) + ).toYieldEqualTo({ + relatedEventButtons: 1, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index a965f06c04926..bbff2388af8b7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -20,7 +20,7 @@ import { SymbolDefinitions, useResolverTheme } from './assets'; import { useStateSyncingActions } from './use_state_syncing_actions'; import { useResolverQueryParams } from './use_resolver_query_params'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; -import { entityId } from '../../../common/endpoint/models/event'; +import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; /** @@ -107,7 +107,7 @@ export const ResolverMap = React.memo(function ({ /> ))} {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityId(processEvent); + const processEntityId = entityIDSafeVersion(processEvent); return ( unknown; -}) { - interface ProcessTableView { - name: string; - timestamp?: Date; - event: ResolverEvent; - } - - const dispatch = useResolverDispatch(); - const { timestamp } = useContext(SideEffectContext); - const isProcessTerminated = useSelector(selectors.isProcessTerminated); - const handleBringIntoViewClick = useCallback( - (processTableViewItem) => { - dispatch({ - type: 'userBroughtProcessIntoView', - payload: { - time: timestamp(), - process: processTableViewItem.event, - }, - }); - pushToQueryParams({ crumbId: event.entityId(processTableViewItem.event), crumbEvent: '' }); - }, - [dispatch, timestamp, pushToQueryParams] - ); - - const columns = useMemo>>( - () => [ - { - field: 'name', - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.table.row.processNameTitle', - { - defaultMessage: 'Process Name', - } - ), - sortable: true, - truncateText: true, - render(name: string, item: ProcessTableView) { - const entityId = event.entityId(item.event); - const isTerminated = isProcessTerminated(entityId); - return name === '' ? ( - - {i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription', - { - defaultMessage: 'Value is missing', - } - )} - - ) : ( - { - handleBringIntoViewClick(item); - pushToQueryParams({ crumbId: event.entityId(item.event), crumbEvent: '' }); - }} - > - - {name} - - ); - }, - }, - { - field: 'timestamp', - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampTitle', - { - defaultMessage: 'Timestamp', - } - ), - dataType: 'date', - sortable: true, - render(eventDate?: Date) { - return eventDate ? ( - formatter.format(eventDate) - ) : ( - - {i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampInvalidLabel', - { - defaultMessage: 'invalid', - } - )} - - ); - }, - }, - ], - [pushToQueryParams, handleBringIntoViewClick, isProcessTerminated] - ); - - const { processNodePositions } = useSelector(selectors.layout); - const processTableView: ProcessTableView[] = useMemo( - () => - [...processNodePositions.keys()].map((processEvent) => { - let dateTime; - const eventTime = event.eventTimestamp(processEvent); - const name = event.eventName(processEvent); - if (eventTime) { - const date = new Date(eventTime); - if (isFinite(date.getTime())) { - dateTime = date; - } - } - return { - name, - timestamp: dateTime, - event: processEvent, - }; - }), - [processNodePositions] - ); - const numberOfProcesses = processTableView.length; - - const crumbs = useMemo(() => { - return [ - { - text: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events', - { - defaultMessage: 'All Process Events', - } - ), - onClick: () => {}, - }, - ]; - }, []); - - const children = useSelector(selectors.hasMoreChildren); - const ancestors = useSelector(selectors.hasMoreAncestors); - const showWarning = children === true || ancestors === true; - return ( - <> - - {showWarning && } - - - data-test-subj="resolver:panel:process-list" - items={processTableView} - columns={columns} - sorting - /> - - ); -}); -ProcessListWithCounts.displayName = 'ProcessListWithCounts'; +/* + * 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 React, { memo, useContext, useCallback, useMemo } from 'react'; +import { + EuiBasicTableColumn, + EuiBadge, + EuiButtonEmpty, + EuiSpacer, + EuiInMemoryTable, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; +import * as event from '../../../../common/endpoint/models/event'; +import * as selectors from '../../store/selectors'; +import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; +import { useResolverDispatch } from '../use_resolver_dispatch'; +import { SideEffectContext } from '../side_effect_context'; +import { CubeForProcess } from './process_cube_icon'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import { LimitWarning } from '../limit_warnings'; + +const StyledLimitWarning = styled(LimitWarning)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; + +/** + * The "default" view for the panel: A list of all the processes currently in the graph. + * + * @param {function} pushToQueryparams A function to update the hash value in the URL to control panel state + */ +export const ProcessListWithCounts = memo(function ProcessListWithCounts({ + pushToQueryParams, +}: { + pushToQueryParams: (queryStringKeyValuePair: CrumbInfo) => unknown; +}) { + interface ProcessTableView { + name?: string; + timestamp?: Date; + event: SafeResolverEvent; + } + + const dispatch = useResolverDispatch(); + const { timestamp } = useContext(SideEffectContext); + const isProcessTerminated = useSelector(selectors.isProcessTerminated); + const handleBringIntoViewClick = useCallback( + (processTableViewItem) => { + dispatch({ + type: 'userBroughtProcessIntoView', + payload: { + time: timestamp(), + process: processTableViewItem.event, + }, + }); + pushToQueryParams({ crumbId: event.entityId(processTableViewItem.event), crumbEvent: '' }); + }, + [dispatch, timestamp, pushToQueryParams] + ); + + const columns = useMemo>>( + () => [ + { + field: 'name', + name: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.processNameTitle', + { + defaultMessage: 'Process Name', + } + ), + sortable: true, + truncateText: true, + render(name: string, item: ProcessTableView) { + const entityID = event.entityIDSafeVersion(item.event); + const isTerminated = entityID === undefined ? false : isProcessTerminated(entityID); + return name === '' ? ( + + {i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription', + { + defaultMessage: 'Value is missing', + } + )} + + ) : ( + { + handleBringIntoViewClick(item); + pushToQueryParams({ + // Take the user back to the list of nodes if this node has no ID + crumbId: event.entityIDSafeVersion(item.event) ?? '', + crumbEvent: '', + }); + }} + > + + {name} + + ); + }, + }, + { + field: 'timestamp', + name: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampTitle', + { + defaultMessage: 'Timestamp', + } + ), + dataType: 'date', + sortable: true, + render(eventDate?: Date) { + return eventDate ? ( + formatter.format(eventDate) + ) : ( + + {i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampInvalidLabel', + { + defaultMessage: 'invalid', + } + )} + + ); + }, + }, + ], + [pushToQueryParams, handleBringIntoViewClick, isProcessTerminated] + ); + + const { processNodePositions } = useSelector(selectors.layout); + const processTableView: ProcessTableView[] = useMemo( + () => + [...processNodePositions.keys()].map((processEvent) => { + let dateTime; + const eventTime = event.timestampSafeVersion(processEvent); + const name = event.processNameSafeVersion(processEvent); + if (eventTime) { + const date = new Date(eventTime); + if (isFinite(date.getTime())) { + dateTime = date; + } + } + return { + name, + timestamp: dateTime, + event: processEvent, + }; + }), + [processNodePositions] + ); + const numberOfProcesses = processTableView.length; + + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events', + { + defaultMessage: 'All Process Events', + } + ), + onClick: () => {}, + }, + ]; + }, []); + + const children = useSelector(selectors.hasMoreChildren); + const ancestors = useSelector(selectors.hasMoreAncestors); + const showWarning = children === true || ancestors === true; + return ( + <> + + {showWarning && } + + + data-test-subj="resolver:panel:process-list" + items={processTableView} + columns={columns} + sorting + /> + + ); +}); +ProcessListWithCounts.displayName = 'ProcessListWithCounts'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 24de45ee894dc..2a5d91028d9f5 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -14,10 +14,9 @@ import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3 } from '../types'; import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; -import * as processEventModel from '../models/process_event'; import * as selectors from '../store/selectors'; import { useResolverQueryParams } from './use_resolver_query_params'; @@ -85,7 +84,7 @@ const UnstyledProcessEventDot = React.memo( /** * An event which contains details about the process node. */ - event: ResolverEvent; + event: SafeResolverEvent; /** * projectionMatrix which can be used to convert `position` to screen coordinates. */ @@ -114,7 +113,11 @@ const UnstyledProcessEventDot = React.memo( // Node (html id=) IDs const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); const selectedNode = useSelector(selectors.selectedNode); - const nodeID = processEventModel.uniquePidForProcess(event); + const nodeID: string | undefined = eventModel.entityIDSafeVersion(event); + if (nodeID === undefined) { + // NB: this component should be taking nodeID as a `string` instead of handling this logic here + throw new Error('Tried to render a node with no ID'); + } const relatedEventStats = useSelector(selectors.relatedEventsStats)(nodeID); // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. @@ -287,7 +290,9 @@ const UnstyledProcessEventDot = React.memo( ? subMenuAssets.initialMenuStatus : relatedEventOptions; - const grandTotal: number | null = useSelector(selectors.relatedEventTotalForProcess)(event); + const grandTotal: number | null = useSelector(selectors.relatedEventTotalForProcess)( + event as ResolverEvent + ); /* eslint-disable jsx-a11y/click-events-have-key-events */ /** @@ -398,11 +403,11 @@ const UnstyledProcessEventDot = React.memo( maxWidth: `${isShowingEventActions ? 400 : 210 * xScale}px`, }} tabIndex={-1} - title={eventModel.eventName(event)} + title={eventModel.processNameSafeVersion(event)} > - {eventModel.eventName(event)} + {eventModel.processNameSafeVersion(event)} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index e74502243ffc8..5f1e5f18e575d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -20,7 +20,7 @@ import { SymbolDefinitions, useResolverTheme } from './assets'; import { useStateSyncingActions } from './use_state_syncing_actions'; import { useResolverQueryParams } from './use_resolver_query_params'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; -import { entityId } from '../../../common/endpoint/models/event'; +import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; import { ResolverProps } from '../types'; @@ -114,7 +114,7 @@ export const ResolverWithoutProviders = React.memo( ) )} {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityId(processEvent); + const processEntityId = entityIDSafeVersion(processEvent); return ( {count ? : ''} {menuTitle} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index b32d63283b547..630ee2f7ff7f0 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -191,7 +191,7 @@ describe('useCamera on an unpainted element', () => { } const processes: ResolverEvent[] = [ ...selectors.layout(store.getState()).processNodePositions.keys(), - ]; + ] as ResolverEvent[]; process = processes[processes.length - 1]; if (!process) { throw new Error('missing the process to bring into view'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 0a08e45324b89..c2e23cc19d89e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -363,6 +363,7 @@ export const queryTimelineById = ({ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeline => ({ duplicate, id, + forceNotes = false, from, notes, timeline, @@ -407,7 +408,7 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli dispatch(dispatchAddGlobalTimelineNote({ noteId: newNote.id, id })); } - if (!duplicate) { + if (!duplicate || forceNotes) { dispatch( dispatchAddNotes({ notes: diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 8950f814d6965..769a0a1658a46 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -192,6 +192,7 @@ export interface OpenTimelineProps { export interface UpdateTimeline { duplicate: boolean; id: string; + forceNotes?: boolean; from: string; notes: NoteResult[] | null | undefined; timeline: TimelineModel; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index c20aaed10f3f8..9d15b4464c191 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -237,7 +237,7 @@ export class ManifestManager { const { items, total } = await this.packageConfigService.list(this.savedObjectsClient, { page, perPage: 20, - kuery: 'ingest-package-configs.package.name:endpoint', + kuery: 'ingest-package-policies.package.name:endpoint', }); for (const packageConfig of items) { diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts index e59b1092978da..7afc185ae07fd 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts @@ -41,7 +41,7 @@ export const getMockJobSummaryResponse = () => [ { id: 'other_job', description: 'a job that is custom', - groups: ['auditbeat', 'process'], + groups: ['auditbeat', 'process', 'security'], processed_record_count: 0, memory_status: 'ok', jobState: 'closed', @@ -54,6 +54,19 @@ export const getMockJobSummaryResponse = () => [ { id: 'another_job', description: 'another job that is custom', + groups: ['auditbeat', 'process', 'security'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, + { + id: 'irrelevant_job', + description: 'a non-security job', groups: ['auditbeat', 'process'], processed_record_count: 0, memory_status: 'ok', diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index 80a9dba26df8e..a6d4dc7a38e14 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -15,6 +15,7 @@ import { MlPluginSetup } from '../../../../ml/server'; import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; import { DetectionRulesUsage, MlJobsUsage } from './index'; import { isJobStarted } from '../../../common/machine_learning/helpers'; +import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; interface DetectionsMetric { isElastic: boolean; @@ -182,11 +183,9 @@ export const getMlJobsUsage = async (ml: MlPluginSetup | undefined): Promise module.jobs); - const jobs = await ml - .jobServiceProvider(internalMlClient, fakeRequest) - .jobsSummary(['siem', 'security']); + const jobs = await ml.jobServiceProvider(internalMlClient, fakeRequest).jobsSummary(); - jobsUsage = jobs.reduce((usage, job) => { + jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); const isEnabled = isJobStarted(job.jobState, job.datafeedState); diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts index 4cdb33c06947f..ff604b18e1d51 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts @@ -24,6 +24,9 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_drilldowns')); loadTestFile(require.resolve('./explore_data_panel_action')); - loadTestFile(require.resolve('./explore_data_chart_action')); + + // Disabled for now as it requires xpack.discoverEnhanced.actions.exploreDataInChart.enabled + // setting set in kibana.yml to work. Once that is enabled by default, we can re-enable this test suite. + // loadTestFile(require.resolve('./explore_data_chart_action')); }); } diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index 767dad74c23d7..f8dc2f3b0aeb8 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -137,7 +137,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('space with index pattern management disabled', () => { + describe('space with index pattern management disabled', function () { + // unskipped because of flakiness in cloud, caused be ingest management tests + // should be unskipped when https://github.com/elastic/kibana/issues/74353 was resolved + this.tags(['skipCloud']); before(async () => { await spacesService.create({ id: 'custom_space_no_index_patterns', diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 4566e9aed61b4..a62bfdcde0572 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); + const editedDescription = 'Edited description'; describe('classification creation', function () { before(async () => { @@ -179,6 +180,36 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should open the edit form for the created job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + }); + + it('should input the description in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + }); + + it('should input the model memory limit in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + }); + + it('should submit the edit job form', async () => { + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + }); + + it('displays details for the edited job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + sourceIndex: testData.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + }); + it('creates the destination index and writes results to it', async () => { await ml.api.assertIndicesExist(testData.destinationIndex); await ml.api.assertIndicesNotEmpty(testData.destinationIndex); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 0320354b99ff0..5b89cec49db3e 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); + const editedDescription = 'Edited description'; describe('outlier detection creation', function () { before(async () => { @@ -197,6 +198,36 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should open the edit form for the created job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + }); + + it('should input the description in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + }); + + it('should input the model memory limit in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + }); + + it('should submit the edit job form', async () => { + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + }); + + it('displays details for the edited job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + sourceIndex: testData.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + }); + it('creates the destination index and writes results to it', async () => { await ml.api.assertIndicesExist(testData.destinationIndex); await ml.api.assertIndicesNotEmpty(testData.destinationIndex); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 1aa505e26e1e9..a67a348323347 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); + const editedDescription = 'Edited description'; describe('regression creation', function () { before(async () => { @@ -179,6 +180,36 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should open the edit form for the created job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + }); + + it('should input the description in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + }); + + it('should input the model memory limit in the edit form', async () => { + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + }); + + it('should submit the edit job form', async () => { + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + }); + + it('displays details for the edited job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + sourceIndex: testData.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + }); + it('creates the destination index and writes results to it', async () => { await ml.api.assertIndicesExist(testData.destinationIndex); await ml.api.assertIndicesNotEmpty(testData.destinationIndex); diff --git a/x-pack/test/functional/apps/ml/pages.ts b/x-pack/test/functional/apps/ml/pages.ts index e2c80c8dab558..3691e6b1afcdc 100644 --- a/x-pack/test/functional/apps/ml/pages.ts +++ b/x-pack/test/functional/apps/ml/pages.ts @@ -53,5 +53,17 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataVisualizer.assertDataVisualizerImportDataCardExists(); await ml.dataVisualizer.assertDataVisualizerIndexDataCardExists(); }); + + it('should load the stack management with the ML menu item being present', async () => { + await ml.navigation.navigateToStackManagement(); + }); + + it('should load the jobs list page in stack management', async () => { + await ml.navigation.navigateToStackManagementJobsListPage(); + }); + + it('should load the analytics jobs list page in stack management', async () => { + await ml.navigation.navigateToStackManagementJobsListPageAnalyticsTab(); + }); }); } diff --git a/x-pack/test/functional/es_archives/fleet/agents/mappings.json b/x-pack/test/functional/es_archives/fleet/agents/mappings.json index acc32c3e2cbb5..23b404a53703f 100644 --- a/x-pack/test/functional/es_archives/fleet/agents/mappings.json +++ b/x-pack/test/functional/es_archives/fleet/agents/mappings.json @@ -28,7 +28,7 @@ "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", "fleet-agent-events": "3231653fafe4ef3196fe3b32ab774bf2", - "ingest-package-configs": "2346514df03316001d56ed4c8d46fa94", + "ingest-package-policies": "2346514df03316001d56ed4c8d46fa94", "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", "inventory-view": "5299b67717e96502c77babf1c16fd4d3", "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", @@ -1834,7 +1834,7 @@ } } }, - "ingest-package-configs": { + "ingest-package-policies": { "properties": { "config_id": { "type": "keyword" diff --git a/x-pack/test/functional/es_archives/lists/mappings.json b/x-pack/test/functional/es_archives/lists/mappings.json index 2fc1f1a3111a7..3b4d915cc1ca5 100644 --- a/x-pack/test/functional/es_archives/lists/mappings.json +++ b/x-pack/test/functional/es_archives/lists/mappings.json @@ -70,7 +70,7 @@ "maps-telemetry": "5ef305b18111b77789afefbd36b66171", "namespace": "2f4316de49999235636386fe51dc06c1", "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", - "ingest-package-configs": "48e8bd97e488008e21c0b5a2367b83ad", + "ingest-package-policies": "48e8bd97e488008e21c0b5a2367b83ad", "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", "config": "c63748b75f39d0c54de12d12c1ccbc20", @@ -1274,7 +1274,7 @@ } } }, - "ingest-package-configs": { + "ingest-package-policies": { "properties": { "config_id": { "type": "keyword" diff --git a/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json b/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json index 1fd338fbb0ffb..3519103d06814 100644 --- a/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json @@ -39,7 +39,7 @@ "index-pattern": "66eccb05066c5a89924f48a9e9736499", "ingest-agent-policies": "9326f99c977fd2ef5ab24b6336a0675c", "ingest-outputs": "8aa988c376e65443fefc26f1075e93a3", - "ingest-package-configs": "48e8bd97e488008e21c0b5a2367b83ad", + "ingest-package-policies": "48e8bd97e488008e21c0b5a2367b83ad", "ingest_manager_settings": "012cf278ec84579495110bb827d1ed09", "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", "lens": "d33c68a69ff1e78c9888dedd2164ac22", @@ -1212,7 +1212,7 @@ } } }, - "ingest-package-configs": { + "ingest-package-policies": { "properties": { "config_id": { "type": "keyword" diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_edit.ts b/x-pack/test/functional/services/ml/data_frame_analytics_edit.ts new file mode 100644 index 0000000000000..fd06dd24d6f8b --- /dev/null +++ b/x-pack/test/functional/services/ml/data_frame_analytics_edit.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommon } from './common'; + +export function MachineLearningDataFrameAnalyticsEditProvider( + { getService }: FtrProviderContext, + mlCommon: MlCommon +) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + return { + async assertJobDescriptionEditInputExists() { + await testSubjects.existOrFail('mlAnalyticsEditFlyoutDescriptionInput'); + }, + async assertJobDescriptionEditValue(expectedValue: string) { + const actualJobDescription = await testSubjects.getAttribute( + 'mlAnalyticsEditFlyoutDescriptionInput', + 'value' + ); + expect(actualJobDescription).to.eql( + expectedValue, + `Job description edit should be '${expectedValue}' (got '${actualJobDescription}')` + ); + }, + async assertJobMmlEditInputExists() { + await testSubjects.existOrFail('mlAnalyticsEditFlyoutmodelMemoryLimitInput'); + }, + async assertJobMmlEditValue(expectedValue: string) { + const actualMml = await testSubjects.getAttribute( + 'mlAnalyticsEditFlyoutmodelMemoryLimitInput', + 'value' + ); + expect(actualMml).to.eql( + expectedValue, + `Job model memory limit edit should be '${expectedValue}' (got '${actualMml}')` + ); + }, + async setJobDescriptionEdit(jobDescription: string) { + await mlCommon.setValueWithChecks('mlAnalyticsEditFlyoutDescriptionInput', jobDescription, { + clearWithKeyboard: true, + }); + await this.assertJobDescriptionEditValue(jobDescription); + }, + + async setJobMmlEdit(mml: string) { + await mlCommon.setValueWithChecks('mlAnalyticsEditFlyoutmodelMemoryLimitInput', mml, { + clearWithKeyboard: true, + }); + await this.assertJobMmlEditValue(mml); + }, + + async assertAnalyticsEditFlyoutMissing() { + await testSubjects.missingOrFail('mlAnalyticsEditFlyout'); + }, + + async updateAnalyticsJob() { + await testSubjects.existOrFail('mlAnalyticsEditFlyoutUpdateButton'); + await testSubjects.click('mlAnalyticsEditFlyoutUpdateButton'); + await retry.tryForTime(5000, async () => { + await this.assertAnalyticsEditFlyoutMissing(); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts index d315f9eb77210..608a1f2bee3e1 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts @@ -88,6 +88,12 @@ export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: F await testSubjects.existOrFail('mlAnalyticsJobViewButton'); } + public async openEditFlyout(analyticsId: string) { + await this.openRowActions(analyticsId); + await testSubjects.click('mlAnalyticsJobEditButton'); + await testSubjects.existOrFail('mlAnalyticsEditFlyout', { timeout: 5000 }); + } + async assertAnalyticsSearchInputValue(expectedSearchValue: string) { const searchBarInput = await this.getAnalyticsSearchInput(); const actualSearchValue = await searchBarInput.getAttribute('value'); diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index fbf31e40a242a..fd36bb0f47f95 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -13,6 +13,7 @@ import { MachineLearningCommonProvider } from './common'; import { MachineLearningCustomUrlsProvider } from './custom_urls'; import { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytics'; import { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; +import { MachineLearningDataFrameAnalyticsEditProvider } from './data_frame_analytics_edit'; import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; import { MachineLearningDataVisualizerProvider } from './data_visualizer'; import { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based'; @@ -47,6 +48,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { common, api ); + const dataFrameAnalyticsEdit = MachineLearningDataFrameAnalyticsEditProvider(context, common); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); const dataVisualizer = MachineLearningDataVisualizerProvider(context); const dataVisualizerFileBased = MachineLearningDataVisualizerFileBasedProvider(context, common); @@ -76,6 +78,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { customUrls, dataFrameAnalytics, dataFrameAnalyticsCreation, + dataFrameAnalyticsEdit, dataFrameAnalyticsTable, dataVisualizer, dataVisualizerFileBased, diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 9b67a369f055f..f52197d4b2256 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -23,6 +23,13 @@ export function MachineLearningNavigationProvider({ }); }, + async navigateToStackManagement() { + await retry.tryForTime(60 * 1000, async () => { + await PageObjects.common.navigateToApp('management'); + await testSubjects.existOrFail('jobsListLink', { timeout: 2000 }); + }); + }, + async assertTabsExist(tabTypeSubject: string, areaSubjects: string[]) { await retry.tryForTime(10000, async () => { const allTabs = await testSubjects.findAll(`~${tabTypeSubject}`, 3); @@ -76,5 +83,25 @@ export function MachineLearningNavigationProvider({ async navigateToSettings() { await this.navigateToArea('~mlMainTab & ~settings', 'mlPageSettings'); }, + + async navigateToStackManagementJobsListPage() { + // clicks the jobsListLink and loads the jobs list page + await testSubjects.click('jobsListLink'); + await retry.tryForTime(60 * 1000, async () => { + // verify that the overall page is present + await testSubjects.existOrFail('mlPageStackManagementJobsList'); + // verify that the default tab with the anomaly detection jobs list got loaded + await testSubjects.existOrFail('ml-jobs-list'); + }); + }, + + async navigateToStackManagementJobsListPageAnalyticsTab() { + // clicks the `Analytics` tab and loads the analytics list page + await testSubjects.click('mlStackManagementJobsListAnalyticsTab'); + await retry.tryForTime(60 * 1000, async () => { + // verify that the empty prompt for analytics jobs list got loaded + await testSubjects.existOrFail('mlNoDataFrameAnalyticsFound'); + }); + }, }; } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js index c87a5039360b8..ea95eb42dd6ff 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js @@ -28,7 +28,8 @@ export default function ({ getService }) { const testHistoryIndex = '.kibana_task_manager_test_result'; const supertest = supertestAsPromised(url.format(config.get('servers.kibana'))); - describe('scheduling and running tasks', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71390 + describe.skip('scheduling and running tasks', () => { beforeEach( async () => await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200) ); diff --git a/x-pack/test/security_solution_cypress/es_archives/export_rule/mappings.json b/x-pack/test/security_solution_cypress/es_archives/export_rule/mappings.json index dc92d23a618d3..bb63d29503663 100644 --- a/x-pack/test/security_solution_cypress/es_archives/export_rule/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/export_rule/mappings.json @@ -41,7 +41,7 @@ "infrastructure-ui-source": "2b2809653635caf490c93f090502d04c", "ingest-agent-policies": "9326f99c977fd2ef5ab24b6336a0675c", "ingest-outputs": "8aa988c376e65443fefc26f1075e93a3", - "ingest-package-configs": "48e8bd97e488008e21c0b5a2367b83ad", + "ingest-package-policies": "48e8bd97e488008e21c0b5a2367b83ad", "ingest_manager_settings": "012cf278ec84579495110bb827d1ed09", "inventory-view": "88fc7e12fd1b45b6f0787323ce4f18d2", "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", @@ -1286,7 +1286,7 @@ } } }, - "ingest-package-configs": { + "ingest-package-policies": { "properties": { "config_id": { "type": "keyword"