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"