From 0cde5fd4560ae216db6815220f5f8d66c25a89f8 Mon Sep 17 00:00:00 2001
From: Anton Dosov
Date: Thu, 17 Sep 2020 14:29:03 +0200
Subject: [PATCH 01/32] [Drilldowns] {{event.points}} in URL drilldown for
VALUE_CLICK_TRIGGER (#76771)
{{event.points}} in URL drilldown for VALUE_CLICK_TRIGGER
Co-authored-by: Elastic Machine
---
docs/user/dashboard/url-drilldown.asciidoc | 17 ++
.../public/triggers/value_click_trigger.ts | 2 +-
.../url_drilldown/url_drilldown.test.ts | 2 -
.../url_drilldown/url_drilldown.tsx | 16 +-
.../url_drilldown/url_drilldown_scope.test.ts | 129 ++++++++++++
...ldown_scope.tsx => url_drilldown_scope.ts} | 196 ++++--------------
.../embeddable_enhanced/public/plugin.ts | 1 -
7 files changed, 192 insertions(+), 171 deletions(-)
create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts
rename x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/{url_drilldown_scope.tsx => url_drilldown_scope.ts} (51%)
diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc
index 16f82477756b7..4919625340da2 100644
--- a/docs/user/dashboard/url-drilldown.asciidoc
+++ b/docs/user/dashboard/url-drilldown.asciidoc
@@ -197,6 +197,7 @@ context.panel.timeRange.indexPatternIds
| ID of saved object behind a panel.
| *Single click*
+
| event.value
| Value behind clicked data point.
@@ -208,6 +209,22 @@ context.panel.timeRange.indexPatternIds
| event.negate
| Boolean, indicating whether clicked data point resulted in negative filter.
+|
+| event.points
+| Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient. +
+
+Example:
+
+`{{json event.points}}` +
+`{{event.points.[0].key}}` +
+`{{event.points.[0].value}}`
+`{{#each event.points}}key=value&{{/each}}`
+
+Note:
+
+`{{event.value}}` is a shorthand for `{{event.points.[0].value}}` +
+`{{event.key}}` is a shorthand for `{{event.points.[0].key}}`
+
| *Range selection*
| event.from +
event.to
diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts
index e63ff28f42d96..f1aff6322522a 100644
--- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts
+++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts
@@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = {
defaultMessage: 'Single click',
}),
description: i18n.translate('uiActions.triggers.valueClickDescription', {
- defaultMessage: 'A single point on the visualization',
+ defaultMessage: 'A data point click on the visualization',
}),
};
diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts
index 4906d0342be84..64af67aefa4be 100644
--- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts
+++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts
@@ -5,7 +5,6 @@
*/
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
-import { coreMock } from '../../../../../../src/core/public/mocks';
import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables';
const mockDataPoints = [
@@ -52,7 +51,6 @@ const mockNavigateToUrl = jest.fn(() => Promise.resolve());
describe('UrlDrilldown', () => {
const urlDrilldown = new UrlDrilldown({
getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }),
- getOpenModal: () => Promise.resolve(coreMock.createStart().overlays.openModal),
getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs',
getVariablesHelpDocsLink: () => 'http://localhost:5601/docs',
navigateToUrl: mockNavigateToUrl,
diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx
index 80478e6490b8f..04f60662d88a3 100644
--- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx
+++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx
@@ -5,7 +5,6 @@
*/
import React from 'react';
-import { OverlayStart } from 'kibana/public';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import { ChartActionContext, IEmbeddable } from '../../../../../../src/plugins/embeddable/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public';
@@ -29,7 +28,6 @@ import { txtUrlDrilldownDisplayName } from './i18n';
interface UrlDrilldownDeps {
getGlobalScope: () => UrlDrilldownGlobalScope;
navigateToUrl: (url: string) => Promise;
- getOpenModal: () => Promise;
getSyntaxHelpDocsLink: () => string;
getVariablesHelpDocsLink: () => string;
}
@@ -112,13 +110,10 @@ export class UrlDrilldown implements Drilldown
- urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context));
+ urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context));
public readonly execute = async (config: Config, context: ActionContext) => {
- const url = await urlDrilldownCompileUrl(
- config.url.template,
- await this.buildRuntimeScope(context, { allowPrompts: true })
- );
+ const url = urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context));
if (config.openInNewTab) {
window.open(url, '_blank', 'noopener');
} else {
@@ -134,14 +129,11 @@ export class UrlDrilldown implements Drilldown {
+ private buildRuntimeScope = (context: ActionContext) => {
return urlDrilldownBuildScope({
globalScope: this.deps.getGlobalScope(),
contextScope: getContextScope(context),
- eventScope: await getEventScope(context, this.deps, opts),
+ eventScope: getEventScope(context),
});
};
}
diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts
new file mode 100644
index 0000000000000..bb1baf5b96428
--- /dev/null
+++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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 {
+ getEventScope,
+ getMockEventScope,
+ ValueClickTriggerEventScope,
+} from './url_drilldown_scope';
+
+const createPoint = ({
+ field,
+ value,
+}: {
+ field: string;
+ value: string | null | number | boolean;
+}) => ({
+ table: {
+ columns: [
+ {
+ name: field,
+ id: '1-1',
+ meta: {
+ type: 'histogram',
+ indexPatternId: 'logstash-*',
+ aggConfigParams: {
+ field,
+ interval: 30,
+ otherBucket: true,
+ },
+ },
+ },
+ ],
+ rows: [
+ {
+ '1-1': '2048',
+ },
+ ],
+ },
+ column: 0,
+ row: 0,
+ value,
+});
+
+describe('VALUE_CLICK_TRIGGER', () => {
+ describe('supports `points[]`', () => {
+ test('getEventScope()', () => {
+ const mockDataPoints = [
+ createPoint({ field: 'field0', value: 'value0' }),
+ createPoint({ field: 'field1', value: 'value1' }),
+ createPoint({ field: 'field2', value: 'value2' }),
+ ];
+
+ const eventScope = getEventScope({
+ data: { data: mockDataPoints },
+ }) as ValueClickTriggerEventScope;
+
+ expect(eventScope.key).toBe('field0');
+ expect(eventScope.value).toBe('value0');
+ expect(eventScope.points).toHaveLength(mockDataPoints.length);
+ expect(eventScope.points).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "key": "field0",
+ "value": "value0",
+ },
+ Object {
+ "key": "field1",
+ "value": "value1",
+ },
+ Object {
+ "key": "field2",
+ "value": "value2",
+ },
+ ]
+ `);
+ });
+
+ test('getMockEventScope()', () => {
+ const mockEventScope = getMockEventScope([
+ 'VALUE_CLICK_TRIGGER',
+ ]) as ValueClickTriggerEventScope;
+ expect(mockEventScope.points.length).toBeGreaterThan(3);
+ expect(mockEventScope.points).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "key": "event.points.0.key",
+ "value": "event.points.0.value",
+ },
+ Object {
+ "key": "event.points.1.key",
+ "value": "event.points.1.value",
+ },
+ Object {
+ "key": "event.points.2.key",
+ "value": "event.points.2.value",
+ },
+ Object {
+ "key": "event.points.3.key",
+ "value": "event.points.3.value",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('handles undefined, null or missing values', () => {
+ test('undefined or missing values are removed from the result scope', () => {
+ const point = createPoint({ field: undefined } as any);
+ const eventScope = getEventScope({
+ data: { data: [point] },
+ }) as ValueClickTriggerEventScope;
+
+ expect('key' in eventScope).toBeFalsy();
+ expect('value' in eventScope).toBeFalsy();
+ });
+
+ test('null value stays in the result scope', () => {
+ const point = createPoint({ field: 'field', value: null });
+ const eventScope = getEventScope({
+ data: { data: [point] },
+ }) as ValueClickTriggerEventScope;
+
+ expect(eventScope.value).toBeNull();
+ });
+ });
+});
diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts
similarity index 51%
rename from x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx
rename to x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts
index d3e3510f1b24e..15a9a3ba77d88 100644
--- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx
+++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts
@@ -9,19 +9,7 @@
* Please refer to ./README.md for explanation of different scope sources
*/
-import React from 'react';
-import {
- EuiButton,
- EuiButtonEmpty,
- EuiModalBody,
- EuiModalFooter,
- EuiModalHeader,
- EuiModalHeaderTitle,
- EuiRadioGroup,
-} from '@elastic/eui';
-import uniqBy from 'lodash/uniqBy';
-import { FormattedMessage } from '@kbn/i18n/react';
-import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public';
+import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public';
import {
IEmbeddable,
isRangeSelectTriggerContext,
@@ -31,8 +19,6 @@ import {
} from '../../../../../../src/plugins/embeddable/public';
import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown';
import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public';
-import { OverlayStart } from '../../../../../../src/core/public';
-import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
type ContextScopeInput = ActionContext | ActionFactoryContext;
@@ -113,38 +99,35 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld
/**
* URL drilldown event scope,
- * available as: {{event.key}}, {{event.from}}
+ * available as {{event.$}}
*/
-type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope;
-type EventScopeInput = ActionContext;
-interface ValueClickTriggerEventScope {
+export type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope;
+export type EventScopeInput = ActionContext;
+export interface ValueClickTriggerEventScope {
key?: string;
- value?: string | number | boolean;
+ value: Primitive;
negate: boolean;
+ points: Array<{ key?: string; value: Primitive }>;
}
-interface RangeSelectTriggerEventScope {
+export interface RangeSelectTriggerEventScope {
key: string;
from?: string | number;
to?: string | number;
}
-export async function getEventScope(
- eventScopeInput: EventScopeInput,
- deps: { getOpenModal: () => Promise },
- opts: { allowPrompts: boolean } = { allowPrompts: false }
-): Promise {
+export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope {
if (isRangeSelectTriggerContext(eventScopeInput)) {
return getEventScopeFromRangeSelectTriggerContext(eventScopeInput);
} else if (isValueClickTriggerContext(eventScopeInput)) {
- return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts);
+ return getEventScopeFromValueClickTriggerContext(eventScopeInput);
} else {
throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger");
}
}
-async function getEventScopeFromRangeSelectTriggerContext(
+function getEventScopeFromRangeSelectTriggerContext(
eventScopeInput: RangeSelectContext
-): Promise {
+): RangeSelectTriggerEventScope {
const { table, column: columnIndex, range } = eventScopeInput.data;
const column = table.columns[columnIndex];
return cleanEmptyKeys({
@@ -154,18 +137,23 @@ async function getEventScopeFromRangeSelectTriggerContext(
});
}
-async function getEventScopeFromValueClickTriggerContext(
- eventScopeInput: ValueClickContext,
- deps: { getOpenModal: () => Promise },
- opts: { allowPrompts: boolean } = { allowPrompts: false }
-): Promise {
+function getEventScopeFromValueClickTriggerContext(
+ eventScopeInput: ValueClickContext
+): ValueClickTriggerEventScope {
const negate = eventScopeInput.data.negate ?? false;
- const point = await getSingleValue(eventScopeInput.data.data, deps, opts);
- const { key, value } = getKeyValueFromPoint(point);
+ const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => {
+ const column = table.columns[columnIndex];
+ return {
+ value: toPrimitiveOrUndefined(value) as Primitive,
+ key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined,
+ };
+ });
+
return cleanEmptyKeys({
- key,
- value,
+ key: points[0]?.key,
+ value: points[0]?.value,
negate,
+ points,
});
}
@@ -182,29 +170,28 @@ export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventSco
to: new Date().toISOString(),
};
} else {
+ // number of mock points to generate
+ // should be larger or equal of any possible data points length emitted by VALUE_CLICK_TRIGGER
+ const nPoints = 4;
+ const points = new Array(nPoints).fill(0).map((_, index) => ({
+ key: `event.points.${index}.key`,
+ value: `event.points.${index}.value`,
+ }));
return {
- key: 'event.key',
- value: 'event.value',
+ key: `event.key`,
+ value: `event.value`,
negate: false,
+ points,
};
}
}
-function getKeyValueFromPoint(
- point: ValueClickContext['data']['data'][0]
-): Pick {
- const { table, column: columnIndex, value } = point;
- const column = table.columns[columnIndex];
- return {
- key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined,
- value: toPrimitiveOrUndefined(value),
- };
-}
-
-function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined {
- if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v;
+type Primitive = string | number | boolean | null;
+function toPrimitiveOrUndefined(v: unknown): Primitive | undefined {
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null)
+ return v;
if (typeof v === 'object' && v instanceof Date) return v.toISOString();
- if (typeof v === 'undefined' || v === null) return undefined;
+ if (typeof v === 'undefined') return undefined;
return String(v);
}
@@ -216,104 +203,3 @@ function cleanEmptyKeys>(obj: T): T {
});
return obj;
}
-
-/**
- * VALUE_CLICK_TRIGGER could have multiple data points
- * Prompt user which data point to use in a drilldown
- */
-async function getSingleValue(
- data: ValueClickContext['data']['data'],
- deps: { getOpenModal: () => Promise },
- opts: { allowPrompts: boolean } = { allowPrompts: false }
-): Promise {
- data = uniqBy(data.filter(Boolean), (point) => {
- const { key, value } = getKeyValueFromPoint(point);
- return `${key}:${value}`;
- });
- if (data.length === 0)
- throw new Error(`[trigger = "VALUE_CLICK_TRIGGER"][getSingleValue] no value to pick from`);
- if (data.length === 1) return Promise.resolve(data[0]);
- if (!opts.allowPrompts) return Promise.resolve(data[0]);
- return new Promise(async (resolve, reject) => {
- const openModal = await deps.getOpenModal();
- const overlay = openModal(
- toMountPoint(
- overlay.close()}
- onSubmit={(point) => {
- if (point) {
- resolve(point);
- }
- overlay.close();
- }}
- data={data}
- />
- )
- );
- overlay.onClose.then(() => reject());
- });
-}
-
-function GetSingleValuePopup({
- data,
- onCancel,
- onSubmit,
-}: {
- data: ValueClickContext['data']['data'];
- onCancel: () => void;
- onSubmit: (value: ValueClickContext['data']['data'][0]) => void;
-}) {
- const values = data
- .map((point) => {
- const { key, value } = getKeyValueFromPoint(point);
- return {
- point,
- id: key ?? '',
- label: `${key}:${value}`,
- };
- })
- .filter((value) => Boolean(value.id));
-
- const [selectedValueId, setSelectedValueId] = React.useState(values[0].id);
-
- return (
-
-
-
-
-
-
-
-
- setSelectedValueId(id)}
- name="drilldownValues"
- />
-
-
-
-
-
-
- onSubmit(values.find((v) => v.id === selectedValueId)?.point!)}
- data-test-subj="applySingleValuePopoverButton"
- fill
- >
-
-
-
-
- );
-}
diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts
index 187db998e06ea..2138a372523b7 100644
--- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts
+++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts
@@ -74,7 +74,6 @@ export class EmbeddableEnhancedPlugin
getGlobalScope: urlDrilldownGlobalScopeProvider({ core }),
navigateToUrl: (url: string) =>
core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)),
- getOpenModal: () => core.getStartServices().then(([{ overlays }]) => overlays.openModal),
getSyntaxHelpDocsLink: () =>
startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax,
getVariablesHelpDocsLink: () =>
From 6d12c6893ab2a2dc66b103844b65af5cd7f462d9 Mon Sep 17 00:00:00 2001
From: Michael Hirsch
Date: Thu, 17 Sep 2020 09:02:28 -0400
Subject: [PATCH 02/32] [ML] Adds ML modules for Metrics UI Integration
(#76460)
* adds metrics ml integration
* renames jobs, updates datafeeds
* adds allow_no_indices: true for datafeeds
* updates module ids in manifest
* adds custom urls
* adds module and individual job descriptions
* removes model plots
* updates terms agg sizes
* updates chunking config
* removes query and default index pattern from manifest, updates descriptions
Co-authored-by: Elastic Machine
---
.../modules/metrics_ui_hosts/logo.json | 3 ++
.../modules/metrics_ui_hosts/manifest.json | 38 +++++++++++++
.../ml/datafeed_hosts_memory_usage.json | 16 ++++++
.../ml/datafeed_hosts_network_in.json | 40 ++++++++++++++
.../ml/datafeed_hosts_network_out.json | 40 ++++++++++++++
.../ml/hosts_memory_usage.json | 50 +++++++++++++++++
.../metrics_ui_hosts/ml/hosts_network_in.json | 37 +++++++++++++
.../ml/hosts_network_out.json | 37 +++++++++++++
.../modules/metrics_ui_k8s/logo.json | 3 ++
.../modules/metrics_ui_k8s/manifest.json | 38 +++++++++++++
.../ml/datafeed_k8s_memory_usage.json | 17 ++++++
.../ml/datafeed_k8s_network_in.json | 44 +++++++++++++++
.../ml/datafeed_k8s_network_out.json | 44 +++++++++++++++
.../metrics_ui_k8s/ml/k8s_memory_usage.json | 53 +++++++++++++++++++
.../metrics_ui_k8s/ml/k8s_network_in.json | 39 ++++++++++++++
.../metrics_ui_k8s/ml/k8s_network_out.json | 39 ++++++++++++++
.../apis/ml/modules/get_module.ts | 2 +
17 files changed, 540 insertions(+)
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json
create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json
new file mode 100644
index 0000000000000..2e57038bbc639
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json
@@ -0,0 +1,3 @@
+{
+ "icon": "metricsApp"
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json
new file mode 100644
index 0000000000000..29ac288c0649f
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json
@@ -0,0 +1,38 @@
+{
+ "id": "metrics_ui_hosts",
+ "title": "Metrics Hosts",
+ "description": "Detect anomalous memory and network behavior on hosts.",
+ "type": "Metricbeat Data",
+ "logoFile": "logo.json",
+ "jobs": [
+ {
+ "id": "hosts_memory_usage",
+ "file": "hosts_memory_usage.json"
+ },
+ {
+ "id": "hosts_network_in",
+ "file": "hosts_network_in.json"
+ },
+ {
+ "id": "hosts_network_out",
+ "file": "hosts_network_out.json"
+ }
+ ],
+ "datafeeds": [
+ {
+ "id": "datafeed-hosts_memory_usage",
+ "file": "datafeed_hosts_memory_usage.json",
+ "job_id": "hosts_memory_usage"
+ },
+ {
+ "id": "datafeed-hosts_network_in",
+ "file": "datafeed_hosts_network_in.json",
+ "job_id": "hosts_network_in"
+ },
+ {
+ "id": "datafeed-hosts_network_out",
+ "file": "datafeed_hosts_network_out.json",
+ "job_id": "hosts_network_out"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json
new file mode 100644
index 0000000000000..db883a6ce36f9
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "system.memory"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json
new file mode 100644
index 0000000000000..7eb430632a81f
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json
@@ -0,0 +1,40 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "system.network"}}
+ ]
+ }
+ },
+ "chunking_config": {
+ "mode": "manual",
+ "time_span": "900s"
+ },
+ "aggregations": {
+ "host.name": {"terms": {"field": "host.name", "size": 100},
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {"field": "@timestamp","fixed_interval": "5m"},
+ "aggregations": {
+ "@timestamp": {"max": {"field": "@timestamp"}},
+ "bytes_in_max": {"max": {"field": "system.network.in.bytes"}},
+ "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}},
+ "positive_only":{
+ "bucket_script": {
+ "buckets_path": {"in_derivative": "bytes_in_derivative.value"},
+ "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json
new file mode 100644
index 0000000000000..427cb678ce663
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json
@@ -0,0 +1,40 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "system.network"}}
+ ]
+ }
+ },
+ "chunking_config": {
+ "mode": "manual",
+ "time_span": "900s"
+ },
+ "aggregations": {
+ "host.name": {"terms": {"field": "host.name", "size": 100},
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {"field": "@timestamp","fixed_interval": "5m"},
+ "aggregations": {
+ "@timestamp": {"max": {"field": "@timestamp"}},
+ "bytes_out_max": {"max": {"field": "system.network.out.bytes"}},
+ "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}},
+ "positive_only":{
+ "bucket_script": {
+ "buckets_path": {"out_derivative": "bytes_out_derivative.value"},
+ "script": "params.out_derivative > 0.0 ? params.out_derivative : 0.0"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json
new file mode 100644
index 0000000000000..186c9dcdb27e5
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json
@@ -0,0 +1,50 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "hosts",
+ "metrics"
+ ],
+ "description": "Metrics: Hosts - Identify unusual spikes in memory usage across hosts.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max('system.memory.actual.used.pct')",
+ "function": "max",
+ "field_name": "system.memory.actual.used.pct",
+ "custom_rules": [
+ {
+ "actions": [
+ "skip_result"
+ ],
+ "conditions": [
+ {
+ "applies_to": "actual",
+ "operator": "lt",
+ "value": 0.1
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "influencers": [
+ "host.name"
+ ]
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "64mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-hosts",
+ "custom_urls": [
+ {
+ "url_name": "Host Metrics",
+ "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json
new file mode 100644
index 0000000000000..0054d90b1df33
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json
@@ -0,0 +1,37 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Metrics: Hosts - Identify unusual spikes in inbound traffic across hosts.",
+ "groups": [
+ "hosts",
+ "metrics"
+ ],
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max(bytes_in_derivative)",
+ "function": "max",
+ "field_name": "bytes_in_derivative"
+ }
+ ],
+ "influencers": [
+ "host.name"
+ ],
+ "summary_count_field_name": "doc_count"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-hosts",
+ "custom_urls": [
+ {
+ "url_name": "Host Metrics",
+ "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json
new file mode 100644
index 0000000000000..601cc3807c441
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json
@@ -0,0 +1,37 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Metrics: Hosts - Identify unusual spikes in outbound traffic across hosts.",
+ "groups": [
+ "hosts",
+ "metrics"
+ ],
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max(bytes_out_derivative)",
+ "function": "max",
+ "field_name": "bytes_out_derivative"
+ }
+ ],
+ "influencers": [
+ "host.name"
+ ],
+ "summary_count_field_name": "doc_count"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-hosts",
+ "custom_urls": [
+ {
+ "url_name": "Host Metrics",
+ "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json
new file mode 100644
index 0000000000000..63105a28c0ab1
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json
@@ -0,0 +1,3 @@
+{
+ "icon": "metricsApp"
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json
new file mode 100644
index 0000000000000..15336069e092b
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json
@@ -0,0 +1,38 @@
+{
+ "id": "metrics_ui_k8s",
+ "title": "Metrics Kubernetes",
+ "description": "Detect anomalous memory and network behavior on Kubernetes pods.",
+ "type": "Metricbeat Data",
+ "logoFile": "logo.json",
+ "jobs": [
+ {
+ "id": "k8s_memory_usage",
+ "file": "k8s_memory_usage.json"
+ },
+ {
+ "id": "k8s_network_in",
+ "file": "k8s_network_in.json"
+ },
+ {
+ "id": "k8s_network_out",
+ "file": "k8s_network_out.json"
+ }
+ ],
+ "datafeeds": [
+ {
+ "id": "datafeed-k8s_memory_usage",
+ "file": "datafeed_k8s_memory_usage.json",
+ "job_id": "k8s_memory_usage"
+ },
+ {
+ "id": "datafeed-k8s_network_in",
+ "file": "datafeed_k8s_network_in.json",
+ "job_id": "k8s_network_in"
+ },
+ {
+ "id": "datafeed-k8s_network_out",
+ "file": "datafeed_k8s_network_out.json",
+ "job_id": "k8s_network_out"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json
new file mode 100644
index 0000000000000..14590f743528e
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json
@@ -0,0 +1,17 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "kubernetes.pod.uid"}},
+ {"exists": {"field": "kubernetes.pod.memory"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json
new file mode 100644
index 0000000000000..4fa4c603ea049
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json
@@ -0,0 +1,44 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "kubernetes.pod.network"}}
+ ]
+ }
+ },
+ "chunking_config": {
+ "mode": "manual",
+ "time_span": "900s"
+ },
+ "aggregations": {
+ "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25},
+ "aggregations": {
+ "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100},
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {"field": "@timestamp","fixed_interval": "5m"},
+ "aggregations": {
+ "@timestamp": {"max": {"field": "@timestamp"}},
+ "bytes_in_max": {"max": {"field": "kubernetes.pod.network.rx.bytes"}},
+ "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}},
+ "positive_only":{
+ "bucket_script": {
+ "buckets_path": {"in_derivative": "bytes_in_derivative.value"},
+ "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json
new file mode 100644
index 0000000000000..633dd6bf490e7
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json
@@ -0,0 +1,44 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "indices_options": {
+ "allow_no_indices": true
+ },
+ "query": {
+ "bool": {
+ "must": [
+ {"exists": {"field": "kubernetes.pod.network"}}
+ ]
+ }
+ },
+ "chunking_config": {
+ "mode": "manual",
+ "time_span": "900s"
+ },
+ "aggregations": {
+ "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25},
+ "aggregations": {
+ "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100},
+ "aggregations": {
+ "buckets": {
+ "date_histogram": {"field": "@timestamp","fixed_interval": "5m"},
+ "aggregations": {
+ "@timestamp": {"max": {"field": "@timestamp"}},
+ "bytes_out_max": {"max": {"field": "kubernetes.pod.network.tx.bytes"}},
+ "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}},
+ "positive_only":{
+ "bucket_script": {
+ "buckets_path": {"pos_derivative": "bytes_out_derivative.value"},
+ "script": "params.pos_derivative > 0.0 ? params.pos_derivative : 0.0"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json
new file mode 100644
index 0000000000000..d3f58086e2fd5
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json
@@ -0,0 +1,53 @@
+{
+ "job_type": "anomaly_detector",
+ "groups": [
+ "k8s",
+ "metrics"
+ ],
+ "description": "Metrics: Kubernetes - Identify unusual spikes in memory usage across Kubernetes pods.",
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max('kubernetes.pod.memory.usage.node.pct')",
+ "function": "max",
+ "field_name": "kubernetes.pod.memory.usage.node.pct",
+ "partition_field_name": "kubernetes.namespace",
+ "custom_rules": [
+ {
+ "actions": [
+ "skip_result"
+ ],
+ "conditions": [
+ {
+ "applies_to": "actual",
+ "operator": "lt",
+ "value": 0.1
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "influencers": [
+ "kubernetes.namespace",
+ "kubernetes.node.name",
+ "kubernetes.pod.uid"
+ ]
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "64mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-k8s",
+ "custom_urls": [
+ {
+ "url_name": "Pod Metrics",
+ "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json
new file mode 100644
index 0000000000000..212b2681beb77
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json
@@ -0,0 +1,39 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Metrics: Kubernetes - Identify unusual spikes in inbound traffic across Kubernetes pods.",
+ "groups": [
+ "k8s",
+ "metrics"
+ ],
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max(bytes_in_derivative)",
+ "function": "max",
+ "field_name": "bytes_in_derivative",
+ "partition_field_name": "kubernetes.namespace"
+ }
+ ],
+ "influencers": [
+ "kubernetes.namespace",
+ "kubernetes.pod.uid"
+ ],
+ "summary_count_field_name": "doc_count"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-k8s",
+ "custom_urls": [
+ {
+ "url_name": "Pod Metrics",
+ "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+}
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json
new file mode 100644
index 0000000000000..b06b0ed5089ef
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json
@@ -0,0 +1,39 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Metrics: Kubernetes - Identify unusual spikes in outbound traffic across Kubernetes pods.",
+ "groups": [
+ "k8s",
+ "metrics"
+ ],
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "max(bytes_out_derivative)",
+ "function": "max",
+ "field_name": "bytes_out_derivative",
+ "partition_field_name": "kubernetes.namespace"
+ }
+ ],
+ "influencers": [
+ "kubernetes.namespace",
+ "kubernetes.pod.uid"
+ ],
+ "summary_count_field_name": "doc_count"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "analysis_limits": {
+ "model_memory_limit": "32mb"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-metrics-ui-k8s",
+ "custom_urls": [
+ {
+ "url_name": "Pod Metrics",
+ "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))"
+ }
+ ]
+ }
+}
diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts
index a3d060bb1faca..6c7cb8bf4dce0 100644
--- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts
+++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts
@@ -20,6 +20,8 @@ const moduleIds = [
'logs_ui_analysis',
'logs_ui_categories',
'metricbeat_system_ecs',
+ 'metrics_ui_hosts',
+ 'metrics_ui_k8s',
'nginx_ecs',
'sample_data_ecommerce',
'sample_data_weblogs',
From 9ac2fdfe261cbbbee14f4fbe00aa4de47cefd1bc Mon Sep 17 00:00:00 2001
From: Alison Goryachev
Date: Thu, 17 Sep 2020 09:39:00 -0400
Subject: [PATCH 03/32] [Mappings editor] Add support for constant_keyword
field type (#76564)
---
.../field_types/constant_keyword_type.tsx | 82 ++++++++++++++++++
.../fields/field_types/index.ts | 2 +
.../constants/data_types_definition.tsx | 21 +++++
.../constants/parameters_definition.tsx | 84 ++++++++++++++++++-
.../mappings_editor/types/document_fields.ts | 5 +-
.../application/services/documentation.ts | 4 +
6 files changed, 196 insertions(+), 2 deletions(-)
create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx
new file mode 100644
index 0000000000000..4c02171d49eec
--- /dev/null
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx
@@ -0,0 +1,82 @@
+/*
+ * 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, { FunctionComponent } from 'react';
+import { i18n } from '@kbn/i18n';
+
+import { documentationService } from '../../../../../../services/documentation';
+import { UseField, Field, JsonEditorField } from '../../../../shared_imports';
+import { getFieldConfig } from '../../../../lib';
+import { NormalizedField } from '../../../../types';
+import { AdvancedParametersSection, EditFieldFormRow, BasicParametersSection } from '../edit_field';
+
+interface Props {
+ field: NormalizedField;
+}
+
+export const ConstantKeywordType: FunctionComponent = ({ field }) => {
+ return (
+ <>
+
+ {/* Value field */}
+
+
+
+
+
+
+ {/* Meta field */}
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts
index d84d9c6ea40cf..0cf921f66451b 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts
@@ -28,6 +28,7 @@ import { ObjectType } from './object_type';
import { OtherType } from './other_type';
import { NestedType } from './nested_type';
import { JoinType } from './join_type';
+import { ConstantKeywordType } from './constant_keyword_type';
import { RankFeatureType } from './rank_feature_type';
import { WildcardType } from './wildcard_type';
@@ -54,6 +55,7 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = {
other: OtherType,
nested: NestedType,
join: JoinType,
+ constant_keyword: ConstantKeywordType,
rank_feature: RankFeatureType,
wildcard: WildcardType,
};
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx
index a8844c7a9b270..18d9c637bd45b 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx
@@ -71,6 +71,26 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {
),
},
+ constant_keyword: {
+ value: 'constant_keyword',
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.constantKeywordDescription', {
+ defaultMessage: 'Constant keyword',
+ }),
+ documentation: {
+ main: '/keyword.html#constant-keyword-field-type',
+ },
+ description: () => (
+
+ {'keyword'},
+ }}
+ />
+
+ ),
+ },
numeric: {
value: 'numeric',
label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.numericDescription', {
@@ -822,6 +842,7 @@ export const MAIN_TYPES: MainType[] = [
'binary',
'boolean',
'completion',
+ 'constant_keyword',
'date',
'date_nanos',
'dense_vector',
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx
index f2148f1f657a6..fd17dc1b8fd1e 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx
@@ -29,7 +29,7 @@ import { INDEX_DEFAULT } from './default_values';
import { TYPE_DEFINITION } from './data_types_definition';
const { toInt } = fieldFormatters;
-const { emptyField, containsCharsField, numberGreaterThanField } = fieldValidators;
+const { emptyField, containsCharsField, numberGreaterThanField, isJsonField } = fieldValidators;
const commonErrorMessages = {
smallerThanZero: i18n.translate(
@@ -404,6 +404,88 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio
},
schema: t.string,
},
+ value: {
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.valueLabel', {
+ defaultMessage: 'Value',
+ }),
+ },
+ schema: t.string,
+ },
+ meta: {
+ fieldConfig: {
+ defaultValue: '',
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaLabel', {
+ defaultMessage: 'Metadata',
+ }),
+ helpText: (
+ {JSON.stringify({ arbitrary_key: 'anything_goes' })},
+ }}
+ />
+ ),
+ validations: [
+ {
+ validator: isJsonField(
+ i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorJsonError', {
+ defaultMessage: 'Invalid JSON.',
+ }),
+ { allowEmptyString: true }
+ ),
+ },
+ {
+ validator: ({ value }: ValidationFuncArg) => {
+ if (typeof value !== 'string' || value.trim() === '') {
+ return;
+ }
+
+ const json = JSON.parse(value);
+ const valuesAreNotString = Object.values(json).some((v) => typeof v !== 'string');
+
+ if (Array.isArray(json)) {
+ return {
+ message: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorArraysNotAllowedError',
+ {
+ defaultMessage: 'Arrays are not allowed.',
+ }
+ ),
+ };
+ } else if (valuesAreNotString) {
+ return {
+ message: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorOnlyStringValuesAllowedError',
+ {
+ defaultMessage: 'Values must be a string.',
+ }
+ ),
+ };
+ }
+ },
+ },
+ ],
+ deserializer: (value: any) => {
+ if (value === '') {
+ return value;
+ }
+ return JSON.stringify(value, null, 2);
+ },
+ serializer: (value: string) => {
+ const parsed = JSON.parse(value);
+ // If an empty object was passed, strip out this value entirely.
+ if (!Object.keys(parsed).length) {
+ return undefined;
+ }
+ return parsed;
+ },
+ },
+ schema: t.any,
+ },
max_input_length: {
fieldConfig: {
defaultValue: 50,
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
index fd0e4ed32bfe8..c2a44152ae1ee 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts
@@ -59,6 +59,7 @@ export type MainType =
| 'geo_point'
| 'geo_shape'
| 'token_count'
+ | 'constant_keyword'
| 'wildcard'
/**
* 'other' is a special type that only exists inside of MappingsEditor as a placeholder
@@ -146,7 +147,9 @@ export type ParameterName =
| 'dims'
| 'depth_limit'
| 'relations'
- | 'max_shingle_size';
+ | 'max_shingle_size'
+ | 'value'
+ | 'meta';
export interface Parameter {
fieldConfig: FieldConfig;
diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts
index afc9c76f1afbe..c52b958d94ae1 100644
--- a/x-pack/plugins/index_management/public/application/services/documentation.ts
+++ b/x-pack/plugins/index_management/public/application/services/documentation.ts
@@ -123,6 +123,10 @@ class DocumentationService {
return `${this.esDocsBase}/ignore-malformed.html`;
}
+ public getMetaLink() {
+ return `${this.esDocsBase}/mapping-field-meta.html`;
+ }
+
public getFormatLink() {
return `${this.esDocsBase}/mapping-date-format.html`;
}
From 75a14594f2bacfe5999ea0065baa4e0cae916d75 Mon Sep 17 00:00:00 2001
From: Uladzislau Lasitsa
Date: Thu, 17 Sep 2020 17:22:41 +0300
Subject: [PATCH 04/32] Use Search API in TSVB (#76274)
* Use Search API for TSVB
* Fixed ci
* Fixed ci
* Use constants
* Fixed tests
* Fixed ci
* Fixed ci
* Back old rollup search
* Fixed test
* Fixed tests
* Fixed issue with series data
* Fixed comments
* Fixed comments
* Fixed unit test
* Deleted unused import
* Fixed comments
Co-authored-by: Elastic Machine
---
.../vis_type_timeseries/server/index.ts | 10 +--
.../server/lib/get_fields.ts | 17 +----
.../server/lib/get_vis_data.ts | 19 +----
.../search_requests/abstract_request.js | 28 -------
.../search_requests/abstract_request.test.js | 46 -----------
.../search_requests/multi_search_request.js | 49 ------------
.../multi_search_request.test.js | 67 ----------------
.../search_requests/search_request.js | 37 ---------
.../search_requests/search_request.test.js | 76 -------------------
.../search_requests/single_search_request.js | 37 ---------
.../single_search_request.test.js | 59 --------------
.../search_strategies_registry.test.ts | 4 +-
.../abstract_search_strategy.test.js | 69 ++++++++++-------
.../strategies/abstract_search_strategy.ts | 55 ++++++++------
.../strategies/default_search_strategy.js | 13 +---
.../default_search_strategy.test.js | 28 +------
.../server/lib/vis_data/get_annotations.js | 6 +-
.../server/lib/vis_data/get_series_data.js | 10 ++-
.../server/lib/vis_data/get_table_data.js | 10 ++-
.../vis_type_timeseries/server/plugin.ts | 12 ++-
.../vis_type_timeseries/server/routes/vis.ts | 3 +-
x-pack/plugins/data_enhanced/server/index.ts | 2 +
.../register_rollup_search_strategy.test.js | 11 +--
.../register_rollup_search_strategy.ts | 13 ++--
.../rollup_search_request.test.js | 53 -------------
.../rollup_search_request.ts | 28 -------
.../rollup_search_strategy.test.js | 44 +++++++----
.../rollup_search_strategy.ts | 46 ++++++-----
x-pack/plugins/rollup/server/plugin.ts | 20 ++---
x-pack/plugins/rollup/server/types.ts | 8 +-
30 files changed, 193 insertions(+), 687 deletions(-)
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js
delete mode 100644 src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js
delete mode 100644 x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js
delete mode 100644 x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts
diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts
index f460257caf5e3..333ed0ff64fdb 100644
--- a/src/plugins/vis_type_timeseries/server/index.ts
+++ b/src/plugins/vis_type_timeseries/server/index.ts
@@ -21,7 +21,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/serve
import { VisTypeTimeseriesConfig, config as configSchema } from './config';
import { VisTypeTimeseriesPlugin } from './plugin';
-export { VisTypeTimeseriesSetup, Framework } from './plugin';
+export { VisTypeTimeseriesSetup } from './plugin';
export const config: PluginConfigDescriptor = {
deprecations: ({ unused, renameFromRoot }) => [
@@ -39,10 +39,10 @@ export const config: PluginConfigDescriptor = {
export { ValidationTelemetryServiceSetup } from './validation_telemetry';
-// @ts-ignore
-export { AbstractSearchStrategy } from './lib/search_strategies/strategies/abstract_search_strategy';
-// @ts-ignore
-export { AbstractSearchRequest } from './lib/search_strategies/search_requests/abstract_request';
+export {
+ AbstractSearchStrategy,
+ ReqFacade,
+} from './lib/search_strategies/strategies/abstract_search_strategy';
// @ts-ignore
export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities';
diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
index 0f0d99bff6f1c..777de89672bbe 100644
--- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts
@@ -38,6 +38,7 @@ export async function getFields(
// level object passed from here. The layers should be refactored fully at some point, but for now
// this works and we are still using the New Platform services for these vis data portions.
const reqFacade: ReqFacade = {
+ requestContext,
...request,
framework,
payload: {},
@@ -48,22 +49,6 @@ export async function getFields(
},
getUiSettingsService: () => requestContext.core.uiSettings.client,
getSavedObjectsClient: () => requestContext.core.savedObjects.client,
- server: {
- plugins: {
- elasticsearch: {
- getCluster: () => {
- return {
- callWithRequest: async (req: any, endpoint: string, params: any) => {
- return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser(
- endpoint,
- params
- );
- },
- };
- },
- },
- },
- },
getEsShardTimeout: async () => {
return await framework.globalConfig$
.pipe(
diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
index f697e754a2e00..5eef2b53e2431 100644
--- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts
@@ -21,7 +21,7 @@ import { FakeRequest, RequestHandlerContext } from 'kibana/server';
import _ from 'lodash';
import { first, map } from 'rxjs/operators';
import { getPanelData } from './vis_data/get_panel_data';
-import { Framework } from '../index';
+import { Framework } from '../plugin';
import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy';
interface GetVisDataResponse {
@@ -65,28 +65,13 @@ export function getVisData(
// level object passed from here. The layers should be refactored fully at some point, but for now
// this works and we are still using the New Platform services for these vis data portions.
const reqFacade: ReqFacade = {
+ requestContext,
...request,
framework,
pre: {},
payload: request.body,
getUiSettingsService: () => requestContext.core.uiSettings.client,
getSavedObjectsClient: () => requestContext.core.savedObjects.client,
- server: {
- plugins: {
- elasticsearch: {
- getCluster: () => {
- return {
- callWithRequest: async (req: any, endpoint: string, params: any) => {
- return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser(
- endpoint,
- params
- );
- },
- };
- },
- },
- },
- },
getEsShardTimeout: async () => {
return await framework.globalConfig$
.pipe(
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js
deleted file mode 100644
index abd2a4c65d35c..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-export class AbstractSearchRequest {
- constructor(req, callWithRequest) {
- this.req = req;
- this.callWithRequest = callWithRequest;
- }
-
- search() {
- throw new Error('AbstractSearchRequest: search method should be defined');
- }
-}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js
deleted file mode 100644
index 6f71aa63728d5..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { AbstractSearchRequest } from './abstract_request';
-
-describe('AbstractSearchRequest', () => {
- let searchRequest;
- let req;
- let callWithRequest;
-
- beforeEach(() => {
- req = {};
- callWithRequest = jest.fn();
- searchRequest = new AbstractSearchRequest(req, callWithRequest);
- });
-
- test('should init an AbstractSearchRequest instance', () => {
- expect(searchRequest.req).toBe(req);
- expect(searchRequest.callWithRequest).toBe(callWithRequest);
- expect(searchRequest.search).toBeDefined();
- });
-
- test('should throw an error trying to search', () => {
- try {
- searchRequest.search();
- } catch (error) {
- expect(error instanceof Error).toBe(true);
- expect(error.message).toEqual('AbstractSearchRequest: search method should be defined');
- }
- });
-});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js
deleted file mode 100644
index 9ada39e359589..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { AbstractSearchRequest } from './abstract_request';
-import { UI_SETTINGS } from '../../../../../data/server';
-
-const SEARCH_METHOD = 'msearch';
-
-export class MultiSearchRequest extends AbstractSearchRequest {
- async search(searches) {
- const includeFrozen = await this.req
- .getUiSettingsService()
- .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN);
- const multiSearchBody = searches.reduce(
- (acc, { body, index }) => [
- ...acc,
- {
- index,
- ignoreUnavailable: true,
- },
- body,
- ],
- []
- );
-
- const { responses } = await this.callWithRequest(this.req, SEARCH_METHOD, {
- body: multiSearchBody,
- rest_total_hits_as_int: true,
- ignore_throttled: !includeFrozen,
- });
-
- return responses;
- }
-}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js
deleted file mode 100644
index c113db76332b7..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { MultiSearchRequest } from './multi_search_request';
-import { UI_SETTINGS } from '../../../../../data/server';
-
-describe('MultiSearchRequest', () => {
- let searchRequest;
- let req;
- let callWithRequest;
- let getServiceMock;
- let includeFrozen;
-
- beforeEach(() => {
- includeFrozen = false;
- getServiceMock = jest.fn().mockResolvedValue(includeFrozen);
- req = {
- getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }),
- };
- callWithRequest = jest.fn().mockReturnValue({ responses: [] });
- searchRequest = new MultiSearchRequest(req, callWithRequest);
- });
-
- test('should init an MultiSearchRequest instance', () => {
- expect(searchRequest.req).toBe(req);
- expect(searchRequest.callWithRequest).toBe(callWithRequest);
- expect(searchRequest.search).toBeDefined();
- });
-
- test('should get the response from elastic msearch', async () => {
- const searches = [
- { body: 'body1', index: 'index' },
- { body: 'body2', index: 'index' },
- ];
-
- const responses = await searchRequest.search(searches);
-
- expect(responses).toEqual([]);
- expect(req.getUiSettingsService).toHaveBeenCalled();
- expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN);
- expect(callWithRequest).toHaveBeenCalledWith(req, 'msearch', {
- body: [
- { ignoreUnavailable: true, index: 'index' },
- 'body1',
- { ignoreUnavailable: true, index: 'index' },
- 'body2',
- ],
- rest_total_hits_as_int: true,
- ignore_throttled: !includeFrozen,
- });
- });
-});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js
deleted file mode 100644
index e6e3bcb527286..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { AbstractSearchRequest } from './abstract_request';
-
-import { MultiSearchRequest } from './multi_search_request';
-import { SingleSearchRequest } from './single_search_request';
-
-export class SearchRequest extends AbstractSearchRequest {
- getSearchRequestType(searches) {
- const isMultiSearch = Array.isArray(searches) && searches.length > 1;
- const SearchRequest = isMultiSearch ? MultiSearchRequest : SingleSearchRequest;
-
- return new SearchRequest(this.req, this.callWithRequest);
- }
-
- async search(options) {
- const concreteSearchRequest = this.getSearchRequestType(options);
-
- return concreteSearchRequest.search(options);
- }
-}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js
deleted file mode 100644
index 3d35a4aa37c5a..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { SearchRequest } from './search_request';
-import { MultiSearchRequest } from './multi_search_request';
-import { SingleSearchRequest } from './single_search_request';
-
-describe('SearchRequest', () => {
- let searchRequest;
- let req;
- let callWithRequest;
- let getServiceMock;
- let includeFrozen;
-
- beforeEach(() => {
- includeFrozen = false;
- getServiceMock = jest.fn().mockResolvedValue(includeFrozen);
- req = {
- getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }),
- };
- callWithRequest = jest.fn().mockReturnValue({ responses: [] });
- searchRequest = new SearchRequest(req, callWithRequest);
- });
-
- test('should init an AbstractSearchRequest instance', () => {
- expect(searchRequest.req).toBe(req);
- expect(searchRequest.callWithRequest).toBe(callWithRequest);
- expect(searchRequest.search).toBeDefined();
- });
-
- test('should return search value', async () => {
- const concreteSearchRequest = {
- search: jest.fn().mockReturnValue('concreteSearchRequest'),
- };
- const options = {};
- searchRequest.getSearchRequestType = jest.fn().mockReturnValue(concreteSearchRequest);
-
- const result = await searchRequest.search(options);
-
- expect(result).toBe('concreteSearchRequest');
- });
-
- test('should return a MultiSearchRequest for multi searches', () => {
- const searches = [
- { index: 'index', body: 'body' },
- { index: 'index', body: 'body' },
- ];
-
- const result = searchRequest.getSearchRequestType(searches);
-
- expect(result instanceof MultiSearchRequest).toBe(true);
- });
-
- test('should return a SingleSearchRequest for single search', () => {
- const searches = [{ index: 'index', body: 'body' }];
-
- const result = searchRequest.getSearchRequestType(searches);
-
- expect(result instanceof SingleSearchRequest).toBe(true);
- });
-});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js
deleted file mode 100644
index 7d8b60a7e4595..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { AbstractSearchRequest } from './abstract_request';
-import { UI_SETTINGS } from '../../../../../data/server';
-
-const SEARCH_METHOD = 'search';
-
-export class SingleSearchRequest extends AbstractSearchRequest {
- async search([{ body, index }]) {
- const includeFrozen = await this.req
- .getUiSettingsService()
- .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN);
- const resp = await this.callWithRequest(this.req, SEARCH_METHOD, {
- ignore_throttled: !includeFrozen,
- body,
- index,
- });
-
- return [resp];
- }
-}
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js
deleted file mode 100644
index b899814f2fe13..0000000000000
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { SingleSearchRequest } from './single_search_request';
-import { UI_SETTINGS } from '../../../../../data/server';
-
-describe('SingleSearchRequest', () => {
- let searchRequest;
- let req;
- let callWithRequest;
- let getServiceMock;
- let includeFrozen;
-
- beforeEach(() => {
- includeFrozen = false;
- getServiceMock = jest.fn().mockResolvedValue(includeFrozen);
- req = {
- getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }),
- };
- callWithRequest = jest.fn().mockReturnValue({});
- searchRequest = new SingleSearchRequest(req, callWithRequest);
- });
-
- test('should init an SingleSearchRequest instance', () => {
- expect(searchRequest.req).toBe(req);
- expect(searchRequest.callWithRequest).toBe(callWithRequest);
- expect(searchRequest.search).toBeDefined();
- });
-
- test('should get the response from elastic search', async () => {
- const searches = [{ body: 'body', index: 'index' }];
-
- const responses = await searchRequest.search(searches);
-
- expect(responses).toEqual([{}]);
- expect(req.getUiSettingsService).toHaveBeenCalled();
- expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN);
- expect(callWithRequest).toHaveBeenCalledWith(req, 'search', {
- body: 'body',
- index: 'index',
- ignore_throttled: !includeFrozen,
- });
- });
-});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
index ecd09653b3b48..66ea4f017dd90 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts
@@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => {
});
test('should add a strategy if it is an instance of AbstractSearchStrategy', () => {
- const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {});
+ const anotherSearchStrategy = new MockSearchStrategy('es');
const addedStrategies = registry.addStrategy(anotherSearchStrategy);
expect(addedStrategies.length).toEqual(2);
@@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => {
test('should return a MockSearchStrategy instance', async () => {
const req = {};
const indexPattern = '*';
- const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {});
+ const anotherSearchStrategy = new MockSearchStrategy('es');
registry.addStrategy(anotherSearchStrategy);
const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!;
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js
index 1fbaffd794c89..6773ee482b098 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js
@@ -18,24 +18,13 @@
*/
import { AbstractSearchStrategy } from './abstract_search_strategy';
-class SearchRequest {
- constructor(req, callWithRequest) {
- this.req = req;
- this.callWithRequest = callWithRequest;
- }
-}
-
describe('AbstractSearchStrategy', () => {
let abstractSearchStrategy;
- let server;
- let callWithRequestFactory;
let req;
let mockedFields;
let indexPattern;
beforeEach(() => {
- server = {};
- callWithRequestFactory = jest.fn().mockReturnValue('callWithRequest');
mockedFields = {};
req = {
pre: {
@@ -45,16 +34,11 @@ describe('AbstractSearchStrategy', () => {
},
};
- abstractSearchStrategy = new AbstractSearchStrategy(
- server,
- callWithRequestFactory,
- SearchRequest
- );
+ abstractSearchStrategy = new AbstractSearchStrategy('es');
});
test('should init an AbstractSearchStrategy instance', () => {
- expect(abstractSearchStrategy.getCallWithRequestInstance).toBeDefined();
- expect(abstractSearchStrategy.getSearchRequest).toBeDefined();
+ expect(abstractSearchStrategy.search).toBeDefined();
expect(abstractSearchStrategy.getFieldsForWildcard).toBeDefined();
expect(abstractSearchStrategy.checkForViability).toBeDefined();
});
@@ -68,17 +52,46 @@ describe('AbstractSearchStrategy', () => {
});
});
- test('should invoke callWithRequestFactory with req param passed', () => {
- abstractSearchStrategy.getCallWithRequestInstance(req);
+ test('should return response', async () => {
+ const searches = [{ body: 'body', index: 'index' }];
+ const searchFn = jest.fn().mockReturnValue(Promise.resolve({}));
- expect(callWithRequestFactory).toHaveBeenCalledWith(server, req);
- });
-
- test('should return a search request', () => {
- const searchRequest = abstractSearchStrategy.getSearchRequest(req);
+ const responses = await abstractSearchStrategy.search(
+ {
+ requestContext: {},
+ framework: {
+ core: {
+ getStartServices: jest.fn().mockReturnValue(
+ Promise.resolve([
+ {},
+ {
+ data: {
+ search: {
+ search: searchFn,
+ },
+ },
+ },
+ ])
+ ),
+ },
+ },
+ },
+ searches
+ );
- expect(searchRequest instanceof SearchRequest).toBe(true);
- expect(searchRequest.callWithRequest).toBe('callWithRequest');
- expect(searchRequest.req).toBe(req);
+ expect(responses).toEqual([{}]);
+ expect(searchFn).toHaveBeenCalledWith(
+ {},
+ {
+ params: {
+ body: 'body',
+ index: 'index',
+ },
+ indexType: undefined,
+ },
+ {
+ strategy: 'es',
+ }
+ );
});
});
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
index 0b1c6e6e20414..92b7e6976962e 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts
@@ -18,7 +18,7 @@
*/
import {
- LegacyAPICaller,
+ RequestHandlerContext,
FakeRequest,
IUiSettingsClient,
SavedObjectsClientContract,
@@ -33,6 +33,7 @@ import { IndexPatternsFetcher } from '../../../../../data/server';
* This will be replaced by standard KibanaRequest and RequestContext objects in a later version.
*/
export type ReqFacade = FakeRequest & {
+ requestContext: RequestHandlerContext;
framework: Framework;
payload: unknown;
pre: {
@@ -40,34 +41,42 @@ export type ReqFacade = FakeRequest & {
};
getUiSettingsService: () => IUiSettingsClient;
getSavedObjectsClient: () => SavedObjectsClientContract;
- server: {
- plugins: {
- elasticsearch: {
- getCluster: () => {
- callWithRequest: (req: ReqFacade, endpoint: string, params: any) => Promise;
- };
- };
- };
- };
getEsShardTimeout: () => Promise;
};
export class AbstractSearchStrategy {
- public getCallWithRequestInstance: (req: ReqFacade) => LegacyAPICaller;
- public getSearchRequest: (req: ReqFacade) => any;
-
- constructor(
- server: any,
- callWithRequestFactory: (server: any, req: ReqFacade) => LegacyAPICaller,
- SearchRequest: any
- ) {
- this.getCallWithRequestInstance = (req) => callWithRequestFactory(server, req);
+ public searchStrategyName!: string;
+ public indexType?: string;
+ public additionalParams: any;
- this.getSearchRequest = (req) => {
- const callWithRequest = this.getCallWithRequestInstance(req);
+ constructor(name: string, type?: string, additionalParams: any = {}) {
+ this.searchStrategyName = name;
+ this.indexType = type;
+ this.additionalParams = additionalParams;
+ }
- return new SearchRequest(req, callWithRequest);
- };
+ async search(req: ReqFacade, bodies: any[], options = {}) {
+ const [, deps] = await req.framework.core.getStartServices();
+ const requests: any[] = [];
+ bodies.forEach((body) => {
+ requests.push(
+ deps.data.search.search(
+ req.requestContext,
+ {
+ params: {
+ ...body,
+ ...this.additionalParams,
+ },
+ indexType: this.indexType,
+ },
+ {
+ ...options,
+ strategy: this.searchStrategyName,
+ }
+ )
+ );
+ });
+ return Promise.all(requests);
}
async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) {
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js
index 63f2911ce1118..7c3609ae3c405 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js
@@ -16,21 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
+
+import { ES_SEARCH_STRATEGY } from '../../../../../data/server';
import { AbstractSearchStrategy } from './abstract_search_strategy';
-import { SearchRequest } from '../search_requests/search_request';
import { DefaultSearchCapabilities } from '../default_search_capabilities';
-const callWithRequestFactory = (server, request) => {
- const { callWithRequest } = request.server.plugins.elasticsearch.getCluster('data');
-
- return callWithRequest;
-};
-
export class DefaultSearchStrategy extends AbstractSearchStrategy {
name = 'default';
- constructor(server) {
- super(server, callWithRequestFactory, SearchRequest);
+ constructor() {
+ super(ES_SEARCH_STRATEGY);
}
checkForViability(req) {
diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js
index 2e3a459bf06fd..a9994ba3e1f75 100644
--- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js
+++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js
@@ -20,42 +20,20 @@ import { DefaultSearchStrategy } from './default_search_strategy';
describe('DefaultSearchStrategy', () => {
let defaultSearchStrategy;
- let server;
- let callWithRequest;
let req;
beforeEach(() => {
- server = {};
- callWithRequest = jest.fn();
- req = {
- server: {
- plugins: {
- elasticsearch: {
- getCluster: jest.fn().mockReturnValue({
- callWithRequest,
- }),
- },
- },
- },
- };
- defaultSearchStrategy = new DefaultSearchStrategy(server);
+ req = {};
+ defaultSearchStrategy = new DefaultSearchStrategy();
});
test('should init an DefaultSearchStrategy instance', () => {
expect(defaultSearchStrategy.name).toBe('default');
expect(defaultSearchStrategy.checkForViability).toBeDefined();
- expect(defaultSearchStrategy.getCallWithRequestInstance).toBeDefined();
- expect(defaultSearchStrategy.getSearchRequest).toBeDefined();
+ expect(defaultSearchStrategy.search).toBeDefined();
expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined();
});
- test('should invoke callWithRequestFactory with passed params', () => {
- const value = defaultSearchStrategy.getCallWithRequestInstance(req);
-
- expect(value).toBe(callWithRequest);
- expect(req.server.plugins.elasticsearch.getCluster).toHaveBeenCalledWith('data');
- });
-
test('should check a strategy for viability', () => {
const value = defaultSearchStrategy.checkForViability(req);
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js
index b015aaf0ef8db..d8a230dfeef4e 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js
@@ -39,7 +39,6 @@ export async function getAnnotations({
capabilities,
series,
}) {
- const searchRequest = searchStrategy.getSearchRequest(req);
const annotations = panel.annotations.filter(validAnnotation);
const lastSeriesTimestamp = getLastSeriesTimestamp(series);
const handleAnnotationResponseBy = handleAnnotationResponse(lastSeriesTimestamp);
@@ -47,6 +46,7 @@ export async function getAnnotations({
const bodiesPromises = annotations.map((annotation) =>
getAnnotationRequestParams(req, panel, annotation, esQueryConfig, capabilities)
);
+
const searches = (await Promise.all(bodiesPromises)).reduce(
(acc, items) => acc.concat(items),
[]
@@ -55,10 +55,10 @@ export async function getAnnotations({
if (!searches.length) return { responses: [] };
try {
- const data = await searchRequest.search(searches);
+ const data = await searchStrategy.search(req.framework.core, req.requestContext, searches);
return annotations.reduce((acc, annotation, index) => {
- acc[annotation.id] = handleAnnotationResponseBy(data[index], annotation);
+ acc[annotation.id] = handleAnnotationResponseBy(data[index].rawResponse, annotation);
return acc;
}, {});
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js
index ee48816c6a8af..1eace13c2e336 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js
@@ -28,7 +28,6 @@ export async function getSeriesData(req, panel) {
searchStrategy,
capabilities,
} = await req.framework.searchStrategyRegistry.getViableStrategyForPanel(req, panel);
- const searchRequest = searchStrategy.getSearchRequest(req);
const esQueryConfig = await getEsQueryConfig(req);
const meta = {
type: panel.type,
@@ -45,8 +44,13 @@ export async function getSeriesData(req, panel) {
[]
);
- const data = await searchRequest.search(searches);
- const series = data.map(handleResponseBody(panel));
+ const data = await searchStrategy.search(req, searches);
+
+ const handleResponseBodyFn = handleResponseBody(panel);
+
+ const series = data.map((resp) =>
+ handleResponseBodyFn(resp.rawResponse ? resp.rawResponse : resp)
+ );
let annotations = null;
if (panel.annotations && panel.annotations.length) {
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js
index 1d1c245907959..3791eb229db5b 100644
--- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js
+++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js
@@ -30,7 +30,6 @@ export async function getTableData(req, panel) {
searchStrategy,
capabilities,
} = await req.framework.searchStrategyRegistry.getViableStrategy(req, panelIndexPattern);
- const searchRequest = searchStrategy.getSearchRequest(req);
const esQueryConfig = await getEsQueryConfig(req);
const { indexPatternObject } = await getIndexPatternObject(req, panelIndexPattern);
@@ -41,13 +40,18 @@ export async function getTableData(req, panel) {
try {
const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities);
- const [resp] = await searchRequest.search([
+ const [resp] = await searchStrategy.search(req, [
{
body,
index: panelIndexPattern,
},
]);
- const buckets = get(resp, 'aggregations.pivot.buckets', []);
+
+ const buckets = get(
+ resp.rawResponse ? resp.rawResponse : resp,
+ 'aggregations.pivot.buckets',
+ []
+ );
return {
...meta,
diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts
index d863937a4e3dc..678ba2b371978 100644
--- a/src/plugins/vis_type_timeseries/server/plugin.ts
+++ b/src/plugins/vis_type_timeseries/server/plugin.ts
@@ -33,6 +33,7 @@ import { VisTypeTimeseriesConfig } from './config';
import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data';
import { ValidationTelemetryService } from './validation_telemetry';
import { UsageCollectionSetup } from '../../usage_collection/server';
+import { PluginStart } from '../../data/server';
import { visDataRoutes } from './routes/vis';
// @ts-ignore
import { fieldsRoutes } from './routes/fields';
@@ -47,6 +48,10 @@ interface VisTypeTimeseriesPluginSetupDependencies {
usageCollection?: UsageCollectionSetup;
}
+interface VisTypeTimeseriesPluginStartDependencies {
+ data: PluginStart;
+}
+
export interface VisTypeTimeseriesSetup {
getVisData: (
requestContext: RequestHandlerContext,
@@ -57,7 +62,7 @@ export interface VisTypeTimeseriesSetup {
}
export interface Framework {
- core: CoreSetup;
+ core: CoreSetup;
plugins: any;
config$: Observable;
globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$'];
@@ -74,7 +79,10 @@ export class VisTypeTimeseriesPlugin implements Plugin {
this.validationTelementryService = new ValidationTelemetryService();
}
- public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) {
+ public setup(
+ core: CoreSetup,
+ plugins: VisTypeTimeseriesPluginSetupDependencies
+ ) {
const logger = this.initializerContext.logger.get('visTypeTimeseries');
core.uiSettings.register(uiSettings);
const config$ = this.initializerContext.config.create();
diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts
index 48efd4398e4d4..1ca8b57ab230f 100644
--- a/src/plugins/vis_type_timeseries/server/routes/vis.ts
+++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts
@@ -21,7 +21,8 @@ import { IRouter, KibanaRequest } from 'kibana/server';
import { schema } from '@kbn/config-schema';
import { getVisData, GetVisDataOptions } from '../lib/get_vis_data';
import { visPayloadSchema } from '../../common/vis_schema';
-import { Framework, ValidationTelemetryServiceSetup } from '../index';
+import { ValidationTelemetryServiceSetup } from '../index';
+import { Framework } from '../plugin';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts
index fbe1ecc10d632..3c5d5d1e99d13 100644
--- a/x-pack/plugins/data_enhanced/server/index.ts
+++ b/x-pack/plugins/data_enhanced/server/index.ts
@@ -11,4 +11,6 @@ export function plugin(initializerContext: PluginInitializerContext) {
return new EnhancedDataServerPlugin(initializerContext);
}
+export { ENHANCED_ES_SEARCH_STRATEGY } from '../common';
+
export { EnhancedDataServerPlugin as Plugin };
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js
index d466ebd69737e..8672a8b8f6849 100644
--- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js
+++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js
@@ -6,21 +6,16 @@
import { registerRollupSearchStrategy } from './register_rollup_search_strategy';
describe('Register Rollup Search Strategy', () => {
- let routeDependencies;
let addSearchStrategy;
+ let getRollupService;
beforeEach(() => {
- routeDependencies = {
- router: jest.fn().mockName('router'),
- elasticsearchService: jest.fn().mockName('elasticsearchService'),
- elasticsearch: jest.fn().mockName('elasticsearch'),
- };
-
addSearchStrategy = jest.fn().mockName('addSearchStrategy');
+ getRollupService = jest.fn().mockName('getRollupService');
});
test('should run initialization', () => {
- registerRollupSearchStrategy(routeDependencies, addSearchStrategy);
+ registerRollupSearchStrategy(addSearchStrategy, getRollupService);
expect(addSearchStrategy).toHaveBeenCalled();
});
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts
index 333863979ba95..22dafbb71d802 100644
--- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts
+++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts
@@ -4,27 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { ILegacyScopedClusterClient } from 'src/core/server';
import {
- AbstractSearchRequest,
DefaultSearchCapabilities,
AbstractSearchStrategy,
+ ReqFacade,
} from '../../../../../../src/plugins/vis_type_timeseries/server';
-import { CallWithRequestFactoryShim } from '../../types';
import { getRollupSearchStrategy } from './rollup_search_strategy';
-import { getRollupSearchRequest } from './rollup_search_request';
import { getRollupSearchCapabilities } from './rollup_search_capabilities';
export const registerRollupSearchStrategy = (
- callWithRequestFactory: CallWithRequestFactoryShim,
- addSearchStrategy: (searchStrategy: any) => void
+ addSearchStrategy: (searchStrategy: any) => void,
+ getRollupService: (reg: ReqFacade) => Promise
) => {
- const RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest);
const RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities);
const RollupSearchStrategy = getRollupSearchStrategy(
AbstractSearchStrategy,
- RollupSearchRequest,
RollupSearchCapabilities,
- callWithRequestFactory
+ getRollupService
);
addSearchStrategy(new RollupSearchStrategy());
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js
deleted file mode 100644
index 2ea0612140946..0000000000000
--- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js
+++ /dev/null
@@ -1,53 +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 { getRollupSearchRequest } from './rollup_search_request';
-
-class AbstractSearchRequest {
- indexPattern = 'indexPattern';
- callWithRequest = jest.fn(({ body }) => Promise.resolve(body));
-}
-
-describe('Rollup search request', () => {
- let RollupSearchRequest;
-
- beforeEach(() => {
- RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest);
- });
-
- test('should create instance of RollupSearchRequest', () => {
- const rollupSearchRequest = new RollupSearchRequest();
-
- expect(rollupSearchRequest).toBeInstanceOf(AbstractSearchRequest);
- expect(rollupSearchRequest.search).toBeDefined();
- expect(rollupSearchRequest.callWithRequest).toBeDefined();
- });
-
- test('should send one request for single search', async () => {
- const rollupSearchRequest = new RollupSearchRequest();
- const searches = [{ body: 'body', index: 'index' }];
-
- await rollupSearchRequest.search(searches);
-
- expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(1);
- expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledWith('rollup.search', {
- body: 'body',
- index: 'index',
- rest_total_hits_as_int: true,
- });
- });
-
- test('should send multiple request for multi search', async () => {
- const rollupSearchRequest = new RollupSearchRequest();
- const searches = [
- { body: 'body', index: 'index' },
- { body: 'body1', index: 'index' },
- ];
-
- await rollupSearchRequest.search(searches);
-
- expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts
deleted file mode 100644
index 7e12d5286f34c..0000000000000
--- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts
+++ /dev/null
@@ -1,28 +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.
- */
-const SEARCH_METHOD = 'rollup.search';
-
-interface Search {
- index: string;
- body: {
- [key: string]: any;
- };
-}
-
-export const getRollupSearchRequest = (AbstractSearchRequest: any) =>
- class RollupSearchRequest extends AbstractSearchRequest {
- async search(searches: Search[]) {
- const requests = searches.map(({ body, index }) =>
- this.callWithRequest(SEARCH_METHOD, {
- body,
- index,
- rest_total_hits_as_int: true,
- })
- );
-
- return await Promise.all(requests);
- }
- };
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js
index 63f4628e36bfe..f3da7ed3fdd17 100644
--- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js
+++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js
@@ -7,13 +7,32 @@ import { getRollupSearchStrategy } from './rollup_search_strategy';
describe('Rollup Search Strategy', () => {
let RollupSearchStrategy;
- let RollupSearchRequest;
let RollupSearchCapabilities;
let callWithRequest;
let rollupResolvedData;
- const server = 'server';
- const request = 'request';
+ const request = {
+ requestContext: {
+ core: {
+ elasticsearch: {
+ client: {
+ asCurrentUser: {
+ rollup: {
+ getRollupIndexCaps: jest.fn().mockImplementation(() => rollupResolvedData),
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ const getRollupService = jest.fn().mockImplementation(() => {
+ return {
+ callAsCurrentUser: async () => {
+ return rollupResolvedData;
+ },
+ };
+ });
const indexPattern = 'indexPattern';
beforeEach(() => {
@@ -33,19 +52,17 @@ describe('Rollup Search Strategy', () => {
}
}
- RollupSearchRequest = jest.fn();
RollupSearchCapabilities = jest.fn(() => 'capabilities');
- callWithRequest = jest.fn().mockImplementation(() => rollupResolvedData);
RollupSearchStrategy = getRollupSearchStrategy(
AbstractSearchStrategy,
- RollupSearchRequest,
- RollupSearchCapabilities
+ RollupSearchCapabilities,
+ getRollupService
);
});
test('should create instance of RollupSearchRequest', () => {
- const rollupSearchStrategy = new RollupSearchStrategy(server);
+ const rollupSearchStrategy = new RollupSearchStrategy();
expect(rollupSearchStrategy.name).toBe('rollup');
});
@@ -55,7 +72,7 @@ describe('Rollup Search Strategy', () => {
const rollupIndex = 'rollupIndex';
beforeEach(() => {
- rollupSearchStrategy = new RollupSearchStrategy(server);
+ rollupSearchStrategy = new RollupSearchStrategy();
rollupSearchStrategy.getRollupData = jest.fn(() => ({
[rollupIndex]: {
rollup_jobs: [
@@ -104,7 +121,7 @@ describe('Rollup Search Strategy', () => {
let rollupSearchStrategy;
beforeEach(() => {
- rollupSearchStrategy = new RollupSearchStrategy(server);
+ rollupSearchStrategy = new RollupSearchStrategy();
});
test('should return rollup data', async () => {
@@ -112,10 +129,7 @@ describe('Rollup Search Strategy', () => {
const rollupData = await rollupSearchStrategy.getRollupData(request, indexPattern);
- expect(callWithRequest).toHaveBeenCalledWith('rollup.rollupIndexCapabilities', {
- indexPattern,
- });
- expect(rollupSearchStrategy.getCallWithRequestInstance).toHaveBeenCalledWith(request);
+ expect(getRollupService).toHaveBeenCalled();
expect(rollupData).toBe('data');
});
@@ -135,7 +149,7 @@ describe('Rollup Search Strategy', () => {
const rollupIndex = 'rollupIndex';
beforeEach(() => {
- rollupSearchStrategy = new RollupSearchStrategy(server);
+ rollupSearchStrategy = new RollupSearchStrategy();
fieldsCapabilities = {
[rollupIndex]: {
aggs: {
diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
index 885836780f1a9..e7794caf8697b 100644
--- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
+++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts
@@ -4,15 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { keyBy, isString } from 'lodash';
-import { KibanaRequest } from 'src/core/server';
-
-import { CallWithRequestFactoryShim } from '../../types';
+import { ILegacyScopedClusterClient } from 'src/core/server';
+import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server';
+import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/server';
import { mergeCapabilitiesWithFields } from '../merge_capabilities_with_fields';
import { getCapabilitiesForRollupIndices } from '../map_capabilities';
-const ROLLUP_INDEX_CAPABILITIES_METHOD = 'rollup.rollupIndexCapabilities';
-
-const getRollupIndices = (rollupData: { [key: string]: any[] }) => Object.keys(rollupData);
+const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData);
const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*');
const isIndexPatternValid = (indexPattern: string) =>
@@ -20,28 +18,40 @@ const isIndexPatternValid = (indexPattern: string) =>
export const getRollupSearchStrategy = (
AbstractSearchStrategy: any,
- RollupSearchRequest: any,
RollupSearchCapabilities: any,
- callWithRequestFactory: CallWithRequestFactoryShim
+ getRollupService: (reg: ReqFacade) => Promise
) =>
class RollupSearchStrategy extends AbstractSearchStrategy {
name = 'rollup';
constructor() {
- // TODO: When vis_type_timeseries and AbstractSearchStrategy are migrated to the NP, it
- // shouldn't require elasticsearchService to be injected, and we can remove this null argument.
- super(null, callWithRequestFactory, RollupSearchRequest);
+ super(ENHANCED_ES_SEARCH_STRATEGY, 'rollup', { rest_total_hits_as_int: true });
}
- getRollupData(req: KibanaRequest, indexPattern: string) {
- const callWithRequest = this.getCallWithRequestInstance(req);
+ async search(req: ReqFacade, bodies: any[], options = {}) {
+ const rollupService = await getRollupService(req);
+ const requests: any[] = [];
+ bodies.forEach((body) => {
+ requests.push(
+ rollupService.callAsCurrentUser('rollup.search', {
+ ...body,
+ rest_total_hits_as_int: true,
+ })
+ );
+ });
+ return Promise.all(requests);
+ }
- return callWithRequest(ROLLUP_INDEX_CAPABILITIES_METHOD, {
- indexPattern,
- }).catch(() => Promise.resolve({}));
+ async getRollupData(req: ReqFacade, indexPattern: string) {
+ const rollupService = await getRollupService(req);
+ return rollupService
+ .callAsCurrentUser('rollup.rollupIndexCapabilities', {
+ indexPattern,
+ })
+ .catch(() => Promise.resolve({}));
}
- async checkForViability(req: KibanaRequest, indexPattern: string) {
+ async checkForViability(req: ReqFacade, indexPattern: string) {
let isViable = false;
let capabilities = null;
@@ -66,7 +76,7 @@ export const getRollupSearchStrategy = (
}
async getFieldsForWildcard(
- req: KibanaRequest,
+ req: ReqFacade,
indexPattern: string,
{
fieldsCapabilities,
diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts
index 8b3a6355f950d..fe193150fc1ca 100644
--- a/x-pack/plugins/rollup/server/plugin.ts
+++ b/x-pack/plugins/rollup/server/plugin.ts
@@ -17,17 +17,16 @@ import {
ILegacyCustomClusterClient,
Plugin,
Logger,
- KibanaRequest,
PluginInitializerContext,
ILegacyScopedClusterClient,
- LegacyAPICaller,
SharedGlobalConfig,
} from 'src/core/server';
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
+import { ReqFacade } from '../../../../src/plugins/vis_type_timeseries/server';
import { PLUGIN, CONFIG_ROLLUPS } from '../common';
-import { Dependencies, CallWithRequestFactoryShim } from './types';
+import { Dependencies } from './types';
import { registerApiRoutes } from './routes';
import { License } from './services';
import { registerRollupUsageCollector } from './collectors';
@@ -132,19 +131,12 @@ export class RollupPlugin implements Plugin {
});
if (visTypeTimeseries) {
- // TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim.
- const callWithRequestFactoryShim = (
- elasticsearchServiceShim: CallWithRequestFactoryShim,
- request: KibanaRequest
- ): LegacyAPICaller => {
- return async (...args: Parameters) => {
- this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices));
- return await this.rollupEsClient.asScoped(request).callAsCurrentUser(...args);
- };
+ const getRollupService = async (request: ReqFacade) => {
+ this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices));
+ return this.rollupEsClient.asScoped(request);
};
-
const { addSearchStrategy } = visTypeTimeseries;
- registerRollupSearchStrategy(callWithRequestFactoryShim, addSearchStrategy);
+ registerRollupSearchStrategy(addSearchStrategy, getRollupService);
}
if (usageCollection) {
diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts
index 290d2df050099..b167806cf8d5d 100644
--- a/x-pack/plugins/rollup/server/types.ts
+++ b/x-pack/plugins/rollup/server/types.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { IRouter, LegacyAPICaller, KibanaRequest } from 'src/core/server';
+import { IRouter } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server';
@@ -39,9 +39,3 @@ export interface RouteDependencies {
IndexPatternsFetcher: typeof IndexPatternsFetcher;
};
}
-
-// TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim.
-export type CallWithRequestFactoryShim = (
- elasticsearchServiceShim: CallWithRequestFactoryShim,
- request: KibanaRequest
-) => LegacyAPICaller;
From e616c1501ab71315d57ec57dc7b473817b9dc2de Mon Sep 17 00:00:00 2001
From: Christos Nasikas
Date: Thu, 17 Sep 2020 17:39:35 +0300
Subject: [PATCH 05/32] [Security Solutions][Cases] Cases Redesign (#73247)
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Yara Tercero
Co-authored-by: Elastic Machine
---
.../cypress/integration/cases.spec.ts | 9 +-
.../timeline_attach_to_case.spec.ts | 10 +-
.../cypress/screens/case_details.ts | 16 +-
.../cypress/screens/create_new_case.ts | 6 +-
.../cypress/tasks/create_new_case.ts | 4 -
.../cases/components/add_comment/index.tsx | 4 +-
.../cases/components/case_view/index.test.tsx | 15 +-
.../public/cases/components/create/index.tsx | 4 +-
.../cases/components/tag_list/index.test.tsx | 12 +-
.../cases/components/tag_list/index.tsx | 14 +-
.../public/cases/components/tag_list/tags.tsx | 32 ++
.../user_action_tree/helpers.test.tsx | 18 +-
.../components/user_action_tree/helpers.tsx | 52 ++-
.../user_action_tree/index.test.tsx | 349 ++++++++-------
.../components/user_action_tree/index.tsx | 407 ++++++++++++------
.../user_action_avatar.test.tsx | 47 ++
.../user_action_tree/user_action_avatar.tsx | 21 +-
.../user_action_content_toolbar.test.tsx | 55 +++
.../user_action_content_toolbar.tsx | 52 +++
.../user_action_copy_link.test.tsx | 74 ++++
.../user_action_copy_link.tsx | 43 ++
.../user_action_tree/user_action_item.tsx | 197 ---------
.../user_action_markdown.test.tsx | 24 +-
.../user_action_tree/user_action_markdown.tsx | 59 ++-
.../user_action_move_to_reference.test.tsx | 34 ++
.../user_action_move_to_reference.tsx | 37 ++
.../user_action_property_actions.test.tsx | 50 +++
.../user_action_property_actions.tsx | 58 +++
.../user_action_timestamp.test.tsx | 74 ++++
.../user_action_timestamp.tsx | 46 ++
.../user_action_title.test.tsx | 54 ---
.../user_action_tree/user_action_title.tsx | 183 --------
.../user_action_username.test.tsx | 68 +++
.../user_action_tree/user_action_username.tsx | 28 ++
.../user_action_username_with_avatar.test.tsx | 42 ++
.../user_action_username_with_avatar.tsx | 43 ++
.../public/common/components/link_to/index.ts | 23 +-
.../components/link_to/redirect_to_case.tsx | 11 +
.../link_to/redirect_to_timelines.tsx | 6 +
.../components/markdown_editor/eui_form.tsx | 87 ++++
.../components/markdown_editor/form.tsx | 67 ---
.../components/markdown_editor/index.test.tsx | 49 ---
.../components/markdown_editor/index.tsx | 165 +------
.../plugins/timeline/constants.ts | 8 +
.../markdown_editor/plugins/timeline/index.ts | 11 +
.../plugins/timeline/parser.ts | 119 +++++
.../plugins/timeline/plugin.tsx | 87 ++++
.../plugins/timeline/processor.tsx | 34 ++
.../plugins/timeline/translations.ts | 54 +++
.../markdown_editor/plugins/timeline/types.ts | 18 +
.../components/markdown_editor/types.ts | 10 +
.../utils/timeline}/use_timeline_click.tsx | 0
.../rules/step_about_rule/index.tsx | 2 +-
.../use_insert_timeline.tsx | 19 +-
54 files changed, 1888 insertions(+), 1123 deletions(-)
create mode 100644 x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx
delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx
delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
rename x-pack/plugins/security_solution/public/{cases/components/utils => common/utils/timeline}/use_timeline_click.tsx (100%)
diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
index 6194d6892d799..a45b1fd18a4b6 100644
--- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts
@@ -24,17 +24,16 @@ import {
ALL_CASES_TAGS_COUNT,
} from '../screens/all_cases';
import {
- ACTION,
CASE_DETAILS_DESCRIPTION,
CASE_DETAILS_PAGE_TITLE,
CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN,
CASE_DETAILS_STATUS,
CASE_DETAILS_TAGS,
- CASE_DETAILS_USER_ACTION,
+ CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME,
+ CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT,
CASE_DETAILS_USERNAMES,
PARTICIPANTS,
REPORTER,
- USER,
} from '../screens/case_details';
import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens/timeline';
@@ -84,8 +83,8 @@ describe('Cases', () => {
const expectedTags = case1.tags.join('');
cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name);
cy.get(CASE_DETAILS_STATUS).should('have.text', 'open');
- cy.get(CASE_DETAILS_USER_ACTION).eq(USER).should('have.text', case1.reporter);
- cy.get(CASE_DETAILS_USER_ACTION).eq(ACTION).should('have.text', 'added description');
+ cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter);
+ cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description');
cy.get(CASE_DETAILS_DESCRIPTION).should(
'have.text',
`${case1.description} ${case1.timeline.title}`
diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts
index 6af4d174b9583..3862a89a7d833 100644
--- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts
@@ -11,7 +11,7 @@ import {
addNewCase,
selectCase,
} from '../tasks/timeline';
-import { DESCRIPTION_INPUT } from '../screens/create_new_case';
+import { DESCRIPTION_INPUT, ADD_COMMENT_INPUT } from '../screens/create_new_case';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case';
@@ -34,7 +34,7 @@ describe('attach timeline to case', () => {
cy.location('origin').then((origin) => {
cy.get(DESCRIPTION_INPUT).should(
'have.text',
- `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
+ `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))`
);
});
});
@@ -46,7 +46,7 @@ describe('attach timeline to case', () => {
cy.location('origin').then((origin) => {
cy.get(DESCRIPTION_INPUT).should(
'have.text',
- `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
+ `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))`
);
});
});
@@ -66,9 +66,9 @@ describe('attach timeline to case', () => {
selectCase(TIMELINE_CASE_ID);
cy.location('origin').then((origin) => {
- cy.get(DESCRIPTION_INPUT).should(
+ cy.get(ADD_COMMENT_INPUT).should(
'have.text',
- `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
+ `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))`
);
});
});
diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts
index f2cdaa6994356..7b995f5395543 100644
--- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts
@@ -4,9 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export const ACTION = 2;
-
-export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]';
+export const CASE_DETAILS_DESCRIPTION =
+ '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]';
export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]';
@@ -17,14 +16,17 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]';
export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]';
-export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]';
+export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN =
+ '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button';
+
+export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT =
+ '[data-test-subj="description-action"] .euiCommentEvent__headerEvent';
-export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem';
+export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME =
+ '[data-test-subj="description-action"] .euiCommentEvent__headerUsername';
export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]';
export const PARTICIPANTS = 1;
export const REPORTER = 0;
-
-export const USER = 1;
diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts
index 9431c054d96a4..4f348b4dcdbd1 100644
--- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts
@@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
+export const ADD_COMMENT_INPUT = '[data-test-subj="add-comment"] textarea';
+
export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]';
-export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]';
+export const DESCRIPTION_INPUT = '[data-test-subj="caseDescription"] textarea';
-export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]';
+export const INSERT_TIMELINE_BTN = '.euiMarkdownEditorToolbar [aria-label="Insert timeline link"]';
export const LOADING_SPINNER = '[data-test-subj="create-case-loading-spinner"]';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts
index 1d5d240c5c53d..f5013eed07d29 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts
@@ -13,7 +13,6 @@ import {
INSERT_TIMELINE_BTN,
LOADING_SPINNER,
TAGS_INPUT,
- TIMELINE,
TIMELINE_SEARCHBOX,
TITLE_INPUT,
} from '../screens/create_new_case';
@@ -43,9 +42,6 @@ export const createNewCaseWithTimeline = (newCase: TestCase) => {
cy.get(INSERT_TIMELINE_BTN).click({ force: true });
cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`);
- cy.get(TIMELINE).should('be.visible');
- cy.wait(300);
- cy.get(TIMELINE).eq(0).click({ force: true });
cy.get(SUBMIT_BTN).click({ force: true });
cy.get(LOADING_SPINNER).should('exist');
diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
index ef13c87a92dbb..14c42697dcbb4 100644
--- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx
@@ -11,14 +11,14 @@ import styled from 'styled-components';
import { CommentRequest } from '../../../../../case/common/api';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
-import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form';
+import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
import * as i18n from './translations';
import { schema } from './schema';
-import { useTimelineClick } from '../utils/use_timeline_click';
+import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx
index e1d7d98ba8c51..246df1c94b817 100644
--- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx
@@ -114,34 +114,41 @@ describe('CaseView ', () => {
expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual(
data.title
);
+
expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual(
data.status
);
+
expect(
wrapper
- .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`)
+ .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`)
.first()
.text()
).toEqual(data.tags[0]);
+
expect(
wrapper
- .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`)
+ .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`)
.first()
.text()
).toEqual(data.tags[1]);
+
expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual(
data.createdBy.username
);
+
expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false);
+
expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual(
data.createdAt
);
+
expect(
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
.first()
- .prop('raw')
- ).toEqual(data.description);
+ .text()
+ ).toBe(data.description);
});
});
diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx
index 3c3cc95218b03..a8babe729fde0 100644
--- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx
@@ -31,10 +31,10 @@ import { schema } from './schema';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import * as i18n from '../../translations';
-import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form';
+import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { useGetTags } from '../../containers/use_get_tags';
import { getCaseDetailsUrl } from '../../../common/components/link_to';
-import { useTimelineClick } from '../utils/use_timeline_click';
+import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click';
export const CommonUseField = getUseField({ component: Field });
diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx
index 7c3fcde687033..a60167a18762f 100644
--- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx
@@ -58,6 +58,7 @@ describe('TagList ', () => {
fetchTags,
}));
});
+
it('Renders no tags, and then edit', () => {
const wrapper = mount(
@@ -69,6 +70,7 @@ describe('TagList ', () => {
expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeFalsy();
expect(wrapper.find(`[data-test-subj="edit-tags"]`).last().exists()).toBeTruthy();
});
+
it('Edit tag on submit', async () => {
const wrapper = mount(
@@ -81,6 +83,7 @@ describe('TagList ', () => {
await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags));
});
});
+
it('Tag options render with new tags added', () => {
const wrapper = mount(
@@ -92,6 +95,7 @@ describe('TagList ', () => {
wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options')
).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]);
});
+
it('Cancels on cancel', async () => {
const props = {
...defaultProps,
@@ -102,17 +106,19 @@ describe('TagList ', () => {
);
- expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy();
+
+ expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy();
wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click');
await act(async () => {
- expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy();
+ expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeFalsy();
wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
- expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy();
+ expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy();
});
});
});
+
it('Renders disabled button', () => {
const props = { ...defaultProps, disabled: true };
const wrapper = mount(
diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx
index eeb7c49eceab5..4af781e3c31f4 100644
--- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx
@@ -10,8 +10,6 @@ import {
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
- EuiBadgeGroup,
- EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
@@ -25,6 +23,8 @@ import { schema } from './schema';
import { CommonUseField } from '../create';
import { useGetTags } from '../../containers/use_get_tags';
+import { Tags } from './tags';
+
interface TagListProps {
disabled?: boolean;
isLoading: boolean;
@@ -99,15 +99,7 @@ export const TagList = React.memo(
{tags.length === 0 && !isEditTags && {i18n.NO_TAGS}
}
-
- {tags.length > 0 &&
- !isEditTags &&
- tags.map((tag) => (
-
- {tag}
-
- ))}
-
+ {!isEditTags && }
{isEditTags && (
diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx
new file mode 100644
index 0000000000000..e257563ce751e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx
@@ -0,0 +1,32 @@
+/*
+ * 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 } from 'react';
+import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui';
+
+interface TagsProps {
+ tags: string[];
+ color?: string;
+ gutterSize?: EuiBadgeGroupProps['gutterSize'];
+}
+
+const TagsComponent: React.FC = ({ tags, color = 'default', gutterSize }) => {
+ return (
+ <>
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+export const Tags = memo(TagsComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx
index b5be84db59920..4e5c05f2f1404 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx
@@ -14,7 +14,7 @@ import { connectorsMock } from '../../containers/configure/mock';
describe('User action tree helpers', () => {
const connectors = connectorsMock;
it('label title generated for update tags', () => {
- const action = getUserAction(['title'], 'update');
+ const action = getUserAction(['tags'], 'update');
const result: string | JSX.Element = getLabelTitle({
action,
connectors,
@@ -27,8 +27,11 @@ describe('User action tree helpers', () => {
` ${i18n.TAGS.toLowerCase()}`
);
- expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue);
+ expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual(
+ action.newValue
+ );
});
+
it('label title generated for update title', () => {
const action = getUserAction(['title'], 'update');
const result: string | JSX.Element = getLabelTitle({
@@ -44,6 +47,7 @@ describe('User action tree helpers', () => {
}"`
);
});
+
it('label title generated for update description', () => {
const action = getUserAction(['description'], 'update');
const result: string | JSX.Element = getLabelTitle({
@@ -55,6 +59,7 @@ describe('User action tree helpers', () => {
expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`);
});
+
it('label title generated for update status to open', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: 'open' };
const result: string | JSX.Element = getLabelTitle({
@@ -66,6 +71,7 @@ describe('User action tree helpers', () => {
expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`);
});
+
it('label title generated for update status to closed', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' };
const result: string | JSX.Element = getLabelTitle({
@@ -77,6 +83,7 @@ describe('User action tree helpers', () => {
expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`);
});
+
it('label title generated for update comment', () => {
const action = getUserAction(['comment'], 'update');
const result: string | JSX.Element = getLabelTitle({
@@ -88,6 +95,7 @@ describe('User action tree helpers', () => {
expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`);
});
+
it('label title generated for pushed incident', () => {
const action = getUserAction(['pushed'], 'push-to-service');
const result: string | JSX.Element = getLabelTitle({
@@ -105,6 +113,7 @@ describe('User action tree helpers', () => {
JSON.parse(action.newValue).external_url
);
});
+
it('label title generated for needs update incident', () => {
const action = getUserAction(['pushed'], 'push-to-service');
const result: string | JSX.Element = getLabelTitle({
@@ -122,6 +131,7 @@ describe('User action tree helpers', () => {
JSON.parse(action.newValue).external_url
);
});
+
it('label title generated for update connector', () => {
const action = getUserAction(['connector_id'], 'update');
const result: string | JSX.Element = getLabelTitle({
@@ -136,6 +146,8 @@ describe('User action tree helpers', () => {
` ${i18n.TAGS.toLowerCase()}`
);
- expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue);
+ expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual(
+ action.newValue
+ );
});
});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
index e343c3da6cc8b..4d8bb9ba078e5 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx
@@ -4,12 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui';
+import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import React from 'react';
import { CaseFullExternalService, Connector } from '../../../../../case/common/api';
import { CaseUserActions } from '../../containers/types';
+import { CaseServices } from '../../containers/use_get_case_user_actions';
import * as i18n from '../case_view/translations';
+import { Tags } from '../tag_list/tags';
interface LabelTitle {
action: CaseUserActions;
@@ -44,22 +46,21 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit
return '';
};
-const getTagsLabelTitle = (action: CaseUserActions) => (
-
-
- {action.action === 'add' && i18n.ADDED_FIELD}
- {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()}
-
-
- {action.newValue != null &&
- action.newValue.split(',').map((tag) => (
-
- {tag}
-
- ))}
-
-
-);
+const getTagsLabelTitle = (action: CaseUserActions) => {
+ const tags = action.newValue != null ? action.newValue.split(',') : [];
+
+ return (
+
+
+ {action.action === 'add' && i18n.ADDED_FIELD}
+ {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()}
+
+
+
+
+
+ );
+};
const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => {
const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService;
@@ -78,3 +79,20 @@ const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean)
);
};
+
+export const getPushInfo = (
+ caseServices: CaseServices,
+ parsedValue: { connector_id: string; connector_name: string },
+ index: number
+) =>
+ parsedValue != null
+ ? {
+ firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index,
+ parsedConnectorId: parsedValue.connector_id,
+ parsedConnectorName: parsedValue.connector_name,
+ }
+ : {
+ firstPush: false,
+ parsedConnectorId: 'none',
+ parsedConnectorName: 'none',
+ };
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx
index d67c364bbda10..d2bb2fb243458 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx
@@ -6,6 +6,9 @@
import React from 'react';
import { mount } from 'enzyme';
+// we don't have the types for waitFor just yet, so using "as waitFor" until when we do
+import { wait as waitFor } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form';
@@ -13,9 +16,6 @@ import { useUpdateComment } from '../../containers/use_update_comment';
import { basicCase, basicPush, getUserAction } from '../../containers/mock';
import { UserActionTree } from '.';
import { TestProviders } from '../../../common/mock';
-// we don't have the types for waitFor just yet, so using "as waitFor" until when we do
-import { wait as waitFor } from '@testing-library/react';
-import { act } from 'react-dom/test-utils';
const fetchUserActions = jest.fn();
const onUpdateField = jest.fn();
@@ -66,9 +66,10 @@ describe('UserActionTree ', () => {
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual(
defaultProps.data.createdBy.fullName
);
- expect(wrapper.find(`[data-test-subj="user-action-title"] strong`).first().text()).toEqual(
- defaultProps.data.createdBy.username
- );
+
+ expect(
+ wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text()
+ ).toEqual(defaultProps.data.createdBy.username);
});
it('Renders service now update line with top and bottom when push is required', async () => {
@@ -76,6 +77,7 @@ describe('UserActionTree ', () => {
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'update'),
];
+
const props = {
...defaultProps,
caseServices: {
@@ -90,20 +92,18 @@ describe('UserActionTree ', () => {
caseUserActions: ourActions,
};
- const wrapper = mount(
-
-
-
-
-
- );
-
await act(async () => {
- wrapper.update();
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy();
+ expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy();
});
-
- expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy();
- expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy();
});
it('Renders service now update line with top only when push is up to date', async () => {
@@ -122,20 +122,17 @@ describe('UserActionTree ', () => {
},
};
- const wrapper = mount(
-
-
-
-
-
- );
-
await act(async () => {
- wrapper.update();
+ const wrapper = mount(
+
+
+
+
+
+ );
+ expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy();
+ expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy();
});
-
- expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy();
- expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy();
});
it('Outlines comment when update move to link is clicked', async () => {
@@ -145,89 +142,104 @@ describe('UserActionTree ', () => {
caseUserActions: ourActions,
};
- const wrapper = mount(
-
-
-
-
-
- );
-
await act(async () => {
- wrapper.update();
- });
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ expect(
+ wrapper
+ .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`)
+ .first()
+ .hasClass('outlined')
+ ).toBeFalsy();
- expect(
- wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline')
- ).toEqual('');
- wrapper
- .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`)
- .first()
- .simulate('click');
- expect(
- wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline')
- ).toEqual(ourActions[0].commentId);
+ wrapper
+ .find(
+ `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]`
+ )
+ .first()
+ .simulate('click');
+
+ await waitFor(() => {
+ wrapper.update();
+ expect(
+ wrapper
+ .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`)
+ .first()
+ .hasClass('outlined')
+ ).toBeTruthy();
+ });
+ });
});
it('Switches to markdown when edit is clicked and back to panel when canceled', async () => {
- const ourActions = [getUserAction(['comment'], 'create')];
- const props = {
- ...defaultProps,
- caseUserActions: ourActions,
- };
-
- const wrapper = mount(
-
-
-
-
-
- );
-
- await act(async () => {
- wrapper.update();
- });
+ await waitFor(() => {
+ const ourActions = [getUserAction(['comment'], 'create')];
+ const props = {
+ ...defaultProps,
+ caseUserActions: ourActions,
+ };
+
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ expect(
+ wrapper
+ .find(
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ )
+ .exists()
+ ).toEqual(false);
- expect(
wrapper
.find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]`
)
- .exists()
- ).toEqual(false);
+ .first()
+ .simulate('click');
- wrapper
- .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`)
- .first()
- .simulate('click');
-
- wrapper
- .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`)
- .first()
- .simulate('click');
+ wrapper.update();
- expect(
wrapper
.find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]`
)
- .exists()
- ).toEqual(true);
+ .first()
+ .simulate('click');
- wrapper
- .find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]`
- )
- .first()
- .simulate('click');
+ expect(
+ wrapper
+ .find(
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ )
+ .exists()
+ ).toEqual(true);
- expect(
wrapper
.find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]`
)
- .exists()
- ).toEqual(false);
+ .first()
+ .simulate('click');
+
+ expect(
+ wrapper
+ .find(
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ )
+ .exists()
+ ).toEqual(false);
+ });
});
it('calls update comment when comment markdown is saved', async () => {
@@ -236,6 +248,7 @@ describe('UserActionTree ', () => {
...defaultProps,
caseUserActions: ourActions,
};
+
const wrapper = mount(
@@ -243,27 +256,35 @@ describe('UserActionTree ', () => {
);
+
wrapper
- .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`)
+ .find(
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]`
+ )
.first()
.simulate('click');
+
wrapper
- .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`)
+ .find(
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]`
+ )
.first()
.simulate('click');
+
wrapper
.find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]`
)
.first()
.simulate('click');
+
await act(async () => {
await waitFor(() => {
wrapper.update();
expect(
wrapper
.find(
- `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
)
.exists()
).toEqual(false);
@@ -288,93 +309,101 @@ describe('UserActionTree ', () => {
);
+
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
.first()
.simulate('click');
+
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`)
.first()
.simulate('click');
- wrapper
- .find(
- `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]`
- )
- .first()
- .simulate('click');
+
await act(async () => {
- await waitFor(() => {
- expect(
- wrapper
- .find(
- `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]`
- )
- .exists()
- ).toEqual(false);
- expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content });
- });
+ wrapper
+ .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`)
+ .first()
+ .simulate('click');
});
+
+ wrapper.update();
+
+ expect(
+ wrapper
+ .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]`)
+ .exists()
+ ).toEqual(false);
+
+ expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content });
});
it('quotes', async () => {
- const commentData = {
- comment: '',
- };
- const formHookMock = getFormMock(commentData);
- const setFieldValue = jest.fn();
- useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } }));
- const props = defaultProps;
- const wrapper = mount(
-
-
-
-
-
- );
-
await act(async () => {
+ const commentData = {
+ comment: '',
+ };
+ const setFieldValue = jest.fn();
+
+ const formHookMock = getFormMock(commentData);
+ useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } }));
+
+ const props = defaultProps;
+ const wrapper = mount(
+
+
+
+
+
+ );
+
+ wrapper
+ .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
+ .first()
+ .simulate('click');
+
await waitFor(() => {
- wrapper
- .find(
- `[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`
- )
- .first()
- .simulate('click');
wrapper.update();
});
- });
- wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`)
- .first()
- .simulate('click');
+ wrapper
+ .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`)
+ .first()
+ .simulate('click');
- expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`);
+ expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`);
+ });
});
it('Outlines comment when url param is provided', async () => {
- const commentId = 'neat-comment-id';
- const ourActions = [getUserAction(['comment'], 'create')];
- const props = {
- ...defaultProps,
- caseUserActions: ourActions,
- };
-
+ const commentId = 'basic-comment-id';
jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId });
- const wrapper = mount(
-
-
-
-
-
- );
await act(async () => {
- wrapper.update();
- });
+ const ourActions = [getUserAction(['comment'], 'create')];
+ const props = {
+ ...defaultProps,
+ caseUserActions: ourActions,
+ };
+
+ const wrapper = mount(
+
+
+
+
+
+ );
- expect(
- wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline')
- ).toEqual(commentId);
+ await waitFor(() => {
+ wrapper.update();
+ });
+
+ expect(
+ wrapper
+ .find(`[data-test-subj="comment-create-action-${commentId}"]`)
+ .first()
+ .hasClass('outlined')
+ ).toBeTruthy();
+ });
});
});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
index d1263ab13f41b..bada15294de09 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx
@@ -3,25 +3,38 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+import classNames from 'classnames';
-import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
+ EuiCommentList,
+ EuiCommentProps,
+} from '@elastic/eui';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
-import * as i18n from '../case_view/translations';
+import * as i18n from './translations';
import { Case, CaseUserActions } from '../../containers/types';
import { useUpdateComment } from '../../containers/use_update_comment';
import { useCurrentUser } from '../../../common/lib/kibana';
import { AddComment, AddCommentRefObject } from '../add_comment';
-import { getLabelTitle } from './helpers';
-import { UserActionItem } from './user_action_item';
-import { UserActionMarkdown } from './user_action_markdown';
import { Connector } from '../../../../../case/common/api/cases';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { parseString } from '../../containers/utils';
import { OnUpdateFields } from '../case_view';
+import { getLabelTitle, getPushInfo } from './helpers';
+import { UserActionAvatar } from './user_action_avatar';
+import { UserActionMarkdown } from './user_action_markdown';
+import { UserActionTimestamp } from './user_action_timestamp';
+import { UserActionCopyLink } from './user_action_copy_link';
+import { UserActionMoveToReference } from './user_action_move_to_reference';
+import { UserActionUsername } from './user_action_username';
+import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
+import { UserActionContentToolbar } from './user_action_content_toolbar';
export interface UserActionTreeProps {
caseServices: CaseServices;
@@ -40,6 +53,31 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 8px;
`;
+const MyEuiCommentList = styled(EuiCommentList)`
+ ${({ theme }) => `
+ & .userAction__comment.outlined .euiCommentEvent {
+ outline: solid 5px ${theme.eui.euiColorVis1_behindText};
+ margin: 0.5em;
+ transition: 0.8s;
+ }
+
+ & .euiComment.isEdit {
+ & .euiCommentEvent {
+ border: none;
+ box-shadow: none;
+ }
+
+ & .euiCommentEvent__body {
+ padding: 0;
+ }
+
+ & .euiCommentEvent__header {
+ display: none;
+ }
+ }
+ `}
+`;
+
const DESCRIPTION_ID = 'description';
const NEW_ID = 'newComment';
@@ -86,8 +124,7 @@ export const UserActionTree = React.memo(
updateCase,
});
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [caseData, handleManageMarkdownEditId, patchComment, updateCase]
+ [caseData.id, fetchUserActions, patchComment, updateCase]
);
const handleOutlineComment = useCallback(
@@ -172,117 +209,246 @@ export const UserActionTree = React.memo(
}
}
}, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]);
- return (
- <>
- {i18n.ADDED_DESCRIPTION}>}
- fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''}
- markdown={MarkdownDescription}
- onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
- onQuote={handleManageQuote.bind(null, caseData.description)}
- username={caseData.createdBy.username ?? i18n.UNKNOWN}
- />
- {caseUserActions.map((action, index) => {
- if (action.commentId != null && action.action === 'create') {
- const comment = caseData.comments.find((c) => c.id === action.commentId);
- if (comment != null) {
- return (
-
);
- wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click');
+
+ wrapper
+ .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`)
+ .first()
+ .simulate('click');
expect(queryTimelineByIdSpy).toBeCalledWith({
apolloClient: mockUseApolloClient(),
@@ -59,8 +64,19 @@ describe('UserActionMarkdown ', () => {
);
- wrapper.find(`[data-test-subj="preview-tab"]`).first().simulate('click');
- wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click');
+
+ // Preview button of Markdown editor
+ wrapper
+ .find(
+ `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty`
+ )
+ .first()
+ .simulate('click');
+
+ wrapper
+ .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`)
+ .first()
+ .simulate('click');
expect(queryTimelineByIdSpy).toBeCalledWith({
apolloClient: mockUseApolloClient(),
graphEventId: '',
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx
index ac2ad179ec60c..45e46b2d7d2db 100644
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx
@@ -4,18 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonEmpty,
+ EuiButton,
+ EuiMarkdownFormat,
+} from '@elastic/eui';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import * as i18n from '../case_view/translations';
-import { Markdown } from '../../../common/components/markdown';
-import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
+import { Form, useForm, UseField } from '../../../shared_imports';
import { schema, Content } from './schema';
-import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
-import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
-import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form';
-import { useTimelineClick } from '../utils/use_timeline_click';
+import {
+ MarkdownEditorForm,
+ parsingPlugins,
+ processingPlugins,
+} from '../../../common/components/markdown_editor/eui_form';
const ContentWrapper = styled.div`
padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`};
@@ -43,24 +49,12 @@ export const UserActionMarkdown = ({
});
const fieldName = 'content';
- const { submit, setFieldValue } = form;
- const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] });
-
- const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
- setFieldValue,
- ]);
-
- const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
- contentFormValue,
- onContentChange
- );
+ const { submit } = form;
const handleCancelAction = useCallback(() => {
onChangeEditable(id);
}, [id, onChangeEditable]);
- const handleTimelineClick = useTimelineClick();
-
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
@@ -105,29 +99,24 @@ export const UserActionMarkdown = ({
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
+ 'aria-label': 'Cases markdown editor',
+ value: content,
+ id,
bottomRightContent: renderButtons({
cancelAction: handleCancelAction,
saveAction: handleSaveAction,
}),
- onClickTimeline: handleTimelineClick,
- onCursorPositionUpdate: handleCursorChange,
- topRightContent: (
-
- ),
}}
/>
) : (
-
-
+
+
+ {content}
+
);
};
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx
new file mode 100644
index 0000000000000..5bb0f50ce25e5
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx
@@ -0,0 +1,34 @@
+/*
+ * 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 from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { UserActionMoveToReference } from './user_action_move_to_reference';
+
+const outlineComment = jest.fn();
+const props = {
+ id: 'move-to-ref-id',
+ outlineComment,
+};
+
+describe('UserActionMoveToReference ', () => {
+ let wrapper: ReactWrapper;
+
+ beforeAll(() => {
+ wrapper = mount();
+ });
+
+ it('it renders', async () => {
+ expect(
+ wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().exists()
+ ).toBeTruthy();
+ });
+
+ it('calls outlineComment correctly', async () => {
+ wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().simulate('click');
+ expect(outlineComment).toHaveBeenCalledWith(props.id);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx
new file mode 100644
index 0000000000000..39d016dd69520
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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, useCallback } from 'react';
+import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
+
+import * as i18n from './translations';
+
+interface UserActionMoveToReferenceProps {
+ id: string;
+ outlineComment: (id: string) => void;
+}
+
+const UserActionMoveToReferenceComponent = ({
+ id,
+ outlineComment,
+}: UserActionMoveToReferenceProps) => {
+ const handleMoveToLink = useCallback(() => {
+ outlineComment(id);
+ }, [id, outlineComment]);
+
+ return (
+ {i18n.MOVE_TO_ORIGINAL_COMMENT}}>
+
+
+ );
+};
+
+export const UserActionMoveToReference = memo(UserActionMoveToReferenceComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx
new file mode 100644
index 0000000000000..bd5da8aca7d4f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { UserActionPropertyActions } from './user_action_property_actions';
+
+const props = {
+ id: 'property-actions-id',
+ editLabel: 'edit',
+ quoteLabel: 'quote',
+ disabled: false,
+ isLoading: false,
+ onEdit: jest.fn(),
+ onQuote: jest.fn(),
+};
+
+describe('UserActionPropertyActions ', () => {
+ let wrapper: ReactWrapper;
+
+ beforeAll(() => {
+ wrapper = mount();
+ });
+
+ it('it renders', async () => {
+ expect(
+ wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists()
+ ).toBeFalsy();
+
+ expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy();
+ });
+
+ it('it shows the edit and quote buttons', async () => {
+ wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click');
+ wrapper.find('[data-test-subj="property-actions-pencil"]').exists();
+ wrapper.find('[data-test-subj="property-actions-quote"]').exists();
+ });
+
+ it('it shows the spinner when loading', async () => {
+ wrapper = mount();
+ expect(
+ wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists()
+ ).toBeTruthy();
+
+ expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx
new file mode 100644
index 0000000000000..454880e93a27f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { memo, useMemo, useCallback } from 'react';
+import { EuiLoadingSpinner } from '@elastic/eui';
+
+import { PropertyActions } from '../property_actions';
+
+interface UserActionPropertyActionsProps {
+ id: string;
+ editLabel: string;
+ quoteLabel: string;
+ disabled: boolean;
+ isLoading: boolean;
+ onEdit: (id: string) => void;
+ onQuote: (id: string) => void;
+}
+
+const UserActionPropertyActionsComponent = ({
+ id,
+ editLabel,
+ quoteLabel,
+ disabled,
+ isLoading,
+ onEdit,
+ onQuote,
+}: UserActionPropertyActionsProps) => {
+ const onEditClick = useCallback(() => onEdit(id), [id, onEdit]);
+ const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]);
+
+ const propertyActions = useMemo(() => {
+ return [
+ {
+ disabled,
+ iconType: 'pencil',
+ label: editLabel,
+ onClick: onEditClick,
+ },
+ {
+ disabled,
+ iconType: 'quote',
+ label: quoteLabel,
+ onClick: onQuoteClick,
+ },
+ ];
+ }, [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]);
+ return (
+ <>
+ {isLoading && }
+ {!isLoading && }
+ >
+ );
+};
+
+export const UserActionPropertyActions = memo(UserActionPropertyActionsComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx
new file mode 100644
index 0000000000000..a65806520c854
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { TestProviders } from '../../../common/mock';
+import { UserActionTimestamp } from './user_action_timestamp';
+
+jest.mock('@kbn/i18n/react', () => {
+ const originalModule = jest.requireActual('@kbn/i18n/react');
+ const FormattedRelative = jest.fn();
+ FormattedRelative.mockImplementationOnce(() => '2 days ago');
+ FormattedRelative.mockImplementation(() => '20 hours ago');
+
+ return {
+ ...originalModule,
+ FormattedRelative,
+ };
+});
+
+const props = {
+ createdAt: '2020-09-06T14:40:59.889Z',
+ updatedAt: '2020-09-07T14:40:59.889Z',
+};
+
+describe('UserActionTimestamp ', () => {
+ let wrapper: ReactWrapper;
+
+ beforeAll(() => {
+ wrapper = mount(, { wrappingComponent: TestProviders });
+ });
+
+ it('it renders', async () => {
+ expect(
+ wrapper.find('[data-test-subj="user-action-title-creation-relative-time"]').first().exists()
+ ).toBeTruthy();
+ expect(
+ wrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists()
+ ).toBeTruthy();
+ });
+
+ it('it shows only the created time when the updated time is missing', async () => {
+ const newWrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(
+ newWrapper
+ .find('[data-test-subj="user-action-title-creation-relative-time"]')
+ .first()
+ .exists()
+ ).toBeTruthy();
+ expect(
+ newWrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists()
+ ).toBeFalsy();
+ });
+
+ it('it shows the timestamp correctly', async () => {
+ const createdText = wrapper
+ .find('[data-test-subj="user-action-title-creation-relative-time"]')
+ .first()
+ .text();
+
+ const updatedText = wrapper
+ .find('[data-test-subj="user-action-title-edited-relative-time"]')
+ .first()
+ .text();
+
+ expect(`${createdText} (${updatedText})`).toBe('2 days ago (20 hours ago)');
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx
new file mode 100644
index 0000000000000..72dc5de9cdb3b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 } from 'react';
+import { EuiTextColor } from '@elastic/eui';
+import { FormattedRelative } from '@kbn/i18n/react';
+
+import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip';
+import * as i18n from './translations';
+
+interface UserActionAvatarProps {
+ createdAt: string;
+ updatedAt?: string | null;
+}
+
+const UserActionTimestampComponent = ({ createdAt, updatedAt }: UserActionAvatarProps) => {
+ return (
+ <>
+
+
+
+ {updatedAt && (
+
+ {/* be careful of the extra space at the beginning of the parenthesis */}
+ {' ('}
+ {i18n.EDITED_FIELD}{' '}
+
+
+
+ {')'}
+
+ )}
+ >
+ );
+};
+
+export const UserActionTimestamp = memo(UserActionTimestampComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx
deleted file mode 100644
index 0bb02ce69a544..0000000000000
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx
+++ /dev/null
@@ -1,54 +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 React from 'react';
-import { mount } from 'enzyme';
-import copy from 'copy-to-clipboard';
-import { Router, routeData, mockHistory } from '../__mock__/router';
-import { caseUserActions as basicUserActions } from '../../containers/mock';
-import { UserActionTitle } from './user_action_title';
-import { TestProviders } from '../../../common/mock';
-
-const outlineComment = jest.fn();
-const onEdit = jest.fn();
-const onQuote = jest.fn();
-
-jest.mock('copy-to-clipboard');
-const defaultProps = {
- createdAt: basicUserActions[0].actionAt,
- disabled: false,
- fullName: basicUserActions[0].actionBy.fullName,
- id: basicUserActions[0].actionId,
- isLoading: false,
- labelEditAction: 'labelEditAction',
- labelQuoteAction: 'labelQuoteAction',
- labelTitle: <>{'cool'}>,
- linkId: basicUserActions[0].commentId,
- onEdit,
- onQuote,
- outlineComment,
- updatedAt: basicUserActions[0].actionAt,
- username: basicUserActions[0].actionBy.username,
-};
-
-describe('UserActionTitle ', () => {
- beforeEach(() => {
- jest.resetAllMocks();
- jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' });
- });
-
- it('Calls copy when copy link is clicked', async () => {
- const wrapper = mount(
-
-
-
-
-
- );
- wrapper.find(`[data-test-subj="copy-link"]`).first().simulate('click');
- expect(copy).toBeCalledTimes(1);
- });
-});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx
deleted file mode 100644
index 9477299e563a8..0000000000000
--- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx
+++ /dev/null
@@ -1,183 +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 {
- EuiLoadingSpinner,
- EuiFlexGroup,
- EuiFlexItem,
- EuiText,
- EuiButtonIcon,
- EuiToolTip,
-} from '@elastic/eui';
-import { FormattedRelative } from '@kbn/i18n/react';
-import copy from 'copy-to-clipboard';
-import { isEmpty } from 'lodash/fp';
-import React, { useMemo, useCallback } from 'react';
-import styled from 'styled-components';
-import { useParams } from 'react-router-dom';
-
-import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip';
-import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search';
-import { navTabs } from '../../../app/home/home_navigations';
-import { PropertyActions } from '../property_actions';
-import { SecurityPageName } from '../../../app/types';
-import * as i18n from './translations';
-
-const MySpinner = styled(EuiLoadingSpinner)`
- .euiLoadingSpinner {
- margin-top: 1px; // yes it matters!
- }
-`;
-
-interface UserActionTitleProps {
- createdAt: string;
- disabled: boolean;
- id: string;
- isLoading: boolean;
- labelEditAction?: string;
- labelQuoteAction?: string;
- labelTitle: JSX.Element;
- linkId?: string | null;
- fullName?: string | null;
- updatedAt?: string | null;
- username?: string | null;
- onEdit?: (id: string) => void;
- onQuote?: (id: string) => void;
- outlineComment?: (id: string) => void;
-}
-
-export const UserActionTitle = ({
- createdAt,
- disabled,
- fullName,
- id,
- isLoading,
- labelEditAction,
- labelQuoteAction,
- labelTitle,
- linkId,
- onEdit,
- onQuote,
- outlineComment,
- updatedAt,
- username = i18n.UNKNOWN,
-}: UserActionTitleProps) => {
- const { detailName: caseId } = useParams<{ detailName: string }>();
- const urlSearch = useGetUrlSearch(navTabs.case);
- const propertyActions = useMemo(() => {
- return [
- ...(labelEditAction != null && onEdit != null
- ? [
- {
- disabled,
- iconType: 'pencil',
- label: labelEditAction,
- onClick: () => onEdit(id),
- },
- ]
- : []),
- ...(labelQuoteAction != null && onQuote != null
- ? [
- {
- disabled,
- iconType: 'quote',
- label: labelQuoteAction,
- onClick: () => onQuote(id),
- },
- ]
- : []),
- ];
- }, [disabled, id, labelEditAction, onEdit, labelQuoteAction, onQuote]);
-
- const handleAnchorLink = useCallback(() => {
- copy(
- `${window.location.origin}${window.location.pathname}#${SecurityPageName.case}/${caseId}/${id}${urlSearch}`
- );
- }, [caseId, id, urlSearch]);
-
- const handleMoveToLink = useCallback(() => {
- if (outlineComment != null && linkId != null) {
- outlineComment(linkId);
- }
- }, [linkId, outlineComment]);
- return (
-
-
-
-
-
- {fullName ?? username}}>
- {username}
-
-
- {labelTitle}
-
-
-
-
-
- {updatedAt != null && (
-
-
- {'('}
- {i18n.EDITED_FIELD}{' '}
-
-
-
- {')'}
-
-
- )}
-
-
-
-
- {!isEmpty(linkId) && (
-
- {i18n.MOVE_TO_ORIGINAL_COMMENT}}>
-
-
-
- )}
-
- {i18n.COPY_REFERENCE_LINK}}>
-
-
-
- {propertyActions.length > 0 && (
-
- {isLoading && }
- {!isLoading && }
-
- )}
-
-
-
-
- );
-};
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx
new file mode 100644
index 0000000000000..008eb18aef074
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { UserActionUsername } from './user_action_username';
+
+const props = {
+ username: 'elastic',
+ fullName: 'Elastic',
+};
+
+describe('UserActionUsername ', () => {
+ let wrapper: ReactWrapper;
+
+ beforeAll(() => {
+ wrapper = mount();
+ });
+
+ it('it renders', async () => {
+ expect(
+ wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().exists()
+ ).toBeTruthy();
+ });
+
+ it('it shows the username', async () => {
+ expect(wrapper.find('[data-test-subj="user-action-username-tooltip"]').text()).toBe('elastic');
+ });
+
+ test('it shows the fullname when hovering the username', () => {
+ // Use fake timers so we don't have to wait for the EuiToolTip timeout
+ jest.useFakeTimers();
+
+ wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().simulate('mouseOver');
+
+ // Run the timers so the EuiTooltip will be visible
+ jest.runAllTimers();
+
+ wrapper.update();
+ expect(wrapper.find('.euiToolTipPopover').text()).toBe('Elastic');
+
+ // Clearing all mocks will also reset fake timers.
+ jest.clearAllMocks();
+ });
+
+ test('it shows the username when hovering the username and the fullname is missing', () => {
+ // Use fake timers so we don't have to wait for the EuiToolTip timeout
+ jest.useFakeTimers();
+
+ const newWrapper = mount();
+ newWrapper
+ .find('[data-test-subj="user-action-username-tooltip"]')
+ .first()
+ .simulate('mouseOver');
+
+ // Run the timers so the EuiTooltip will be visible
+ jest.runAllTimers();
+
+ newWrapper.update();
+ expect(newWrapper.find('.euiToolTipPopover').text()).toBe('elastic');
+
+ // Clearing all mocks will also reset fake timers.
+ jest.clearAllMocks();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
new file mode 100644
index 0000000000000..dbc153ddbe577
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { memo } from 'react';
+import { EuiToolTip } from '@elastic/eui';
+import { isEmpty } from 'lodash/fp';
+
+interface UserActionUsernameProps {
+ username: string;
+ fullName?: string;
+}
+
+const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => {
+ return (
+ {isEmpty(fullName) ? username : fullName}}
+ data-test-subj="user-action-username-tooltip"
+ >
+ {username}
+
+ );
+};
+
+export const UserActionUsername = memo(UserActionUsernameComponent);
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx
new file mode 100644
index 0000000000000..f8403738c24ea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
+
+const props = {
+ username: 'elastic',
+ fullName: 'Elastic',
+};
+
+describe('UserActionUsernameWithAvatar ', () => {
+ let wrapper: ReactWrapper;
+
+ beforeAll(() => {
+ wrapper = mount();
+ });
+
+ it('it renders', async () => {
+ expect(
+ wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists()
+ ).toBeTruthy();
+ expect(
+ wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists()
+ ).toBeTruthy();
+ });
+
+ it('it shows the avatar', async () => {
+ expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E');
+ });
+
+ it('it shows the avatar without fullName', async () => {
+ const newWrapper = mount();
+ expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe(
+ 'e'
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
new file mode 100644
index 0000000000000..e2326a3580e6f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { memo } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui';
+import { isEmpty } from 'lodash/fp';
+
+import { UserActionUsername } from './user_action_username';
+
+interface UserActionUsernameWithAvatarProps {
+ username: string;
+ fullName?: string;
+}
+
+const UserActionUsernameWithAvatarComponent = ({
+ username,
+ fullName,
+}: UserActionUsernameWithAvatarProps) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const UserActionUsernameWithAvatar = memo(UserActionUsernameWithAvatarComponent);
diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts
index 403c8d838fa44..89fcc67bcd15f 100644
--- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts
+++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts
@@ -16,25 +16,40 @@ export { getDetectionEngineUrl } from './redirect_to_detection_engine';
export { getAppOverviewUrl } from './redirect_to_overview';
export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts';
export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network';
-export { getTimelinesUrl, getTimelineTabsUrl } from './redirect_to_timelines';
+export { getTimelinesUrl, getTimelineTabsUrl, getTimelineUrl } from './redirect_to_timelines';
export {
getCaseDetailsUrl,
getCaseUrl,
getCreateCaseUrl,
getConfigureCasesUrl,
+ getCaseDetailsUrlWithCommentId,
} from './redirect_to_case';
+interface FormatUrlOptions {
+ absolute: boolean;
+ skipSearch: boolean;
+}
+
+type FormatUrl = (path: string, options?: Partial) => string;
+
export const useFormatUrl = (page: SecurityPageName) => {
const { getUrlForApp } = useKibana().services.application;
const search = useGetUrlSearch(navTabs[page]);
- const formatUrl = useCallback(
- (path: string) => {
+ const formatUrl = useCallback(
+ (path: string, { absolute = false, skipSearch = false } = {}) => {
const pathArr = path.split('?');
const formattedPath = `${pathArr[0]}${
- isEmpty(pathArr[1]) ? search : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}`
+ !skipSearch
+ ? isEmpty(pathArr[1])
+ ? search
+ : `?${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}`
+ : isEmpty(pathArr[1])
+ ? ''
+ : `?${pathArr[1]}`
}`;
return getUrlForApp(`${APP_ID}:${page}`, {
path: formattedPath,
+ absolute,
});
},
[getUrlForApp, page, search]
diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx
index 7005460999fc7..3ef00635844f6 100644
--- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx
@@ -11,6 +11,17 @@ export const getCaseUrl = (search: string | null) => `${appendSearch(search ?? u
export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) =>
`/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
+export const getCaseDetailsUrlWithCommentId = ({
+ id,
+ commentId,
+ search,
+}: {
+ id: string;
+ commentId: string;
+ search?: string | null;
+}) =>
+ `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`;
+
export const getCreateCaseUrl = (search?: string | null) =>
`/create${appendSearch(search ?? undefined)}`;
diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx
index 75a2fa1efa414..58b9f940ceaa6 100644
--- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { isEmpty } from 'lodash/fp';
import { TimelineTypeLiteral } from '../../../../common/types/timeline';
import { appendSearch } from './helpers';
@@ -11,3 +12,8 @@ export const getTimelinesUrl = (search?: string) => `${appendSearch(search)}`;
export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) =>
`/${tabName}${appendSearch(search)}`;
+
+export const getTimelineUrl = (id: string, graphEventId?: string) =>
+ `?timeline=(id:'${id}',isOpen:!t${
+ isEmpty(graphEventId) ? ')' : `,graphEventId:'${graphEventId}')`
+ }`;
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx
new file mode 100644
index 0000000000000..481ed7892a8be
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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, { useState, useCallback } from 'react';
+import styled from 'styled-components';
+import {
+ EuiMarkdownEditor,
+ EuiMarkdownEditorProps,
+ EuiFormRow,
+ EuiFlexItem,
+ EuiFlexGroup,
+ getDefaultEuiMarkdownParsingPlugins,
+ getDefaultEuiMarkdownProcessingPlugins,
+} from '@elastic/eui';
+import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports';
+
+import * as timelineMarkdownPlugin from './plugins/timeline';
+
+type MarkdownEditorFormProps = EuiMarkdownEditorProps & {
+ id: string;
+ field: FieldHook;
+ dataTestSubj: string;
+ idAria: string;
+ isDisabled?: boolean;
+ bottomRightContent?: React.ReactNode;
+};
+
+const BottomContentWrapper = styled(EuiFlexGroup)`
+ ${({ theme }) => `
+ padding: ${theme.eui.ruleMargins.marginSmall} 0;
+ `}
+`;
+
+export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
+parsingPlugins.push(timelineMarkdownPlugin.parser);
+
+export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins();
+processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer;
+
+export const MarkdownEditorForm: React.FC = ({
+ id,
+ field,
+ dataTestSubj,
+ idAria,
+ bottomRightContent,
+}) => {
+ const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
+ const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]);
+ const onParse = useCallback((err, { messages }) => {
+ setMarkdownErrorMessages(err ? [err] : messages);
+ }, []);
+
+ return (
+
+ <>
+
+ {bottomRightContent && (
+
+ {bottomRightContent}
+
+ )}
+ >
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx
deleted file mode 100644
index 2cc3fe05a2215..0000000000000
--- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx
+++ /dev/null
@@ -1,67 +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 { EuiFormRow } from '@elastic/eui';
-import React, { useCallback } from 'react';
-
-import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports';
-import { CursorPosition, MarkdownEditor } from '.';
-
-interface IMarkdownEditorForm {
- bottomRightContent?: React.ReactNode;
- dataTestSubj: string;
- field: FieldHook;
- idAria: string;
- isDisabled: boolean;
- onClickTimeline?: (timelineId: string, graphEventId?: string) => void;
- onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
- placeholder?: string;
- topRightContent?: React.ReactNode;
-}
-export const MarkdownEditorForm = ({
- bottomRightContent,
- dataTestSubj,
- field,
- idAria,
- isDisabled = false,
- onClickTimeline,
- onCursorPositionUpdate,
- placeholder,
- topRightContent,
-}: IMarkdownEditorForm) => {
- const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
-
- const handleContentChange = useCallback(
- (newContent: string) => {
- field.setValue(newContent);
- },
- [field]
- );
-
- return (
-
-
-
- );
-};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx
deleted file mode 100644
index b5e5b01189418..0000000000000
--- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { mount } from 'enzyme';
-import React from 'react';
-
-import { MarkdownEditor } from '.';
-import { TestProviders } from '../../mock';
-
-describe('Markdown Editor', () => {
- const onChange = jest.fn();
- const onCursorPositionUpdate = jest.fn();
- const defaultProps = {
- content: 'hello world',
- onChange,
- onCursorPositionUpdate,
- };
- beforeEach(() => {
- jest.clearAllMocks();
- });
- test('it calls onChange with correct value', () => {
- const wrapper = mount(
-
-
-
- );
- const newValue = 'a new string';
- wrapper
- .find(`[data-test-subj="textAreaInput"]`)
- .first()
- .simulate('change', { target: { value: newValue } });
- expect(onChange).toBeCalledWith(newValue);
- });
- test('it calls onCursorPositionUpdate with correct args', () => {
- const wrapper = mount(
-
-
-
- );
- wrapper.find(`[data-test-subj="textAreaInput"]`).first().simulate('blur');
- expect(onCursorPositionUpdate).toBeCalledWith({
- start: 0,
- end: 0,
- });
- });
-});
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx
index d4ad4a11b60a3..9f4141dbcae7d 100644
--- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx
@@ -4,167 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- EuiFlexGroup,
- EuiFlexItem,
- EuiLink,
- EuiPanel,
- EuiTabbedContent,
- EuiTextArea,
-} from '@elastic/eui';
-import React, { useMemo, useCallback, ChangeEvent } from 'react';
-import styled, { css } from 'styled-components';
-
-import { Markdown } from '../markdown';
-import * as i18n from './translations';
-import { MARKDOWN_HELP_LINK } from './constants';
-
-const TextArea = styled(EuiTextArea)`
- width: 100%;
-`;
-
-const Container = styled(EuiPanel)`
- ${({ theme }) => css`
- padding: 0;
- background: ${theme.eui.euiColorLightestShade};
- position: relative;
- .markdown-tabs-header {
- position: absolute;
- top: ${theme.eui.euiSizeS};
- right: ${theme.eui.euiSizeS};
- z-index: ${theme.eui.euiZContentMenu};
- }
- .euiTab {
- padding: 10px;
- }
- .markdown-tabs {
- width: 100%;
- }
- .markdown-tabs-footer {
- height: 41px;
- padding: 0 ${theme.eui.euiSizeM};
- .euiLink {
- font-size: ${theme.eui.euiSizeM};
- }
- }
- .euiFormRow__labelWrapper {
- position: absolute;
- top: -${theme.eui.euiSizeL};
- }
- .euiFormErrorText {
- padding: 0 ${theme.eui.euiSizeM};
- }
- `}
-`;
-
-const MarkdownContainer = styled(EuiPanel)`
- min-height: 150px;
- overflow: auto;
-`;
-
-export interface CursorPosition {
- start: number;
- end: number;
-}
-
-/** An input for entering a new case description */
-export const MarkdownEditor = React.memo<{
- bottomRightContent?: React.ReactNode;
- topRightContent?: React.ReactNode;
- content: string;
- isDisabled?: boolean;
- onChange: (description: string) => void;
- onClickTimeline?: (timelineId: string, graphEventId?: string) => void;
- onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
- placeholder?: string;
-}>(
- ({
- bottomRightContent,
- topRightContent,
- content,
- isDisabled = false,
- onChange,
- onClickTimeline,
- placeholder,
- onCursorPositionUpdate,
- }) => {
- const handleOnChange = useCallback(
- (evt: ChangeEvent) => {
- onChange(evt.target.value);
- },
- [onChange]
- );
-
- const setCursorPosition = useCallback(
- (e: React.ChangeEvent) => {
- if (onCursorPositionUpdate) {
- onCursorPositionUpdate({
- start: e!.target!.selectionStart ?? 0,
- end: e!.target!.selectionEnd ?? 0,
- });
- }
- },
- [onCursorPositionUpdate]
- );
-
- const tabs = useMemo(
- () => [
- {
- id: 'comment',
- name: i18n.MARKDOWN,
- content: (
-
- ),
- },
- {
- id: 'preview',
- name: i18n.PREVIEW,
- 'data-test-subj': 'preview-tab',
- content: (
-
-
-
- ),
- },
- ],
- [content, handleOnChange, isDisabled, onClickTimeline, placeholder, setCursorPosition]
- );
- return (
-
- {topRightContent && {topRightContent}
}
-
-
-
-
- {i18n.MARKDOWN_SYNTAX_HELP}
-
-
- {bottomRightContent && {bottomRightContent}}
-
-
- );
- }
-);
-
-MarkdownEditor.displayName = 'MarkdownEditor';
+export * from './types';
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
new file mode 100644
index 0000000000000..917000a8ba21c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export const ID = 'timeline';
+export const PREFIX = `[`;
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
new file mode 100644
index 0000000000000..701889013ee53
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { plugin } from './plugin';
+import { TimelineParser } from './parser';
+import { TimelineMarkDownRenderer } from './processor';
+
+export { plugin, TimelineParser as parser, TimelineMarkDownRenderer as renderer };
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
new file mode 100644
index 0000000000000..d322a2c9e1929
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 { Plugin } from '@elastic/eui/node_modules/unified';
+import { RemarkTokenizer } from '@elastic/eui';
+import { parse } from 'query-string';
+import { decodeRisonUrlState } from '../../../url_state/helpers';
+import { ID, PREFIX } from './constants';
+import * as i18n from './translations';
+
+export const TimelineParser: Plugin = function () {
+ const Parser = this.Parser;
+ const tokenizers = Parser.prototype.inlineTokenizers;
+ const methods = Parser.prototype.inlineMethods;
+
+ const parseTimeline: RemarkTokenizer = function (eat, value, silent) {
+ let index = 0;
+ const nextChar = value[index];
+
+ if (nextChar !== '[') {
+ return false;
+ }
+
+ if (silent) {
+ return true;
+ }
+
+ function readArg(open: string, close: string) {
+ if (value[index] !== open) {
+ throw new Error(i18n.NO_PARENTHESES);
+ }
+
+ index++;
+
+ let body = '';
+ let openBrackets = 0;
+
+ for (; index < value.length; index++) {
+ const char = value[index];
+
+ if (char === close && openBrackets === 0) {
+ index++;
+ return body;
+ } else if (char === close) {
+ openBrackets--;
+ } else if (char === open) {
+ openBrackets++;
+ }
+
+ body += char;
+ }
+
+ return '';
+ }
+
+ const timelineTitle = readArg('[', ']');
+ const timelineUrl = readArg('(', ')');
+ const now = eat.now();
+
+ if (!timelineTitle) {
+ this.file.info(i18n.NO_TIMELINE_NAME_FOUND, {
+ line: now.line,
+ column: now.column,
+ });
+ return false;
+ }
+
+ try {
+ const timelineSearch = timelineUrl.split('?');
+ const parseTimelineUrlSearch = parse(timelineSearch[1]) as { timeline: string };
+ const { id: timelineId = '', graphEventId = '' } = decodeRisonUrlState(
+ parseTimelineUrlSearch.timeline ?? ''
+ ) ?? { id: null, graphEventId: '' };
+
+ if (!timelineId) {
+ this.file.info(i18n.NO_TIMELINE_ID_FOUND, {
+ line: now.line,
+ column: now.column + timelineUrl.indexOf('id'),
+ });
+ return false;
+ }
+
+ return eat(`[${timelineTitle}](${timelineUrl})`)({
+ type: ID,
+ id: timelineId,
+ title: timelineTitle,
+ graphEventId,
+ });
+ } catch {
+ this.file.info(i18n.TIMELINE_URL_IS_NOT_VALID(timelineUrl), {
+ line: now.line,
+ column: now.column,
+ });
+ }
+
+ return false;
+ };
+
+ const tokenizeTimeline: RemarkTokenizer = function tokenizeTimeline(eat, value, silent) {
+ if (
+ value.startsWith(PREFIX) === false ||
+ (value.startsWith(PREFIX) === true && !value.includes('timelines?timeline=(id'))
+ ) {
+ return false;
+ }
+
+ return parseTimeline.call(this, eat, value, silent);
+ };
+
+ tokenizeTimeline.locator = (value: string, fromIndex: number) => {
+ return value.indexOf(PREFIX, fromIndex);
+ };
+
+ tokenizers.timeline = tokenizeTimeline;
+ methods.splice(methods.indexOf('url'), 0, ID);
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx
new file mode 100644
index 0000000000000..8d2488b269d76
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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, { useCallback, memo } from 'react';
+import {
+ EuiSelectableOption,
+ EuiModalBody,
+ EuiMarkdownEditorUiPlugin,
+ EuiCodeBlock,
+} from '@elastic/eui';
+
+import { TimelineType } from '../../../../../../common/types/timeline';
+import { SelectableTimeline } from '../../../../../timelines/components/timeline/selectable_timeline';
+import { OpenTimelineResult } from '../../../../../timelines/components/open_timeline/types';
+import { getTimelineUrl, useFormatUrl } from '../../../link_to';
+
+import { ID } from './constants';
+import * as i18n from './translations';
+import { SecurityPageName } from '../../../../../app/types';
+
+interface TimelineEditorProps {
+ onClosePopover: () => void;
+ onInsert: (markdown: string, config: { block: boolean }) => void;
+}
+
+const TimelineEditorComponent: React.FC = ({ onClosePopover, onInsert }) => {
+ const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
+
+ const handleGetSelectableOptions = useCallback(
+ ({ timelines }: { timelines: OpenTimelineResult[] }) => [
+ ...timelines.map(
+ (t: OpenTimelineResult, index: number) =>
+ ({
+ description: t.description,
+ favorite: t.favorite,
+ label: t.title,
+ id: t.savedObjectId,
+ key: `${t.title}-${index}`,
+ title: t.title,
+ checked: undefined,
+ } as EuiSelectableOption)
+ ),
+ ],
+ []
+ );
+
+ return (
+
+ {
+ const url = formatUrl(getTimelineUrl(timelineId ?? '', graphEventId), {
+ absolute: true,
+ skipSearch: true,
+ });
+ onInsert(`[${timelineTitle}](${url})`, {
+ block: false,
+ });
+ }}
+ onClosePopover={onClosePopover}
+ timelineType={TimelineType.default}
+ />
+
+ );
+};
+
+const TimelineEditor = memo(TimelineEditorComponent);
+
+export const plugin: EuiMarkdownEditorUiPlugin = {
+ name: ID,
+ button: {
+ label: i18n.INSERT_TIMELINE,
+ iconType: 'timeline',
+ },
+ helpText: (
+
+ {'[title](url)'}
+
+ ),
+ editor: function editor({ node, onSave, onCancel }) {
+ return ;
+ },
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
new file mode 100644
index 0000000000000..fb72b4368c8ea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
@@ -0,0 +1,34 @@
+/*
+ * 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, { useCallback, memo } from 'react';
+import { EuiToolTip, EuiLink, EuiMarkdownAstNodePosition } from '@elastic/eui';
+
+import { useTimelineClick } from '../../../../utils/timeline/use_timeline_click';
+import { TimelineProps } from './types';
+import * as i18n from './translations';
+
+export const TimelineMarkDownRendererComponent: React.FC<
+ TimelineProps & {
+ position: EuiMarkdownAstNodePosition;
+ }
+> = ({ id, title, graphEventId }) => {
+ const handleTimelineClick = useTimelineClick();
+ const onClickTimeline = useCallback(() => handleTimelineClick(id ?? '', graphEventId), [
+ id,
+ graphEventId,
+ handleTimelineClick,
+ ]);
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+export const TimelineMarkDownRenderer = memo(TimelineMarkDownRendererComponent);
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
new file mode 100644
index 0000000000000..5a23b2a742157
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const INSERT_TIMELINE = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.insertTimelineButtonLabel',
+ {
+ defaultMessage: 'Insert timeline link',
+ }
+);
+
+export const TIMELINE_ID = (timelineId: string) =>
+ i18n.translate('xpack.securitySolution.markdownEditor.plugins.timeline.toolTip.timelineId', {
+ defaultMessage: 'Timeline id: { timelineId }',
+ values: {
+ timelineId,
+ },
+ });
+
+export const NO_TIMELINE_NAME_FOUND = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noTimelineNameFoundErrorMsg',
+ {
+ defaultMessage: 'No timeline name found',
+ }
+);
+
+export const NO_TIMELINE_ID_FOUND = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noTimelineIdFoundErrorMsg',
+ {
+ defaultMessage: 'No timeline id found',
+ }
+);
+
+export const TIMELINE_URL_IS_NOT_VALID = (timelineUrl: string) =>
+ i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.toolTip.timelineUrlIsNotValidErrorMsg',
+ {
+ defaultMessage: 'Timeline URL is not valid => {timelineUrl}',
+ values: {
+ timelineUrl,
+ },
+ }
+ );
+
+export const NO_PARENTHESES = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noParenthesesErrorMsg',
+ {
+ defaultMessage: 'Expected left parentheses',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
new file mode 100644
index 0000000000000..8b9111fc9fc7d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { ID } from './constants';
+
+export interface TimelineConfiguration {
+ id: string | null;
+ title: string;
+ graphEventId?: string;
+ [key: string]: string | null | undefined;
+}
+
+export interface TimelineProps extends TimelineConfiguration {
+ type: typeof ID;
+}
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
new file mode 100644
index 0000000000000..030def21ac36f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export interface CursorPosition {
+ start: number;
+ end: number;
+}
diff --git a/x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
similarity index 100%
rename from x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx
rename to x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
index d2c84883fa99b..66f95f5ce15d2 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
@@ -36,7 +36,7 @@ import { schema } from './schema';
import * as I18n from './translations';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
-import { MarkdownEditorForm } from '../../../../common/components/markdown_editor/form';
+import { MarkdownEditorForm } from '../../../../common/components/markdown_editor/eui_form';
import { SeverityField } from '../severity_mapping';
import { RiskScoreField } from '../risk_score_mapping';
import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
index 55c0709bd5543..f1f419fd4b52a 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
@@ -4,17 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { isEmpty } from 'lodash/fp';
import { useCallback, useState, useEffect } from 'react';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
-import { useBasePath } from '../../../../common/lib/kibana';
+import { SecurityPageName } from '../../../../../common/constants';
+import { getTimelineUrl, useFormatUrl } from '../../../../common/components/link_to';
import { CursorPosition } from '../../../../common/components/markdown_editor';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
import { setInsertTimeline } from '../../../store/timeline/actions';
export const useInsertTimeline = (value: string, onChange: (newValue: string) => void) => {
- const basePath = window.location.origin + useBasePath();
const dispatch = useDispatch();
+ const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
const [cursorPosition, setCursorPosition] = useState({
start: 0,
end: 0,
@@ -24,21 +24,22 @@ export const useInsertTimeline = (value: string, onChange: (newValue: string) =>
const handleOnTimelineChange = useCallback(
(title: string, id: string | null, graphEventId?: string) => {
- const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}'${
- !isEmpty(graphEventId) ? `,graphEventId:'${graphEventId}'` : ''
- },isOpen:!t)`;
+ const url = formatUrl(getTimelineUrl(id ?? '', graphEventId), {
+ absolute: true,
+ skipSearch: true,
+ });
const newValue: string = [
value.slice(0, cursorPosition.start),
cursorPosition.start === cursorPosition.end
- ? `[${title}](${builtLink})`
- : `[${value.slice(cursorPosition.start, cursorPosition.end)}](${builtLink})`,
+ ? `[${title}](${url})`
+ : `[${value.slice(cursorPosition.start, cursorPosition.end)}](${url})`,
value.slice(cursorPosition.end),
].join('');
onChange(newValue);
},
- [value, onChange, basePath, cursorPosition]
+ [value, onChange, cursorPosition, formatUrl]
);
const handleCursorChange = useCallback((cp: CursorPosition) => {
From fc97a3741c23b047b6f85d56c671dba893916014 Mon Sep 17 00:00:00 2001
From: Larry Gregory
Date: Thu, 17 Sep 2020 10:48:08 -0400
Subject: [PATCH 06/32] Add telemetry as an automatic privilege grant (#77390)
Co-authored-by: Elastic Machine
---
src/plugins/telemetry/server/routes/telemetry_opt_in.ts | 9 ++++++++-
x-pack/plugins/features/server/feature_registry.test.ts | 8 ++++----
x-pack/plugins/features/server/feature_registry.ts | 7 ++++++-
3 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts
index aa1de4b2443a4..dd6953ebcda99 100644
--- a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts
+++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts
@@ -26,6 +26,7 @@ import {
StatsGetterConfig,
TelemetryCollectionManagerPluginSetup,
} from 'src/plugins/telemetry_collection_manager/server';
+import { SavedObjectsErrorHelpers } from '../../../../core/server';
import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config';
import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats';
@@ -109,7 +110,13 @@ export function registerTelemetryOptInRoutes({
});
}
- await updateTelemetrySavedObject(context.core.savedObjects.client, attributes);
+ try {
+ await updateTelemetrySavedObject(context.core.savedObjects.client, attributes);
+ } catch (e) {
+ if (SavedObjectsErrorHelpers.isForbiddenError(e)) {
+ return res.forbidden();
+ }
+ }
return res.ok({ body: optInStatus });
}
);
diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts
index 24aae3a69ee5d..e89cf06ec8621 100644
--- a/x-pack/plugins/features/server/feature_registry.test.ts
+++ b/x-pack/plugins/features/server/feature_registry.test.ts
@@ -57,7 +57,7 @@ describe('FeatureRegistry', () => {
read: {
savedObject: {
all: [],
- read: ['config', 'url'],
+ read: ['config', 'url', 'telemetry'],
},
ui: [],
},
@@ -230,7 +230,7 @@ describe('FeatureRegistry', () => {
expect(allPrivilege?.savedObject.all).toEqual(['telemetry']);
});
- it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => {
+ it(`automatically grants access to config, url, and telemetry saved objects`, () => {
const feature: KibanaFeatureConfig = {
id: 'test-feature',
name: 'Test Feature',
@@ -263,7 +263,7 @@ describe('FeatureRegistry', () => {
const allPrivilege = result[0].privileges?.all;
const readPrivilege = result[0].privileges?.read;
expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']);
- expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']);
+ expect(readPrivilege?.savedObject.read).toEqual(['config', 'telemetry', 'url']);
});
it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => {
@@ -332,7 +332,7 @@ describe('FeatureRegistry', () => {
const readPrivilege = result[0].privileges!.read;
expect(allPrivilege?.savedObject.all).toEqual(['telemetry']);
expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']);
- expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']);
+ expect(readPrivilege?.savedObject.read).toEqual(['config', 'url', 'telemetry']);
});
it(`does not allow duplicate features to be registered`, () => {
diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts
index d357bdb782797..e9e556ba22fd2 100644
--- a/x-pack/plugins/features/server/feature_registry.ts
+++ b/x-pack/plugins/features/server/feature_registry.ts
@@ -97,7 +97,12 @@ function applyAutomaticReadPrivilegeGrants(
) {
readPrivileges.forEach((readPrivilege) => {
if (readPrivilege) {
- readPrivilege.savedObject.read = uniq([...readPrivilege.savedObject.read, 'config', 'url']);
+ readPrivilege.savedObject.read = uniq([
+ ...readPrivilege.savedObject.read,
+ 'config',
+ 'telemetry',
+ 'url',
+ ]);
}
});
}
From 102be1ba39b05c2102572b804cfe42bc342f4f86 Mon Sep 17 00:00:00 2001
From: Michail Yasonik
Date: Thu, 17 Sep 2020 11:05:47 -0400
Subject: [PATCH 07/32] Fix memory leak in query_string_input (#77649)
---
.../public/ui/query_string_input/query_string_input.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx
index f159cac664a9e..8e1151b387fee 100644
--- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx
+++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx
@@ -546,13 +546,16 @@ export class QueryStringInputUI extends Component {
this.updateSuggestions.cancel();
this.componentIsUnmounting = true;
window.removeEventListener('resize', this.handleAutoHeight);
- window.removeEventListener('scroll', this.handleListUpdate);
+ window.removeEventListener('scroll', this.handleListUpdate, { capture: true });
}
- handleListUpdate = () =>
- this.setState({
+ handleListUpdate = () => {
+ if (this.componentIsUnmounting) return;
+
+ return this.setState({
queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(),
});
+ };
handleAutoHeight = () => {
if (this.inputRef !== null && document.activeElement === this.inputRef) {
From 4bf0932500e1d74e42bb8e4e42ec61f53fbbfd4a Mon Sep 17 00:00:00 2001
From: Michail Yasonik
Date: Thu, 17 Sep 2020 11:11:04 -0400
Subject: [PATCH 08/32] Adding meta data and highlighting to nav search
(#77662)
---
.../__snapshots__/search_bar.test.tsx.snap | 28 ++++++++++++++++---
.../public/components/search_bar.test.tsx | 4 ++-
.../public/components/search_bar.tsx | 25 +++++++++++------
3 files changed, 43 insertions(+), 14 deletions(-)
diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap
index 0217f039e08ba..7bb9954fa3048 100644
--- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap
+++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap
@@ -7,8 +7,13 @@ Array [
"className": "euiSelectableTemplateSitewide__listItem",
"key": "Canvas",
"label": "Canvas",
+ "meta": Array [
+ Object {
+ "text": "Kibana",
+ },
+ ],
"prepend": undefined,
- "title": "Canvasundefinedundefined",
+ "title": "Canvas • Kibana",
"url": "/app/test/Canvas",
},
Object {
@@ -16,8 +21,13 @@ Array [
"className": "euiSelectableTemplateSitewide__listItem",
"key": "Discover",
"label": "Discover",
+ "meta": Array [
+ Object {
+ "text": "Kibana",
+ },
+ ],
"prepend": undefined,
- "title": "Discoverundefinedundefined",
+ "title": "Discover • Kibana",
"url": "/app/test/Discover",
},
Object {
@@ -25,8 +35,13 @@ Array [
"className": "euiSelectableTemplateSitewide__listItem",
"key": "Graph",
"label": "Graph",
+ "meta": Array [
+ Object {
+ "text": "Kibana",
+ },
+ ],
"prepend": undefined,
- "title": "Graphundefinedundefined",
+ "title": "Graph • Kibana",
"url": "/app/test/Graph",
},
]
@@ -39,8 +54,13 @@ Array [
"className": "euiSelectableTemplateSitewide__listItem",
"key": "Discover",
"label": "Discover",
+ "meta": Array [
+ Object {
+ "text": "Kibana",
+ },
+ ],
"prepend": undefined,
- "title": "Discoverundefinedundefined",
+ "title": "Discover • Kibana",
"url": "/app/test/Discover",
},
Object {
diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx
index 0d1e8725b4911..11fbc7931e620 100644
--- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx
+++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx
@@ -21,6 +21,7 @@ type Result = { id: string; type: string } | string;
const createResult = (result: Result): GlobalSearchResult => {
const id = typeof result === 'string' ? result : result.id;
const type = typeof result === 'string' ? 'application' : result.type;
+ const meta = type === 'application' ? { categoryLabel: 'Kibana' } : { categoryLabel: null };
return {
id,
@@ -28,6 +29,7 @@ const createResult = (result: Result): GlobalSearchResult => {
title: id,
url: `/app/test/${id}`,
score: 42,
+ meta,
};
};
@@ -74,7 +76,7 @@ describe('SearchBar', () => {
expect(findSpy).toHaveBeenCalledTimes(1);
expect(findSpy).toHaveBeenCalledWith('', {});
expect(getSelectableProps(component).options).toMatchSnapshot();
- await wait(() => getSearchProps(component).onSearch('d'));
+ await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } }));
jest.runAllTimers();
component.update();
expect(getSelectableProps(component).options).toMatchSnapshot();
diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx
index d00349e21a7e4..e41f9243198ad 100644
--- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx
+++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx
@@ -52,14 +52,20 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) {
if (!isMounted()) return;
_setOptions([
- ..._options.map((option) => ({
- key: option.id,
- label: option.title,
- url: option.url,
- ...(option.icon && { icon: { type: option.icon } }),
- ...(option.type &&
- option.type !== 'application' && { meta: [{ text: cleanMeta(option.type) }] }),
- })),
+ ..._options.map(({ id, title, url, icon, type, meta }) => {
+ const option: EuiSelectableTemplateSitewideOption = {
+ key: id,
+ label: title,
+ url,
+ };
+
+ if (icon) option.icon = { type: icon };
+
+ if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }];
+ else option.meta = [{ text: cleanMeta(type) }];
+
+ return option;
+ }),
]);
},
[isMounted, _setOptions]
@@ -133,7 +139,8 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) {
onChange={onChange}
options={options}
searchProps={{
- onSearch: setSearchValue,
+ onKeyUpCapture: (e: React.KeyboardEvent) =>
+ setSearchValue(e.currentTarget.value),
'data-test-subj': 'header-search',
inputRef: setSearchRef,
compressed: true,
From 3cf41674f51de8bc0ff5313d6968d3c49a249037 Mon Sep 17 00:00:00 2001
From: Stratoula Kalafateli
Date: Thu, 17 Sep 2020 18:38:43 +0300
Subject: [PATCH 09/32] Aligns the y axis settings on horizontal mode (#77585)
---
.../shared_components/toolbar_popover.tsx | 1 +
.../xy_visualization/xy_config_panel.test.tsx | 46 ++++++++++++++++++-
.../xy_visualization/xy_config_panel.tsx | 45 +++++++++++++-----
3 files changed, 79 insertions(+), 13 deletions(-)
diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx
index 98f5878ec927e..07baf29fdd32a 100644
--- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx
+++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx
@@ -56,6 +56,7 @@ export const ToolbarPopover: React.FunctionComponent = ({
onClick={() => {
setOpen(!open);
}}
+ title={title}
hasArrow={false}
isDisabled={isDisabled}
groupPosition={groupPosition}
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx
index 7e2e8f0453588..2114d63fcfacd 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers';
import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui';
-import { LayerContextMenu, XyToolbar } from './xy_config_panel';
+import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel';
import { ToolbarPopover } from '../shared_components';
import { AxisSettingsPopover } from './axis_settings_popover';
import { FramePublicAPI } from '../types';
@@ -171,4 +171,48 @@ describe('XY Config panels', () => {
expect(component.find(AxisSettingsPopover).length).toEqual(3);
});
});
+
+ describe('Dimension Editor', () => {
+ test('shows the correct axis side options when in horizontal mode', () => {
+ const state = testState();
+ const component = mount(
+
+ );
+
+ const options = component
+ .find(EuiButtonGroup)
+ .first()
+ .prop('options') as EuiButtonGroupProps['options'];
+
+ expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Bottom', 'Top']);
+ });
+
+ test('shows the default axis side options when not in horizontal mode', () => {
+ const state = testState();
+ const component = mount(
+
+ );
+
+ const options = component
+ .find(EuiButtonGroup)
+ .first()
+ .prop('options') as EuiButtonGroupProps['options'];
+
+ expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Left', 'Right']);
+ });
+ });
});
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 bc98bf53d9f12..4aa5bd62c05a5 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
@@ -274,9 +274,15 @@ export function XyToolbar(props: VisualizationToolbarProps) {
group.groupId === 'left') || {}).length === 0
}
@@ -310,9 +316,15 @@ export function XyToolbar(props: VisualizationToolbarProps) {
toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange}
/>
group.groupId === 'right') || {}).length === 0
}
@@ -345,6 +357,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps)
const { state, setState, layerId, accessor } = props;
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
+ const isHorizontal = isHorizontalChart(state.layers);
const axisMode =
(layer.yConfig &&
layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) ||
@@ -377,15 +390,23 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps)
},
{
id: `${idPrefix}left`,
- label: i18n.translate('xpack.lens.xyChart.axisSide.left', {
- defaultMessage: 'Left',
- }),
+ label: isHorizontal
+ ? i18n.translate('xpack.lens.xyChart.axisSide.bottom', {
+ defaultMessage: 'Bottom',
+ })
+ : i18n.translate('xpack.lens.xyChart.axisSide.left', {
+ defaultMessage: 'Left',
+ }),
},
{
id: `${idPrefix}right`,
- label: i18n.translate('xpack.lens.xyChart.axisSide.right', {
- defaultMessage: 'Right',
- }),
+ label: isHorizontal
+ ? i18n.translate('xpack.lens.xyChart.axisSide.top', {
+ defaultMessage: 'Top',
+ })
+ : i18n.translate('xpack.lens.xyChart.axisSide.right', {
+ defaultMessage: 'Right',
+ }),
},
]}
idSelected={`${idPrefix}${axisMode}`}
From 03e3c852288644d961aee7b4f976275b95c83885 Mon Sep 17 00:00:00 2001
From: Constance
Date: Thu, 17 Sep 2020 08:43:40 -0700
Subject: [PATCH 10/32] [Enterprise Search] Add read-only mode interceptor and
error handler (#77569)
* Add readOnlyMode prop + callout to Layout component
* Update HttpLogic to initialize readOnlyMode from config_data
+ update App Search & Workplace Search layout to pass readOnlyMode state
- update passed props test to not refer to readOnlyMode, so as not to confuse distinction between props.readOnlyMode (passed on init, can grow stale) and HttpLogic.values.readOnlyMode (will update on every http call)
- DRY out HttpLogic initializeHttp type defs
* Update enterpriseSearchRequestHandler to pass read-only mode header
+ add a custom 503 API response for read-only mode errors that come back from API endpoints (e.g. when attempting to create/edit a document) - this is so we correctly display a flash message error instead of the generic "Error Connecting" state
+ note that we still need to send back read only mode on ALL headers, not just on handleReadOnlyModeError however - this is so that the read-only mode state can updates dynamically on all API polls (e.g. on a 200 GET)
* Add HttpLogic read-only mode interceptor
- which should now dynamically listen / update state every time an Enterprise Search API call is made
+ DRY out isEnterpriseSearchApi helper and making wrapping/branching clearer
* PR feedback: Copy
---
.../enterprise_search/common/constants.ts | 2 +
.../applications/app_search/index.test.tsx | 13 ++-
.../public/applications/app_search/index.tsx | 4 +-
.../public/applications/index.tsx | 6 +-
.../shared/http/http_logic.test.ts | 96 ++++++++++++++++---
.../applications/shared/http/http_logic.ts | 63 ++++++++----
.../shared/http/http_provider.test.tsx | 1 +
.../shared/http/http_provider.tsx | 3 +-
.../applications/shared/layout/layout.scss | 11 +++
.../shared/layout/layout.test.tsx | 8 +-
.../applications/shared/layout/layout.tsx | 21 +++-
.../workplace_search/index.test.tsx | 14 ++-
.../applications/workplace_search/index.tsx | 4 +-
.../enterprise_search_request_handler.test.ts | 61 ++++++++++--
.../lib/enterprise_search_request_handler.ts | 50 ++++++++--
15 files changed, 298 insertions(+), 59 deletions(-)
diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts
index d6a51e8b482d0..5df25f11e5070 100644
--- a/x-pack/plugins/enterprise_search/common/constants.ts
+++ b/x-pack/plugins/enterprise_search/common/constants.ts
@@ -76,4 +76,6 @@ export const JSON_HEADER = {
Accept: 'application/json', // Required for Enterprise Search APIs
};
+export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode';
+
export const ENGINES_PAGE_SIZE = 10;
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
index 94e9127bbed74..31c7680fd2f1c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
@@ -54,6 +54,7 @@ describe('AppSearchConfigured', () => {
const wrapper = shallow();
expect(wrapper.find(Layout)).toHaveLength(1);
+ expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy();
expect(wrapper.find(EngineOverview)).toHaveLength(1);
});
@@ -61,9 +62,9 @@ describe('AppSearchConfigured', () => {
const initializeAppData = jest.fn();
(useActions as jest.Mock).mockImplementation(() => ({ initializeAppData }));
- shallow();
+ shallow();
- expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true });
+ expect(initializeAppData).toHaveBeenCalledWith({ ilmEnabled: true });
});
it('does not re-initialize app data', () => {
@@ -83,6 +84,14 @@ describe('AppSearchConfigured', () => {
expect(wrapper.find(ErrorConnecting)).toHaveLength(1);
});
+
+ it('passes readOnlyMode state', () => {
+ (useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true }));
+
+ const wrapper = shallow();
+
+ expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true);
+ });
});
describe('AppSearchNav', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
index c4a366930d22a..643c4b5ccc873 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
@@ -51,7 +51,7 @@ export const AppSearchUnconfigured: React.FC = () => (
export const AppSearchConfigured: React.FC = (props) => {
const { hasInitialized } = useValues(AppLogic);
const { initializeAppData } = useActions(AppLogic);
- const { errorConnecting } = useValues(HttpLogic);
+ const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
if (!hasInitialized) initializeAppData(props);
@@ -63,7 +63,7 @@ export const AppSearchConfigured: React.FC = (props) => {
- }>
+ } readOnlyMode={readOnlyMode}>
{errorConnecting ? (
) : (
diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx
index a54295548004a..82f884644be4a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx
@@ -69,7 +69,11 @@ export const renderApp = (
>
-
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts
index c032e3b04ebe6..b65499be2f7c0 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts
@@ -16,6 +16,7 @@ describe('HttpLogic', () => {
http: null,
httpInterceptors: [],
errorConnecting: false,
+ readOnlyMode: false,
};
beforeEach(() => {
@@ -31,12 +32,17 @@ describe('HttpLogic', () => {
describe('initializeHttp()', () => {
it('sets values based on passed props', () => {
HttpLogic.mount();
- HttpLogic.actions.initializeHttp({ http: mockHttp, errorConnecting: true });
+ HttpLogic.actions.initializeHttp({
+ http: mockHttp,
+ errorConnecting: true,
+ readOnlyMode: true,
+ });
expect(HttpLogic.values).toEqual({
http: mockHttp,
httpInterceptors: [],
errorConnecting: true,
+ readOnlyMode: true,
});
});
});
@@ -52,50 +58,110 @@ describe('HttpLogic', () => {
});
});
+ describe('setReadOnlyMode()', () => {
+ it('sets readOnlyMode value', () => {
+ HttpLogic.mount();
+ HttpLogic.actions.setReadOnlyMode(true);
+ expect(HttpLogic.values.readOnlyMode).toEqual(true);
+
+ HttpLogic.actions.setReadOnlyMode(false);
+ expect(HttpLogic.values.readOnlyMode).toEqual(false);
+ });
+ });
+
describe('http interceptors', () => {
describe('initializeHttpInterceptors()', () => {
beforeEach(() => {
HttpLogic.mount();
jest.spyOn(HttpLogic.actions, 'setHttpInterceptors');
- jest.spyOn(HttpLogic.actions, 'setErrorConnecting');
HttpLogic.actions.initializeHttp({ http: mockHttp });
-
HttpLogic.actions.initializeHttpInterceptors();
});
it('calls http.intercept and sets an array of interceptors', () => {
- mockHttp.intercept.mockImplementationOnce(() => 'removeInterceptorFn' as any);
+ mockHttp.intercept
+ .mockImplementationOnce(() => 'removeErrorInterceptorFn' as any)
+ .mockImplementationOnce(() => 'removeReadOnlyInterceptorFn' as any);
HttpLogic.actions.initializeHttpInterceptors();
expect(mockHttp.intercept).toHaveBeenCalled();
- expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith(['removeInterceptorFn']);
+ expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith([
+ 'removeErrorInterceptorFn',
+ 'removeReadOnlyInterceptorFn',
+ ]);
});
describe('errorConnectingInterceptor', () => {
+ let interceptedResponse: any;
+
+ beforeEach(() => {
+ interceptedResponse = mockHttp.intercept.mock.calls[0][0].responseError;
+ jest.spyOn(HttpLogic.actions, 'setErrorConnecting');
+ });
+
it('handles errors connecting to Enterprise Search', async () => {
- const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
- const httpResponse = { response: { url: '/api/app_search/engines', status: 502 } };
- await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
+ const httpResponse = {
+ response: { url: '/api/app_search/engines', status: 502 },
+ };
+ await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).toHaveBeenCalled();
});
it('does not handle non-502 Enterprise Search errors', async () => {
- const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
- const httpResponse = { response: { url: '/api/workplace_search/overview', status: 404 } };
- await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
+ const httpResponse = {
+ response: { url: '/api/workplace_search/overview', status: 404 },
+ };
+ await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled();
});
- it('does not handle errors for unrelated calls', async () => {
- const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
- const httpResponse = { response: { url: '/api/some_other_plugin/', status: 502 } };
- await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
+ it('does not handle errors for non-Enterprise Search API calls', async () => {
+ const httpResponse = {
+ response: { url: '/api/some_other_plugin/', status: 502 },
+ };
+ await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled();
});
});
+
+ describe('readOnlyModeInterceptor', () => {
+ let interceptedResponse: any;
+
+ beforeEach(() => {
+ interceptedResponse = mockHttp.intercept.mock.calls[1][0].response;
+ jest.spyOn(HttpLogic.actions, 'setReadOnlyMode');
+ });
+
+ it('sets readOnlyMode to true if the response header is true', async () => {
+ const httpResponse = {
+ response: { url: '/api/app_search/engines', headers: { get: () => 'true' } },
+ };
+ await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
+
+ expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(true);
+ });
+
+ it('sets readOnlyMode to false if the response header is false', async () => {
+ const httpResponse = {
+ response: { url: '/api/workplace_search/overview', headers: { get: () => 'false' } },
+ };
+ await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
+
+ expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(false);
+ });
+
+ it('does not handle headers for non-Enterprise Search API calls', async () => {
+ const httpResponse = {
+ response: { url: '/api/some_other_plugin/', headers: { get: () => 'true' } },
+ };
+ await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
+
+ expect(HttpLogic.actions.setReadOnlyMode).not.toHaveBeenCalled();
+ });
+ });
});
it('sets httpInterceptors and calls all valid remove functions on unmount', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts
index ec9db30ddef3b..5e2b5a9ed6b06 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts
@@ -6,32 +6,32 @@
import { kea, MakeLogicType } from 'kea';
-import { HttpSetup, HttpInterceptorResponseError } from 'src/core/public';
+import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public';
+import { IHttpProviderProps } from './http_provider';
+
+import { READ_ONLY_MODE_HEADER } from '../../../../common/constants';
export interface IHttpValues {
http: HttpSetup;
httpInterceptors: Function[];
errorConnecting: boolean;
+ readOnlyMode: boolean;
}
export interface IHttpActions {
- initializeHttp({
- http,
- errorConnecting,
- }: {
- http: HttpSetup;
- errorConnecting?: boolean;
- }): { http: HttpSetup; errorConnecting?: boolean };
+ initializeHttp({ http, errorConnecting, readOnlyMode }: IHttpProviderProps): IHttpProviderProps;
initializeHttpInterceptors(): void;
setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] };
setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean };
+ setReadOnlyMode(readOnlyMode: boolean): { readOnlyMode: boolean };
}
export const HttpLogic = kea>({
actions: {
- initializeHttp: ({ http, errorConnecting }) => ({ http, errorConnecting }),
+ initializeHttp: (props) => props,
initializeHttpInterceptors: () => null,
setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }),
setErrorConnecting: (errorConnecting) => ({ errorConnecting }),
+ setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }),
},
reducers: {
http: [
@@ -53,6 +53,13 @@ export const HttpLogic = kea>({
setErrorConnecting: (_, { errorConnecting }) => errorConnecting,
},
],
+ readOnlyMode: [
+ false,
+ {
+ initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode,
+ setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode,
+ },
+ ],
},
listeners: ({ values, actions }) => ({
initializeHttpInterceptors: () => {
@@ -60,13 +67,13 @@ export const HttpLogic = kea>({
const errorConnectingInterceptor = values.http.intercept({
responseError: async (httpResponse) => {
- const { url, status } = httpResponse.response!;
- const hasErrorConnecting = status === 502;
- const isApiResponse =
- url.includes('/api/app_search/') || url.includes('/api/workplace_search/');
+ if (isEnterpriseSearchApi(httpResponse)) {
+ const { status } = httpResponse.response!;
+ const hasErrorConnecting = status === 502;
- if (isApiResponse && hasErrorConnecting) {
- actions.setErrorConnecting(true);
+ if (hasErrorConnecting) {
+ actions.setErrorConnecting(true);
+ }
}
// Re-throw error so that downstream catches work as expected
@@ -75,7 +82,23 @@ export const HttpLogic = kea>({
});
httpInterceptors.push(errorConnectingInterceptor);
- // TODO: Read only mode interceptor
+ const readOnlyModeInterceptor = values.http.intercept({
+ response: async (httpResponse) => {
+ if (isEnterpriseSearchApi(httpResponse)) {
+ const readOnlyMode = httpResponse.response!.headers.get(READ_ONLY_MODE_HEADER);
+
+ if (readOnlyMode === 'true') {
+ actions.setReadOnlyMode(true);
+ } else {
+ actions.setReadOnlyMode(false);
+ }
+ }
+
+ return Promise.resolve(httpResponse);
+ },
+ });
+ httpInterceptors.push(readOnlyModeInterceptor);
+
actions.setHttpInterceptors(httpInterceptors);
},
}),
@@ -87,3 +110,11 @@ export const HttpLogic = kea>({
},
}),
});
+
+/**
+ * Small helper that checks whether or not an http call is for an Enterprise Search API
+ */
+const isEnterpriseSearchApi = (httpResponse: HttpResponse) => {
+ const { url } = httpResponse.response!;
+ return url.includes('/api/app_search/') || url.includes('/api/workplace_search/');
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx
index 81106235780d6..902c910f10d7c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx
@@ -17,6 +17,7 @@ describe('HttpProvider', () => {
const props = {
http: {} as any,
errorConnecting: false,
+ readOnlyMode: false,
};
const initializeHttp = jest.fn();
const initializeHttpInterceptors = jest.fn();
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx
index 4c2160195a1af..db1b0d611079a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx
@@ -11,9 +11,10 @@ import { HttpSetup } from 'src/core/public';
import { HttpLogic } from './http_logic';
-interface IHttpProviderProps {
+export interface IHttpProviderProps {
http: HttpSetup;
errorConnecting?: boolean;
+ readOnlyMode?: boolean;
}
export const HttpProvider: React.FC = (props) => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss
index f6c83888413d3..e867e9cf5a445 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss
@@ -81,4 +81,15 @@
padding: $euiSize;
}
}
+
+ &__readOnlyMode {
+ margin: -$euiSizeM 0 $euiSizeL;
+
+ @include euiBreakpoint('m') {
+ margin: 0 0 $euiSizeL;
+ }
+ @include euiBreakpoint('xs', 's') {
+ margin: 0;
+ }
+ }
}
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx
index 623e6e47167d2..7b876d81527fa 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx
@@ -6,7 +6,7 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { EuiPageSideBar, EuiButton, EuiPageBody } from '@elastic/eui';
+import { EuiPageSideBar, EuiButton, EuiPageBody, EuiCallOut } from '@elastic/eui';
import { Layout, INavContext } from './layout';
@@ -55,6 +55,12 @@ describe('Layout', () => {
expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen');
});
+ it('renders a read-only mode callout', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(EuiCallOut)).toHaveLength(1);
+ });
+
it('renders children', () => {
const wrapper = shallow(
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx
index e122c4d5cfdfa..ef8216e8b6711 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx
@@ -7,7 +7,7 @@
import React, { useState } from 'react';
import classNames from 'classnames';
-import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton } from '@elastic/eui';
+import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import './layout.scss';
@@ -15,6 +15,7 @@ import './layout.scss';
interface ILayoutProps {
navigation: React.ReactNode;
restrictWidth?: boolean;
+ readOnlyMode?: boolean;
}
export interface INavContext {
@@ -22,7 +23,12 @@ export interface INavContext {
}
export const NavContext = React.createContext({});
-export const Layout: React.FC = ({ children, navigation, restrictWidth }) => {
+export const Layout: React.FC = ({
+ children,
+ navigation,
+ restrictWidth,
+ readOnlyMode,
+}) => {
const [isNavOpen, setIsNavOpen] = useState(false);
const toggleNavigation = () => setIsNavOpen(!isNavOpen);
const closeNavigation = () => setIsNavOpen(false);
@@ -56,6 +62,17 @@ export const Layout: React.FC = ({ children, navigation, restrictW
{navigation}
+ {readOnlyMode && (
+
+ )}
{children}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx
index 39280ad6f4be4..fc1943264d72b 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx
@@ -12,6 +12,7 @@ import { Redirect } from 'react-router-dom';
import { shallow } from 'enzyme';
import { useValues, useActions } from 'kea';
+import { Layout } from '../shared/layout';
import { SetupGuide } from './views/setup_guide';
import { ErrorState } from './views/error_state';
import { Overview } from './views/overview';
@@ -53,6 +54,7 @@ describe('WorkplaceSearchConfigured', () => {
it('renders with layout', () => {
const wrapper = shallow();
+ expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy();
expect(wrapper.find(Overview)).toHaveLength(1);
});
@@ -60,9 +62,9 @@ describe('WorkplaceSearchConfigured', () => {
const initializeAppData = jest.fn();
(useActions as jest.Mock).mockImplementation(() => ({ initializeAppData }));
- shallow();
+ shallow();
- expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true });
+ expect(initializeAppData).toHaveBeenCalledWith({ isFederatedAuth: true });
});
it('does not re-initialize app data', () => {
@@ -82,4 +84,12 @@ describe('WorkplaceSearchConfigured', () => {
expect(wrapper.find(ErrorState)).toHaveLength(2);
});
+
+ it('passes readOnlyMode state', () => {
+ (useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true }));
+
+ const wrapper = shallow();
+
+ expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true);
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
index 6a51b49869eaf..a68dfaf8ea471 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
@@ -31,7 +31,7 @@ export const WorkplaceSearch: React.FC = (props) => {
export const WorkplaceSearchConfigured: React.FC = (props) => {
const { hasInitialized } = useValues(AppLogic);
const { initializeAppData } = useActions(AppLogic);
- const { errorConnecting } = useValues(HttpLogic);
+ const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
if (!hasInitialized) initializeAppData(props);
@@ -46,7 +46,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => {
{errorConnecting ? : }
- }>
+ } readOnlyMode={readOnlyMode}>
{errorConnecting ? (
) : (
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
index 0c1e81e3aba46..3d0a3181f8ab8 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts
@@ -5,7 +5,7 @@
*/
import { mockConfig, mockLogger } from '../__mocks__';
-import { JSON_HEADER } from '../../common/constants';
+import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler';
@@ -18,6 +18,9 @@ const responseMock = {
custom: jest.fn(),
customError: jest.fn(),
};
+const mockExpectedResponseHeaders = {
+ [READ_ONLY_MODE_HEADER]: 'false',
+};
describe('EnterpriseSearchRequestHandler', () => {
const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({
@@ -58,6 +61,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.custom).toHaveBeenCalledWith({
body: responseBody,
statusCode: 200,
+ headers: mockExpectedResponseHeaders,
});
});
@@ -112,11 +116,12 @@ describe('EnterpriseSearchRequestHandler', () => {
await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example');
- expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 201 });
+ expect(responseMock.custom).toHaveBeenCalledWith({
+ body: {},
+ statusCode: 201,
+ headers: mockExpectedResponseHeaders,
+ });
});
-
- // TODO: It's possible we may also pass back headers at some point
- // from Enterprise Search, e.g. the x-read-only mode header
});
});
@@ -140,6 +145,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'some error message',
attributes: { errors: ['some error message'] },
},
+ headers: mockExpectedResponseHeaders,
});
});
@@ -156,6 +162,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'one,two,three',
attributes: { errors: ['one', 'two', 'three'] },
},
+ headers: mockExpectedResponseHeaders,
});
});
@@ -171,6 +178,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Bad Request',
attributes: { errors: ['Bad Request'] },
},
+ headers: mockExpectedResponseHeaders,
});
});
@@ -186,6 +194,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Bad Request',
attributes: { errors: ['Bad Request'] },
},
+ headers: mockExpectedResponseHeaders,
});
});
@@ -201,6 +210,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Not Found',
attributes: { errors: ['Not Found'] },
},
+ headers: mockExpectedResponseHeaders,
});
});
});
@@ -215,12 +225,33 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: expect.stringContaining('Enterprise Search encountered an internal server error'),
+ headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Enterprise Search Server Error 500 at : "something crashed!"'
);
});
+ it('handleReadOnlyModeError()', async () => {
+ EnterpriseSearchAPI.mockReturn(
+ { errors: ['Read only mode'] },
+ { status: 503, headers: { ...JSON_HEADER, [READ_ONLY_MODE_HEADER]: 'true' } }
+ );
+ const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/503' });
+
+ await makeAPICall(requestHandler);
+ EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/503');
+
+ expect(responseMock.customError).toHaveBeenCalledWith({
+ statusCode: 503,
+ body: expect.stringContaining('Enterprise Search is in read-only mode'),
+ headers: { [READ_ONLY_MODE_HEADER]: 'true' },
+ });
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ 'Cannot perform action: Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.'
+ );
+ });
+
it('handleInvalidDataError()', async () => {
EnterpriseSearchAPI.mockReturn({ results: false });
const requestHandler = enterpriseSearchRequestHandler.createRequest({
@@ -234,6 +265,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Invalid data received from Enterprise Search',
+ headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Invalid data received from : {"results":false}'
@@ -250,6 +282,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Error connecting to Enterprise Search: Failed',
+ headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalled();
});
@@ -265,6 +298,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Cannot authenticate Enterprise Search user',
+ headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalled();
});
@@ -279,6 +313,18 @@ describe('EnterpriseSearchRequestHandler', () => {
});
});
+ it('setResponseHeaders', async () => {
+ EnterpriseSearchAPI.mockReturn('anything' as any, {
+ headers: { [READ_ONLY_MODE_HEADER]: 'true' },
+ });
+ const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' });
+ await makeAPICall(requestHandler);
+
+ expect(enterpriseSearchRequestHandler.headers).toEqual({
+ [READ_ONLY_MODE_HEADER]: 'true',
+ });
+ });
+
it('isEmptyObj', async () => {
expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true);
expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false);
@@ -304,9 +350,10 @@ const EnterpriseSearchAPI = {
...expectedParams,
});
},
- mockReturn(response: object, options?: object) {
+ mockReturn(response: object, options?: any) {
fetchMock.mockImplementation(() => {
- return Promise.resolve(new Response(JSON.stringify(response), options));
+ const headers = Object.assign({}, mockExpectedResponseHeaders, options?.headers);
+ return Promise.resolve(new Response(JSON.stringify(response), { ...options, headers }));
});
},
mockReturnError() {
diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
index 00d5eaf5d6a83..6b65c16c832fd 100644
--- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
+++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts
@@ -14,7 +14,7 @@ import {
Logger,
} from 'src/core/server';
import { ConfigType } from '../index';
-import { JSON_HEADER } from '../../common/constants';
+import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
interface IConstructorDependencies {
config: ConfigType;
@@ -46,6 +46,7 @@ export interface IEnterpriseSearchRequestHandler {
export class EnterpriseSearchRequestHandler {
private enterpriseSearchUrl: string;
private log: Logger;
+ private headers: Record = {};
constructor({ config, log }: IConstructorDependencies) {
this.log = log;
@@ -80,6 +81,9 @@ export class EnterpriseSearchRequestHandler {
// Call the Enterprise Search API
const apiResponse = await fetch(url, { method, headers, body });
+ // Handle response headers
+ this.setResponseHeaders(apiResponse);
+
// Handle authentication redirects
if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) {
return this.handleAuthenticationError(response);
@@ -88,7 +92,13 @@ export class EnterpriseSearchRequestHandler {
// Handle 400-500+ responses from the Enterprise Search server
const { status } = apiResponse;
if (status >= 500) {
- return this.handleServerError(response, apiResponse, url);
+ if (this.headers[READ_ONLY_MODE_HEADER] === 'true') {
+ // Handle 503 read-only mode errors
+ return this.handleReadOnlyModeError(response);
+ } else {
+ // Handle unexpected server errors
+ return this.handleServerError(response, apiResponse, url);
+ }
} else if (status >= 400) {
return this.handleClientError(response, apiResponse);
}
@@ -100,7 +110,11 @@ export class EnterpriseSearchRequestHandler {
}
// Pass successful responses back to the front-end
- return response.custom({ statusCode: status, body: json });
+ return response.custom({
+ statusCode: status,
+ headers: this.headers,
+ body: json,
+ });
} catch (e) {
// Catch connection/auth errors
return this.handleConnectionError(response, e);
@@ -160,7 +174,7 @@ export class EnterpriseSearchRequestHandler {
const { status } = apiResponse;
const body = await this.getErrorResponseBody(apiResponse);
- return response.customError({ statusCode: status, body });
+ return response.customError({ statusCode: status, headers: this.headers, body });
}
async handleServerError(response: KibanaResponseFactory, apiResponse: Response, url: string) {
@@ -172,14 +186,22 @@ export class EnterpriseSearchRequestHandler {
'Enterprise Search encountered an internal server error. Please contact your system administrator if the problem persists.';
this.log.error(`Enterprise Search Server Error ${status} at <${url}>: ${message}`);
- return response.customError({ statusCode: 502, body: errorMessage });
+ return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
+ }
+
+ handleReadOnlyModeError(response: KibanaResponseFactory) {
+ const errorMessage =
+ 'Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.';
+
+ this.log.error(`Cannot perform action: ${errorMessage}`);
+ return response.customError({ statusCode: 503, headers: this.headers, body: errorMessage });
}
handleInvalidDataError(response: KibanaResponseFactory, url: string, json: object) {
const errorMessage = 'Invalid data received from Enterprise Search';
this.log.error(`Invalid data received from <${url}>: ${JSON.stringify(json)}`);
- return response.customError({ statusCode: 502, body: errorMessage });
+ return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
handleConnectionError(response: KibanaResponseFactory, e: Error) {
@@ -188,14 +210,26 @@ export class EnterpriseSearchRequestHandler {
this.log.error(errorMessage);
if (e instanceof Error) this.log.debug(e.stack as string);
- return response.customError({ statusCode: 502, body: errorMessage });
+ return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
handleAuthenticationError(response: KibanaResponseFactory) {
const errorMessage = 'Cannot authenticate Enterprise Search user';
this.log.error(errorMessage);
- return response.customError({ statusCode: 502, body: errorMessage });
+ return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
+ }
+
+ /**
+ * Set response headers
+ *
+ * Currently just forwards the read-only mode header, but we can expand this
+ * in the future to pass more headers from Enterprise Search as we need them
+ */
+
+ setResponseHeaders(apiResponse: Response) {
+ const readOnlyMode = apiResponse.headers.get(READ_ONLY_MODE_HEADER);
+ this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false';
}
/**
From 161a74f732dc1c5a06c1c5c342e33399b2993077 Mon Sep 17 00:00:00 2001
From: Pierre Gayvallet
Date: Thu, 17 Sep 2020 18:00:04 +0200
Subject: [PATCH 11/32] remove legacy security plugin (#75648)
* remove legacy security plugin
* remove legacy plugin check
* adapt mocha tests
* update CODEOWNERS
---
.github/CODEOWNERS | 4 +---
x-pack/index.js | 3 +--
x-pack/legacy/plugins/security/index.ts | 21 ---------------------
3 files changed, 2 insertions(+), 26 deletions(-)
delete mode 100644 x-pack/legacy/plugins/security/index.ts
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d81f6af4cec28..1077230812b60 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -167,7 +167,6 @@
# Security
/src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform
-/x-pack/legacy/plugins/security/ @elastic/kibana-security
/x-pack/legacy/plugins/spaces/ @elastic/kibana-security
/x-pack/plugins/spaces/ @elastic/kibana-security
/x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security
@@ -287,7 +286,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib
/src/plugins/dashboard/**/*.scss @elastic/kibana-core-ui-designers
/x-pack/plugins/canvas/**/*.scss @elastic/kibana-core-ui-designers
/src/legacy/core_plugins/kibana/public/home/**/*.scss @elastic/kibana-core-ui-designers
-/x-pack/legacy/plugins/security/**/*.scss @elastic/kibana-core-ui-designers
/x-pack/legacy/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers
/x-pack/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers
/x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers
@@ -297,7 +295,7 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib
/x-pack/plugins/infra/**/*.scss @elastic/observability-design
/x-pack/plugins/ingest_manager/**/*.scss @elastic/observability-design
/x-pack/plugins/observability/**/*.scss @elastic/observability-design
-/x-pack/plugins/monitoring/**/*.scss @elastic/observability-design
+/x-pack/plugins/monitoring/**/*.scss @elastic/observability-design
# Ent. Search design
/x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design
diff --git a/x-pack/index.js b/x-pack/index.js
index 074b8e6859dc2..745b4bd72dde8 100644
--- a/x-pack/index.js
+++ b/x-pack/index.js
@@ -5,9 +5,8 @@
*/
import { xpackMain } from './legacy/plugins/xpack_main';
-import { security } from './legacy/plugins/security';
import { spaces } from './legacy/plugins/spaces';
module.exports = function (kibana) {
- return [xpackMain(kibana), spaces(kibana), security(kibana)];
+ return [xpackMain(kibana), spaces(kibana)];
};
diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts
deleted file mode 100644
index c3596d3745e57..0000000000000
--- a/x-pack/legacy/plugins/security/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { Root } from 'joi';
-import { resolve } from 'path';
-
-export const security = (kibana: Record) =>
- new kibana.Plugin({
- id: 'security',
- publicDir: resolve(__dirname, 'public'),
- require: ['elasticsearch'],
- configPrefix: 'xpack.security',
- config: (Joi: Root) =>
- Joi.object({ enabled: Joi.boolean().default(true) })
- .unknown()
- .default(),
- init() {},
- });
From 9c90c14b25bb02e05c9bddc8e14cd04e82160f4d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cau=C3=AA=20Marcondes?=
<55978943+cauemarcondes@users.noreply.github.com>
Date: Thu, 17 Sep 2020 17:13:06 +0100
Subject: [PATCH 12/32] [APM] Show accurate metrics for containerized
applications (#76768)
* adding cgroup fields
* calculate the memory based on the cgroup fields otherwise use the system fields
* updating script
* adding api tests
* using cgroup fields on service map
* addressing PR comment
* fixing unit test
* fixing test
* changing api tests to use snapshot
* removing inactive_files from calculation
* fixing test
* refactoring painless script
* addressing PR comment
* addressing pr comments
Co-authored-by: Elastic Machine
---
.../elasticsearch_fieldnames.test.ts.snap | 12 +
.../apm/common/elasticsearch_fieldnames.ts | 4 +
.../__snapshots__/queries.test.ts.snap | 273 +-
.../metrics/by_agent/shared/memory/index.ts | 64 +-
.../get_service_map_service_node_info.ts | 66 +-
.../apm_api_integration/basic/tests/index.ts | 4 +
.../tests/metrics_charts/metrics_charts.ts | 440 ++
.../es_archiver/metrics_8.0.0/data.json.gz | Bin 0 -> 177873 bytes
.../es_archiver/metrics_8.0.0/mappings.json | 4092 +++++++++++++++++
9 files changed, 4879 insertions(+), 76 deletions(-)
create mode 100644 x-pack/test/apm_api_integration/basic/tests/metrics_charts/metrics_charts.ts
create mode 100644 x-pack/test/apm_api_integration/common/fixtures/es_archiver/metrics_8.0.0/data.json.gz
create mode 100644 x-pack/test/apm_api_integration/common/fixtures/es_archiver/metrics_8.0.0/mappings.json
diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
index 3524d41646d50..8c233d3691c7f 100644
--- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
+++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap
@@ -52,6 +52,10 @@ exports[`Error LABEL_NAME 1`] = `undefined`;
exports[`Error LCP_FIELD 1`] = `undefined`;
+exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
+
+exports[`Error METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`;
+
exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Error METRIC_JAVA_GC_TIME 1`] = `undefined`;
@@ -220,6 +224,10 @@ exports[`Span LABEL_NAME 1`] = `undefined`;
exports[`Span LCP_FIELD 1`] = `undefined`;
+exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
+
+exports[`Span METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`;
+
exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Span METRIC_JAVA_GC_TIME 1`] = `undefined`;
@@ -388,6 +396,10 @@ exports[`Transaction LABEL_NAME 1`] = `undefined`;
exports[`Transaction LCP_FIELD 1`] = `undefined`;
+exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
+
+exports[`Transaction METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`;
+
exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`;
exports[`Transaction METRIC_JAVA_GC_TIME 1`] = `undefined`;
diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
index 612cb18bbe190..cc6a1fffb2288 100644
--- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
+++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts
@@ -79,6 +79,10 @@ export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free';
export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total';
export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct';
export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct';
+export const METRIC_CGROUP_MEMORY_LIMIT_BYTES =
+ 'system.process.cgroup.memory.mem.limit.bytes';
+export const METRIC_CGROUP_MEMORY_USAGE_BYTES =
+ 'system.process.cgroup.memory.mem.usage.bytes';
export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max';
export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed';
diff --git a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap
index b88c90a213c67..2868dcfda97b6 100644
--- a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap
+++ b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap
@@ -203,16 +203,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -221,16 +255,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -275,12 +343,7 @@ Object {
},
Object {
"exists": Object {
- "field": "system.memory.actual.free",
- },
- },
- Object {
- "exists": Object {
- "field": "system.memory.total",
+ "field": "system.process.cgroup.memory.mem.usage.bytes",
},
},
],
@@ -682,16 +745,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -700,16 +797,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -760,12 +891,7 @@ Object {
},
Object {
"exists": Object {
- "field": "system.memory.actual.free",
- },
- },
- Object {
- "exists": Object {
- "field": "system.memory.total",
+ "field": "system.process.cgroup.memory.mem.usage.bytes",
},
},
],
@@ -1157,16 +1283,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -1175,16 +1335,50 @@ Object {
"memoryUsedAvg": Object {
"avg": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
"memoryUsedMax": Object {
"max": Object {
"script": Object {
- "lang": "expression",
- "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']",
+ "lang": "painless",
+ "source": "
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = 'system.process.cgroup.memory.mem.limit.bytes';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value;
+
+ double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value;
+
+ return used / total;
+ ",
},
},
},
@@ -1224,12 +1418,7 @@ Object {
},
Object {
"exists": Object {
- "field": "system.memory.actual.free",
- },
- },
- Object {
- "exists": Object {
- "field": "system.memory.total",
+ "field": "system.process.cgroup.memory.mem.usage.bytes",
},
},
],
diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts
index 316b0d59d2c5b..a60576ca0c175 100644
--- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts
+++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts
@@ -6,6 +6,8 @@
import { i18n } from '@kbn/i18n';
import {
+ METRIC_CGROUP_MEMORY_LIMIT_BYTES,
+ METRIC_CGROUP_MEMORY_USAGE_BYTES,
METRIC_SYSTEM_FREE_MEMORY,
METRIC_SYSTEM_TOTAL_MEMORY,
} from '../../../../../../common/elasticsearch_fieldnames';
@@ -14,8 +16,8 @@ import {
SetupTimeRange,
SetupUIFilters,
} from '../../../../helpers/setup_request';
-import { ChartBase } from '../../../types';
import { fetchAndTransformMetrics } from '../../../fetch_and_transform_metrics';
+import { ChartBase } from '../../../types';
const series = {
memoryUsedMax: {
@@ -43,36 +45,68 @@ const chartBase: ChartBase = {
series,
};
-export const percentMemoryUsedScript = {
+export const percentSystemMemoryUsedScript = {
lang: 'expression',
source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']`,
};
+export const percentCgroupMemoryUsedScript = {
+ lang: 'painless',
+ source: `
+ /*
+ When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants.
+ This number represents the max possible value for the limit field.
+ */
+ double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L;
+
+ String limitKey = '${METRIC_CGROUP_MEMORY_LIMIT_BYTES}';
+
+ //Should use cgropLimit when value is not empty and not equals to the max limit value.
+ boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE;
+
+ double total = useCgroupLimit ? doc[limitKey].value : doc['${METRIC_SYSTEM_TOTAL_MEMORY}'].value;
+
+ double used = doc['${METRIC_CGROUP_MEMORY_USAGE_BYTES}'].value;
+
+ return used / total;
+ `,
+};
+
export async function getMemoryChartData(
setup: Setup & SetupTimeRange & SetupUIFilters,
serviceName: string,
serviceNodeName?: string
) {
- return fetchAndTransformMetrics({
+ const cgroupResponse = await fetchAndTransformMetrics({
setup,
serviceName,
serviceNodeName,
chartBase,
aggs: {
- memoryUsedAvg: { avg: { script: percentMemoryUsedScript } },
- memoryUsedMax: { max: { script: percentMemoryUsedScript } },
+ memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } },
+ memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } },
},
additionalFilters: [
- {
- exists: {
- field: METRIC_SYSTEM_FREE_MEMORY,
- },
- },
- {
- exists: {
- field: METRIC_SYSTEM_TOTAL_MEMORY,
- },
- },
+ { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } },
],
});
+
+ if (cgroupResponse.noHits) {
+ return await fetchAndTransformMetrics({
+ setup,
+ serviceName,
+ serviceNodeName,
+ chartBase,
+ aggs: {
+ memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } },
+ memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } },
+ },
+ additionalFilters: [
+ { exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
+ { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
+ ],
+ });
+ }
+
+ return cgroupResponse;
}
diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts
index 88cc26608b850..5c183fd9150dd 100644
--- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts
+++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts
@@ -14,13 +14,17 @@ import {
METRIC_SYSTEM_CPU_PERCENT,
METRIC_SYSTEM_FREE_MEMORY,
METRIC_SYSTEM_TOTAL_MEMORY,
+ METRIC_CGROUP_MEMORY_USAGE_BYTES,
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { rangeFilter } from '../../../common/utils/range_filter';
import { ESFilter } from '../../../typings/elasticsearch';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
-import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory';
+import {
+ percentCgroupMemoryUsedScript,
+ percentSystemMemoryUsedScript,
+} from '../metrics/by_agent/shared/memory';
import {
getProcessorEventForAggregatedTransactions,
getTransactionDurationFieldForAggregatedTransactions,
@@ -205,26 +209,50 @@ async function getMemoryStats({
filter,
}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> {
const { apmEventClient } = setup;
- const response = await apmEventClient.search({
- apm: {
- events: [ProcessorEvent.metric],
- },
- body: {
- size: 0,
- query: {
- bool: {
- filter: [
- ...filter,
- { exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
- { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
- ],
+
+ const getAvgMemoryUsage = async ({
+ additionalFilters,
+ script,
+ }: {
+ additionalFilters: ESFilter[];
+ script: typeof percentCgroupMemoryUsedScript;
+ }) => {
+ const response = await apmEventClient.search({
+ apm: {
+ events: [ProcessorEvent.metric],
+ },
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [...filter, ...additionalFilters],
+ },
+ },
+ aggs: {
+ avgMemoryUsage: { avg: { script } },
},
},
- aggs: { avgMemoryUsage: { avg: { script: percentMemoryUsedScript } } },
- },
- });
+ });
- return {
- avgMemoryUsage: response.aggregations?.avgMemoryUsage.value ?? null,
+ return response.aggregations?.avgMemoryUsage.value ?? null;
};
+
+ let avgMemoryUsage = await getAvgMemoryUsage({
+ additionalFilters: [
+ { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } },
+ ],
+ script: percentCgroupMemoryUsedScript,
+ });
+
+ if (!avgMemoryUsage) {
+ avgMemoryUsage = await getAvgMemoryUsage({
+ additionalFilters: [
+ { exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
+ { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
+ ],
+ script: percentSystemMemoryUsedScript,
+ });
+ }
+
+ return { avgMemoryUsage };
}
diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts
index bae94d89e7457..8aa509b0899ce 100644
--- a/x-pack/test/apm_api_integration/basic/tests/index.ts
+++ b/x-pack/test/apm_api_integration/basic/tests/index.ts
@@ -52,5 +52,9 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont
loadTestFile(require.resolve('./observability_overview/has_data'));
loadTestFile(require.resolve('./observability_overview/observability_overview'));
});
+
+ describe('Metrics', function () {
+ loadTestFile(require.resolve('./metrics_charts/metrics_charts'));
+ });
});
}
diff --git a/x-pack/test/apm_api_integration/basic/tests/metrics_charts/metrics_charts.ts b/x-pack/test/apm_api_integration/basic/tests/metrics_charts/metrics_charts.ts
new file mode 100644
index 0000000000000..f82e16e090eae
--- /dev/null
+++ b/x-pack/test/apm_api_integration/basic/tests/metrics_charts/metrics_charts.ts
@@ -0,0 +1,440 @@
+/*
+ * 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 { first } from 'lodash';
+import { MetricsChartsByAgentAPIResponse } from '../../../../../plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent';
+import { GenericMetricsChart } from '../../../../../plugins/apm/server/lib/metrics/transform_metrics_chart';
+import { FtrProviderContext } from '../../../common/ftr_provider_context';
+import { expectSnapshot } from '../../../common/match_snapshot';
+
+interface ChartResponse {
+ body: MetricsChartsByAgentAPIResponse;
+ status: number;
+}
+
+export default function ApiTest({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+
+ describe('when data is loaded', () => {
+ before(() => esArchiver.load('metrics_8.0.0'));
+ after(() => esArchiver.unload('metrics_8.0.0'));
+
+ describe('for opbeans-node', () => {
+ const start = encodeURIComponent('2020-09-08T14:50:00.000Z');
+ const end = encodeURIComponent('2020-09-08T14:55:00.000Z');
+ const uiFilters = encodeURIComponent(JSON.stringify({}));
+ const agentName = 'nodejs';
+
+ describe('returns metrics data', () => {
+ let chartsResponse: ChartResponse;
+ before(async () => {
+ chartsResponse = await supertest.get(
+ `/api/apm/services/opbeans-node/metrics/charts?start=${start}&end=${end}&uiFilters=${uiFilters}&agentName=${agentName}`
+ );
+ });
+ it('contains CPU usage and System memory usage chart data', async () => {
+ expect(chartsResponse.status).to.be(200);
+ expectSnapshot(chartsResponse.body.charts.map((chart) => chart.title)).toMatchInline(`
+ Array [
+ "CPU usage",
+ "System memory usage",
+ ]
+ `);
+ });
+
+ describe('CPU usage', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(({ key }) => key === 'cpu_usage_chart');
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "System max",
+ "System average",
+ "Process max",
+ "Process average",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0.714,
+ 0.38770000000000004,
+ 0.75,
+ 0.2543,
+ ]
+ `);
+ });
+ });
+
+ describe("System memory usage (using 'system.memory' fields to calculate the memory usage)", () => {
+ let systemMemoryUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ systemMemoryUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'memory_usage_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(systemMemoryUsageChart).to.not.empty();
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Max",
+ "Average",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0.7220939209255549,
+ 0.7181735467963479,
+ ]
+ `);
+ });
+ });
+ });
+ });
+
+ describe('for opbeans-java', () => {
+ const uiFilters = encodeURIComponent(JSON.stringify({}));
+ const agentName = 'java';
+
+ describe('returns metrics data', () => {
+ const start = encodeURIComponent('2020-09-08T14:55:30.000Z');
+ const end = encodeURIComponent('2020-09-08T15:00:00.000Z');
+
+ let chartsResponse: ChartResponse;
+ before(async () => {
+ chartsResponse = await supertest.get(
+ `/api/apm/services/opbeans-java/metrics/charts?start=${start}&end=${end}&uiFilters=${uiFilters}&agentName=${agentName}`
+ );
+ });
+
+ it('has correct chart data', async () => {
+ expect(chartsResponse.status).to.be(200);
+ expectSnapshot(chartsResponse.body.charts.map((chart) => chart.title)).toMatchInline(`
+ Array [
+ "CPU usage",
+ "System memory usage",
+ "Heap Memory",
+ "Non-Heap Memory",
+ "Thread Count",
+ "Garbage collection per minute",
+ "Garbage collection time spent per minute",
+ ]
+ `);
+ });
+
+ describe('CPU usage', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(({ key }) => key === 'cpu_usage_chart');
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "System max",
+ "System average",
+ "Process max",
+ "Process average",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0.203,
+ 0.17877777777777779,
+ 0.01,
+ 0.009000000000000001,
+ ]
+ `);
+ });
+
+ it('has the correct rate', async () => {
+ const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 0.193,
+ 0.193,
+ 0.009000000000000001,
+ 0.009000000000000001,
+ ]
+ `);
+ });
+ });
+
+ describe("System memory usage (using 'system.process.cgroup' fields to calculate the memory usage)", () => {
+ let systemMemoryUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ systemMemoryUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'memory_usage_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(systemMemoryUsageChart).to.not.empty();
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Max",
+ "Average",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0.7079247035578369,
+ 0.7053959808411816,
+ ]
+ `);
+ });
+
+ it('has the correct rate', async () => {
+ const yValues = systemMemoryUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 0.7079247035578369,
+ 0.7079247035578369,
+ ]
+ `);
+ });
+ });
+
+ describe('Heap Memory', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'heap_memory_area_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Avg. used",
+ "Avg. committed",
+ "Avg. limit",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 222501617.7777778,
+ 374341632,
+ 1560281088,
+ ]
+ `);
+ });
+
+ it('has the correct rate', async () => {
+ const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 211472896,
+ 374341632,
+ 1560281088,
+ ]
+ `);
+ });
+ });
+
+ describe('Non-Heap Memory', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'non_heap_memory_area_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Avg. used",
+ "Avg. committed",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 138573397.33333334,
+ 147677639.1111111,
+ ]
+ `);
+ });
+
+ it('has the correct rate', async () => {
+ const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 138162752,
+ 147386368,
+ ]
+ `);
+ });
+ });
+
+ describe('Thread Count', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'thread_count_line_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Avg. count",
+ "Max count",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 44.44444444444444,
+ 45,
+ ]
+ `);
+ });
+
+ it('has the correct rate', async () => {
+ const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 44,
+ 44,
+ ]
+ `);
+ });
+ });
+
+ describe('Garbage collection per minute', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'gc_rate_line_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "G1 Old Generation",
+ "G1 Young Generation",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0,
+ 15,
+ ]
+ `);
+ });
+ });
+
+ describe('Garbage collection time spent per minute', () => {
+ let cpuUsageChart: GenericMetricsChart | undefined;
+ before(() => {
+ cpuUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'gc_time_line_chart'
+ );
+ });
+
+ it('has correct series', () => {
+ expect(cpuUsageChart).to.not.empty();
+ expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "G1 Old Generation",
+ "G1 Young Generation",
+ ]
+ `);
+ });
+
+ it('has correct series overall values', () => {
+ expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0,
+ 187.5,
+ ]
+ `);
+ });
+ });
+ });
+
+ // 9223372036854771712 = memory limit for a c-group when no memory limit is specified
+ it('calculates system memory usage using system total field when cgroup limit is equal to 9223372036854771712', async () => {
+ const start = encodeURIComponent('2020-09-08T15:00:30.000Z');
+ const end = encodeURIComponent('2020-09-08T15:05:00.000Z');
+
+ const chartsResponse: ChartResponse = await supertest.get(
+ `/api/apm/services/opbeans-java/metrics/charts?start=${start}&end=${end}&uiFilters=${uiFilters}&agentName=${agentName}`
+ );
+
+ const systemMemoryUsageChart = chartsResponse.body.charts.find(
+ ({ key }) => key === 'memory_usage_chart'
+ );
+
+ expect(systemMemoryUsageChart).to.not.empty();
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)).toMatchInline(`
+ Array [
+ "Max",
+ "Average",
+ ]
+ `);
+ expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue))
+ .toMatchInline(`
+ Array [
+ 0.11452389642649889,
+ 0.11400237609041514,
+ ]
+ `);
+
+ const yValues = systemMemoryUsageChart?.series.map((serie) => first(serie.data)?.y);
+ expectSnapshot(yValues).toMatchInline(`
+ Array [
+ 0.11383724014063981,
+ 0.11383724014063981,
+ ]
+ `);
+ });
+ });
+ });
+}
diff --git a/x-pack/test/apm_api_integration/common/fixtures/es_archiver/metrics_8.0.0/data.json.gz b/x-pack/test/apm_api_integration/common/fixtures/es_archiver/metrics_8.0.0/data.json.gz
new file mode 100644
index 0000000000000000000000000000000000000000..7b38da28496a57defd128fae94e0fce1648b3afc
GIT binary patch
literal 177873
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