From 250fe67828bb1af012b5f8f6a00ef0c4e5ca984a Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 5 Nov 2020 12:23:57 -0600 Subject: [PATCH 01/20] [Metrics UI] Add full custom metric UI to inventory alerts (#81929) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../inventory/components/expression.test.tsx | 4 +- .../inventory/components/expression.tsx | 85 ++---- .../alerting/inventory/components/metric.tsx | 273 +++++++++++++++--- .../inventory/components/validation.tsx | 14 +- 4 files changed, 282 insertions(+), 94 deletions(-) diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 60a00371e5ade5..54d3b783d22f6c 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -12,7 +12,7 @@ import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/ap // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; import React from 'react'; -import { Expressions, AlertContextMeta, ExpressionRow } from './expression'; +import { Expressions, AlertContextMeta, ExpressionRow, defaultExpression } from './expression'; import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; @@ -105,6 +105,7 @@ describe('Expression', () => { threshold: [], timeSize: 1, timeUnit: 'm', + customMetric: defaultExpression.customMetric, }, ]); }); @@ -155,6 +156,7 @@ describe('ExpressionRow', () => { alertsContextMetadata={{ customMetrics: [], }} + fields={[{ name: 'some.system.field', type: 'bzzz' }]} /> ); diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 66d547eb50d9c2..097e0f1f1690b4 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { set } from '@elastic/safer-lodash-set'; -import { debounce, pick, uniqBy, isEqual } from 'lodash'; +import { debounce, pick } from 'lodash'; import { Unit } from '@elastic/datemath'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; +import { IFieldType } from 'src/plugins/data/public'; import { EuiFlexGroup, EuiFlexItem, @@ -23,7 +23,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertPreview } from '../../common'; import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; @@ -95,13 +94,18 @@ interface Props { setAlertProperty(key: string, value: any): void; } -const defaultExpression = { +export const defaultExpression = { metric: 'cpu' as SnapshotMetricType, comparator: Comparator.GT, threshold: [], timeSize: 1, timeUnit: 'm', - customMetric: undefined, + customMetric: { + type: 'custom', + id: 'alert-custom-metric', + field: '', + aggregation: 'avg', + }, } as InventoryMetricConditions; export const Expressions: React.FC = (props) => { @@ -226,7 +230,7 @@ export const Expressions: React.FC = (props) => { metric: md.options.metric!.type, customMetric: SnapshotCustomMetricInputRT.is(md.options.metric) ? md.options.metric - : undefined, + : defaultExpression.customMetric, } as InventoryMetricConditions, ]); } else { @@ -306,6 +310,7 @@ export const Expressions: React.FC = (props) => { errors={errors[idx] || emptyError} expression={e || {}} alertsContextMetadata={alertsContext.metadata} + fields={derivedIndexPattern.fields} /> ); })} @@ -415,6 +420,7 @@ interface ExpressionRowProps { remove(id: number): void; setAlertParams(id: number, params: Partial): void; alertsContextMetadata: AlertsContextValue['metadata']; + fields: IFieldType[]; } const StyledExpressionRow = euiStyled(EuiFlexGroup)` @@ -428,48 +434,25 @@ const StyledExpression = euiStyled.div` `; export const ExpressionRow: React.FC = (props) => { - const { - setAlertParams, - expression, - errors, - expressionId, - remove, - canDelete, - alertsContextMetadata, - } = props; + const { setAlertParams, expression, errors, expressionId, remove, canDelete, fields } = props; const { metric, comparator = Comparator.GT, threshold = [], customMetric } = expression; - const [customMetrics, updateCustomMetrics] = useState([]); - - // Create and uniquify a list of custom metrics including: - // - The alert metadata context (which only gives us custom metrics on the inventory page) - // - The custom metric stored in the expression (necessary when editing this alert without having - // access to the metadata context) - // - Whatever custom metrics were previously stored in this list (to preserve the custom metric in the dropdown - // if the user edits the alert and switches away from the custom metric) - useEffect(() => { - const ctxCustomMetrics = alertsContextMetadata?.customMetrics ?? []; - const expressionCustomMetrics = customMetric ? [customMetric] : []; - const newCustomMetrics = uniqBy( - [...customMetrics, ...ctxCustomMetrics, ...expressionCustomMetrics], - (cm: SnapshotCustomMetricInput) => cm.id - ); - if (!isEqual(customMetrics, newCustomMetrics)) updateCustomMetrics(newCustomMetrics); - }, [alertsContextMetadata, customMetric, customMetrics, updateCustomMetrics]); const updateMetric = useCallback( (m?: SnapshotMetricType | string) => { - const newMetric = SnapshotMetricTypeRT.is(m) ? m : 'custom'; + const newMetric = SnapshotMetricTypeRT.is(m) ? m : Boolean(m) ? 'custom' : undefined; const newAlertParams = { ...expression, metric: newMetric }; - if (newMetric === 'custom' && customMetrics) { - set( - newAlertParams, - 'customMetric', - customMetrics.find((cm) => cm.id === m) - ); - } setAlertParams(expressionId, newAlertParams); }, - [expressionId, expression, setAlertParams, customMetrics] + [expressionId, expression, setAlertParams] + ); + + const updateCustomMetric = useCallback( + (cm?: SnapshotCustomMetricInput) => { + if (SnapshotCustomMetricInputRT.is(cm)) { + setAlertParams(expressionId, { ...expression, customMetric: cm }); + } + }, + [expressionId, expression, setAlertParams] ); const updateComparator = useCallback( @@ -515,17 +498,8 @@ export const ExpressionRow: React.FC = (props) => { myMetrics = containerMetricTypes; break; } - const baseMetricOpts = myMetrics.map(toMetricOpt); - const customMetricOpts = customMetrics - ? customMetrics.map((m, i) => ({ - text: getCustomMetricLabel(m), - value: m.id, - })) - : []; - return [...baseMetricOpts, ...customMetricOpts]; - }, [props.nodeType, customMetrics]); - - const selectedMetricValue = metric === 'custom' && customMetric ? customMetric.id : metric!; + return myMetrics.map(toMetricOpt); + }, [props.nodeType]); return ( <> @@ -535,8 +509,8 @@ export const ExpressionRow: React.FC = (props) => { v?.value === selectedMetricValue)?.text || '', + value: metric!, + text: ofFields.find((v) => v?.value === metric)?.text || '', }} metrics={ ofFields.filter((m) => m !== undefined && m.value !== undefined) as Array<{ @@ -545,7 +519,10 @@ export const ExpressionRow: React.FC = (props) => { }> } onChange={updateMetric} + onChangeCustom={updateCustomMetric} errors={errors} + customMetric={customMetric} + fields={fields} /> diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx index 5418eab3c5fc22..2dd2938dfd55af 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { debounce } from 'lodash'; import { EuiExpression, EuiPopover, @@ -14,16 +15,33 @@ import { EuiFlexItem, EuiFormRow, EuiComboBox, + EuiButtonGroup, + EuiSpacer, + EuiSelect, + EuiText, + EuiFieldText, } from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + SnapshotCustomMetricInput, + SnapshotCustomMetricInputRT, + SnapshotCustomAggregation, + SNAPSHOT_CUSTOM_AGGREGATIONS, + SnapshotCustomAggregationRT, +} from '../../../../common/http_api/snapshot_api'; interface Props { metric?: { value: string; text: string }; metrics: Array<{ value: string; text: string }>; errors: IErrorObject; onChange: (metric?: string) => void; + onChangeCustom: (customMetric?: SnapshotCustomMetricInput) => void; + customMetric?: SnapshotCustomMetricInput; + fields: IFieldType[]; popupPosition?: | 'upCenter' | 'upLeft' @@ -39,8 +57,40 @@ interface Props { | 'rightDown'; } -export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosition }: Props) => { - const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false); +const AGGREGATION_LABELS = { + ['avg']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.avg', { + defaultMessage: 'Average', + }), + ['max']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.max', { + defaultMessage: 'Max', + }), + ['min']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.min', { + defaultMessage: 'Min', + }), + ['rate']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.rate', { + defaultMessage: 'Rate', + }), +}; +const aggregationOptions = SNAPSHOT_CUSTOM_AGGREGATIONS.map((k) => ({ + text: AGGREGATION_LABELS[k as SnapshotCustomAggregation], + value: k, +})); + +export const MetricExpression = ({ + metric, + metrics, + customMetric, + fields, + errors, + onChange, + onChangeCustom, + popupPosition, +}: Props) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [customMetricTabOpen, setCustomMetricTabOpen] = useState(metric?.value === 'custom'); + const [selectedOption, setSelectedOption] = useState(metric?.value); + const [fieldDisplayedCustomLabel, setFieldDisplayedCustomLabel] = useState(customMetric?.label); + const firstFieldOption = { text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.metric.selectFieldLabel', { defaultMessage: 'Select a metric', @@ -48,13 +98,84 @@ export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosit value: '', }; + const fieldOptions = useMemo( + () => + fields + .filter((f) => f.aggregatable && f.type === 'number' && !(customMetric?.field === f.name)) + .map((f) => ({ label: f.name })), + [fields, customMetric?.field] + ); + + const expressionDisplayValue = useMemo( + () => { + return customMetricTabOpen + ? customMetric?.field && getCustomMetricLabel(customMetric) + : metric?.text || firstFieldOption.text; + }, + // The ?s are confusing eslint here, so... + // eslint-disable-next-line react-hooks/exhaustive-deps + [customMetricTabOpen, metric, customMetric, firstFieldOption] + ); + + const onChangeTab = useCallback( + (id) => { + if (id === 'metric-popover-custom') { + setCustomMetricTabOpen(true); + onChange('custom'); + } else { + setCustomMetricTabOpen(false); + onChange(selectedOption); + } + }, + [setCustomMetricTabOpen, onChange, selectedOption] + ); + + const onAggregationChange = useCallback( + (e) => { + const value = e.target.value; + const aggValue: SnapshotCustomAggregation = SnapshotCustomAggregationRT.is(value) + ? value + : 'avg'; + const newCustomMetric = { + ...customMetric, + aggregation: aggValue, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) onChangeCustom(newCustomMetric); + }, + [customMetric, onChangeCustom] + ); + + const onFieldChange = useCallback( + (selectedOptions: Array<{ label: string }>) => { + const newCustomMetric = { + ...customMetric, + field: selectedOptions[0].label, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) onChangeCustom(newCustomMetric); + }, + [customMetric, onChangeCustom] + ); + + const debouncedOnChangeCustom = debounce(onChangeCustom, 500); + const onLabelChange = useCallback( + (e) => { + setFieldDisplayedCustomLabel(e.target.value); + const newCustomMetric = { + ...customMetric, + label: e.target.value, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) debouncedOnChangeCustom(newCustomMetric); + }, + [customMetric, debouncedOnChangeCustom] + ); + const availablefieldsOptions = metrics.map((m) => { return { label: m.text, value: m.value }; }, []); return ( 0))} + value={expressionDisplayValue} + isActive={Boolean(popoverOpen || (errors.metric && errors.metric.length > 0))} onClick={() => { - setAggFieldPopoverOpen(true); + setPopoverOpen(true); }} color={errors.metric?.length ? 'danger' : 'secondary'} /> } - isOpen={aggFieldPopoverOpen} + isOpen={popoverOpen} closePopover={() => { - setAggFieldPopoverOpen(false); + setPopoverOpen(false); }} anchorPosition={popupPosition ?? 'downRight'} zIndex={8000} > -
- setAggFieldPopoverOpen(false)}> +
+ setPopoverOpen(false)}> - - - 0} error={errors.metric}> - + + {customMetricTabOpen ? ( + <> + + + + + + + + + {i18n.translate('xpack.infra.waffle.customMetrics.of', { + defaultMessage: 'of', + })} + + + + + 0} + /> + + + + of ".', + })} + > + 0} - placeholder={firstFieldOption.text} - options={availablefieldsOptions} - noSuggestions={!availablefieldsOptions.length} - selectedOptions={ - metric ? availablefieldsOptions.filter((a) => a.value === metric.value) : [] - } - renderOption={(o: any) => o.label} - onChange={(selectedOptions) => { - if (selectedOptions.length > 0) { - onChange(selectedOptions[0].value); - setAggFieldPopoverOpen(false); - } else { - onChange(); - } - }} + onChange={onLabelChange} /> - - + + ) : ( + + + + 0} + placeholder={firstFieldOption.text} + options={availablefieldsOptions} + noSuggestions={!availablefieldsOptions.length} + selectedOptions={ + metric ? availablefieldsOptions.filter((a) => a.value === metric.value) : [] + } + renderOption={(o: any) => o.label} + onChange={(selectedOptions) => { + if (selectedOptions.length > 0) { + onChange(selectedOptions[0].value); + setSelectedOption(selectedOptions[0].value); + } else { + onChange(); + } + }} + /> + + + + )}
); diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx index 47ecd3c527fadd..4b522d7d97730b 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx @@ -6,14 +6,14 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { MetricExpressionParams } from '../../../../server/lib/alerting/metric_threshold/types'; +import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; export function validateMetricThreshold({ criteria, }: { - criteria: MetricExpressionParams[]; + criteria: InventoryMetricConditions[]; }): ValidationResult { const validationResult = { errors: {} }; const errors: { @@ -81,14 +81,20 @@ export function validateMetricThreshold({ }) ); } - - if (!c.metric && c.aggType !== 'count') { + if (!c.metric) { errors[id].metric.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.metricRequired', { defaultMessage: 'Metric is required.', }) ); } + if (c.metric === 'custom' && !c.customMetric?.field) { + errors[id].metric.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.customMetricFieldRequired', { + defaultMessage: 'Field is required.', + }) + ); + } }); return validationResult; From 9bff56df7d28b7471830be798262d30579e537a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Thu, 5 Nov 2020 19:37:21 +0100 Subject: [PATCH 02/20] [Security Solution] Fix Overview cypress tests (#82761) --- .../security_solution/cypress/integration/overview.spec.ts | 4 ++-- .../plugins/security_solution/cypress/support/commands.js | 3 +-- x-pack/plugins/security_solution/cypress/support/index.d.ts | 6 +----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 9e46a537030412..69094cad7456e9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -13,7 +13,7 @@ import { OVERVIEW_URL } from '../urls/navigation'; describe('Overview Page', () => { it('Host stats render with correct values', () => { - cy.stubSearchStrategyApi('overviewHostQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); expandHostStats(); @@ -23,7 +23,7 @@ describe('Overview Page', () => { }); it('Network stats render with correct values', () => { - cy.stubSearchStrategyApi('overviewNetworkQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); expandNetworkStats(); diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index dbd60cdd31a5a6..e13a76736205c7 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -40,7 +40,6 @@ Cypress.Commands.add('stubSecurityApi', function (dataFileName) { }); Cypress.Commands.add('stubSearchStrategyApi', function ( - queryId, dataFileName, searchStrategyName = 'securitySolutionSearchStrategy' ) { @@ -49,7 +48,7 @@ Cypress.Commands.add('stubSearchStrategyApi', function ( }); cy.server(); cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', `internal/search/${searchStrategyName}/${queryId}`, `@${dataFileName}JSON`); + cy.route('POST', `internal/search/${searchStrategyName}`, `@${dataFileName}JSON`); }); Cypress.Commands.add( diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index 0cf3cf614cdb9e..fb55a2890c8b7f 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -8,11 +8,7 @@ declare namespace Cypress { interface Chainable { promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; - stubSearchStrategyApi( - queryId: string, - dataFileName: string, - searchStrategyName?: string - ): Chainable; + stubSearchStrategyApi(dataFileName: string, searchStrategyName?: string): Chainable; attachFile(fileName: string, fileType?: string): Chainable; waitUntil( fn: (subject: Subject) => boolean | Chainable, From 62443a6a0574343f0ee7fd448328369e748aee32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 5 Nov 2020 16:29:27 -0300 Subject: [PATCH 03/20] [APM] Filtering by "Type" on error overview sometimes causes an error --- .../__test__/__snapshots__/List.test.tsx.snap | 24 +++++++++---------- .../app/ErrorGroupOverview/List/index.tsx | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index b5a558621e9ca9..1f34a0cef1ccf5 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -826,7 +826,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -838,12 +838,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1065,7 +1065,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1077,12 +1077,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1304,7 +1304,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1316,12 +1316,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1543,7 +1543,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1555,12 +1555,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx index 33105189f9c3e4..e1f6239112555e 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx @@ -107,7 +107,7 @@ function ErrorGroupList({ items, serviceName }: Props) { query={ { ...urlParams, - kuery: `error.exception.type:${type}`, + kuery: `error.exception.type:"${type}"`, } as APMQueryParams } > From eeebe580e31f08b04953bf13d95c7f35945a37f5 Mon Sep 17 00:00:00 2001 From: Stuart Tettemer Date: Thu, 5 Nov 2020 13:30:15 -0600 Subject: [PATCH 04/20] Docs: Remove references to Goovy, JS and Py scripted fields (#82662) --- docs/management/managing-fields.asciidoc | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/management/managing-fields.asciidoc b/docs/management/managing-fields.asciidoc index 3734655edd91b3..e69b147615669b 100644 --- a/docs/management/managing-fields.asciidoc +++ b/docs/management/managing-fields.asciidoc @@ -121,12 +121,8 @@ WARNING: Computing data on the fly with scripted fields can be very resource int {kib} performance. Keep in mind that there's no built-in validation of a scripted field. If your scripts are buggy, you'll get exceptions whenever you try to view the dynamically generated data. -When you define a scripted field in {kib}, you have a choice of scripting languages. In 5.0 and later, the default -options are {ref}/modules-scripting-expression.html[Lucene expressions] and {ref}/modules-scripting-painless.html[Painless]. -While you can use other scripting languages if you enable dynamic scripting for them in {es}, this is not recommended -because they cannot be sufficiently {ref}/modules-scripting-security.html[sandboxed]. - -WARNING: In 5.0 and later, Groovy, JavaScript, and Python scripting are deprecated and unsupported. +When you define a scripted field in {kib}, you have a choice of the {ref}/modules-scripting-expression.html[Lucene expressions] or the +{ref}/modules-scripting-painless.html[Painless] scripting language. You can reference any single value numeric field in your expressions, for example: From c584376ef75e7e138719d1a108bb05ae09753310 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Thu, 5 Nov 2020 14:58:31 -0500 Subject: [PATCH 05/20] Skip failing suite (#81848) --- .../security_solution/cypress/integration/overview.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 69094cad7456e9..dafcabb8e1e8df 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -11,7 +11,8 @@ import { loginAndWaitForPage } from '../tasks/login'; import { OVERVIEW_URL } from '../urls/navigation'; -describe('Overview Page', () => { +// Failing: See https://github.com/elastic/kibana/issues/81848 +describe.skip('Overview Page', () => { it('Host stats render with correct values', () => { cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); From 64371392b0d795f63736f5cfbb4557a369a026af Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Thu, 5 Nov 2020 15:06:31 -0500 Subject: [PATCH 06/20] [Fleet] Remove asterix from test file name (#82721) * Revert "Revert "[Fleet] Allow snake cased Kibana assets (#77515)" (#82706)" This reverts commit bc05e79b850615ae42cd9eb9e542a8d85c845799. * Rename test index pattern --- .../package_to_package_policy.test.ts | 2 +- .../ingest_manager/common/types/models/epm.ts | 16 ++- .../ingest_manager/sections/epm/constants.tsx | 4 +- .../server/routes/data_streams/handlers.ts | 4 +- .../services/epm/kibana/assets/install.ts | 114 +++++++++++++++--- .../epm/kibana/index_pattern/install.ts | 2 +- .../ensure_installed_default_packages.test.ts | 4 +- .../epm/packages/get_install_type.test.ts | 6 +- .../server/services/epm/packages/install.ts | 5 +- .../server/services/epm/packages/remove.ts | 42 +++++-- .../server/services/epm/registry/index.ts | 4 +- .../ingest_manager/server/types/index.tsx | 1 + .../apis/epm/install_remove_assets.ts | 33 +++++ .../apis/epm/update_assets.ts | 8 +- .../0.1.0/kibana/index_pattern/invalid.json | 11 ++ .../index_pattern/test_index_pattern.json | 11 ++ 16 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts index 8927b5ab3ca4b8..91396bce359b04 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts @@ -25,7 +25,7 @@ describe('Ingest Manager - packageToPackagePolicy', () => { dashboard: [], visualization: [], search: [], - 'index-pattern': [], + index_pattern: [], map: [], }, }, diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index a32322ecff62a9..c5fc208bfb2dc8 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -35,7 +35,21 @@ export type ServiceName = 'kibana' | 'elasticsearch'; export type AgentAssetType = typeof agentAssetTypes; export type AssetType = KibanaAssetType | ElasticsearchAssetType | ValueOf; +/* + Enum mapping of a saved object asset type to how it would appear in a package file path (snake cased) +*/ export enum KibanaAssetType { + dashboard = 'dashboard', + visualization = 'visualization', + search = 'search', + indexPattern = 'index_pattern', + map = 'map', +} + +/* + Enum of saved object types that are allowed to be installed +*/ +export enum KibanaSavedObjectType { dashboard = 'dashboard', visualization = 'visualization', search = 'search', @@ -271,7 +285,7 @@ export type NotInstalled = T & { export type AssetReference = KibanaAssetReference | EsAssetReference; export type KibanaAssetReference = Pick & { - type: KibanaAssetType; + type: KibanaSavedObjectType; }; export type EsAssetReference = Pick & { type: ElasticsearchAssetType; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx index da3cab1a4b8a3d..1dad25e9cf0595 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx @@ -20,7 +20,7 @@ export const AssetTitleMap: Record = { ilm_policy: 'ILM Policy', ingest_pipeline: 'Ingest Pipeline', transform: 'Transform', - 'index-pattern': 'Index Pattern', + index_pattern: 'Index Pattern', index_template: 'Index Template', component_template: 'Component Template', search: 'Saved Search', @@ -36,7 +36,7 @@ export const ServiceTitleMap: Record = { export const AssetIcons: Record = { dashboard: 'dashboardApp', - 'index-pattern': 'indexPatternApp', + index_pattern: 'indexPatternApp', search: 'searchProfilerApp', visualization: 'visualizeApp', map: 'mapApp', diff --git a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts index 652a7789f65a30..f42f5da2695d06 100644 --- a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts @@ -5,7 +5,7 @@ */ import { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; import { DataStream } from '../../types'; -import { GetDataStreamsResponse, KibanaAssetType } from '../../../common'; +import { GetDataStreamsResponse, KibanaAssetType, KibanaSavedObjectType } from '../../../common'; import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; @@ -124,7 +124,7 @@ export const getListHandler: RequestHandler = async (context, request, response) // then pick the dashboards from the package saved object const dashboards = pkgSavedObject[0].attributes?.installed_kibana?.filter( - (o) => o.type === KibanaAssetType.dashboard + (o) => o.type === KibanaSavedObjectType.dashboard ) || []; // and then pick the human-readable titles from the dashboard saved objects const enhancedDashboards = await getEnhancedDashboards( diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts index 201003629e5ea3..e7b251ef133c5b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts @@ -11,17 +11,49 @@ import { } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; import * as Registry from '../../registry'; -import { AssetType, KibanaAssetType, AssetReference } from '../../../../types'; +import { + AssetType, + KibanaAssetType, + AssetReference, + AssetParts, + KibanaSavedObjectType, +} from '../../../../types'; import { savedObjectTypes } from '../../packages'; +import { indexPatternTypes } from '../index_pattern/install'; type SavedObjectToBe = Required> & { - type: AssetType; + type: KibanaSavedObjectType; }; export type ArchiveAsset = Pick< SavedObject, 'id' | 'attributes' | 'migrationVersion' | 'references' > & { - type: AssetType; + type: KibanaSavedObjectType; +}; + +// KibanaSavedObjectTypes are used to ensure saved objects being created for a given +// KibanaAssetType have the correct type +const KibanaSavedObjectTypeMapping: Record = { + [KibanaAssetType.dashboard]: KibanaSavedObjectType.dashboard, + [KibanaAssetType.indexPattern]: KibanaSavedObjectType.indexPattern, + [KibanaAssetType.map]: KibanaSavedObjectType.map, + [KibanaAssetType.search]: KibanaSavedObjectType.search, + [KibanaAssetType.visualization]: KibanaSavedObjectType.visualization, +}; + +// Define how each asset type will be installed +const AssetInstallers: Record< + KibanaAssetType, + (args: { + savedObjectsClient: SavedObjectsClientContract; + kibanaAssets: ArchiveAsset[]; + }) => Promise>> +> = { + [KibanaAssetType.dashboard]: installKibanaSavedObjects, + [KibanaAssetType.indexPattern]: installKibanaIndexPatterns, + [KibanaAssetType.map]: installKibanaSavedObjects, + [KibanaAssetType.search]: installKibanaSavedObjects, + [KibanaAssetType.visualization]: installKibanaSavedObjects, }; export async function getKibanaAsset(key: string): Promise { @@ -47,16 +79,22 @@ export function createSavedObjectKibanaAsset(asset: ArchiveAsset): SavedObjectTo export async function installKibanaAssets(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; - kibanaAssets: ArchiveAsset[]; + kibanaAssets: Record; }): Promise { const { savedObjectsClient, kibanaAssets } = options; // install the assets const kibanaAssetTypes = Object.values(KibanaAssetType); const installedAssets = await Promise.all( - kibanaAssetTypes.map((assetType) => - installKibanaSavedObjects({ savedObjectsClient, assetType, kibanaAssets }) - ) + kibanaAssetTypes.map((assetType) => { + if (kibanaAssets[assetType]) { + return AssetInstallers[assetType]({ + savedObjectsClient, + kibanaAssets: kibanaAssets[assetType], + }); + } + return []; + }) ); return installedAssets.flat(); } @@ -74,25 +112,50 @@ export const deleteKibanaInstalledRefs = async ( installed_kibana: installedAssetsToSave, }); }; -export async function getKibanaAssets(paths: string[]) { - const isKibanaAssetType = (path: string) => Registry.pathParts(path).type in KibanaAssetType; - const filteredPaths = paths.filter(isKibanaAssetType); - const kibanaAssets = await Promise.all(filteredPaths.map((path) => getKibanaAsset(path))); - return kibanaAssets; +export async function getKibanaAssets( + paths: string[] +): Promise> { + const kibanaAssetTypes = Object.values(KibanaAssetType); + const isKibanaAssetType = (path: string) => { + const parts = Registry.pathParts(path); + + return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); + }; + + const filteredPaths = paths + .filter(isKibanaAssetType) + .map<[string, AssetParts]>((path) => [path, Registry.pathParts(path)]); + + const assetArrays: Array> = []; + for (const assetType of kibanaAssetTypes) { + const matching = filteredPaths.filter(([path, parts]) => parts.type === assetType); + + assetArrays.push(Promise.all(matching.map(([path]) => path).map(getKibanaAsset))); + } + + const resolvedAssets = await Promise.all(assetArrays); + + const result = {} as Record; + + for (const [index, assetType] of kibanaAssetTypes.entries()) { + const expectedType = KibanaSavedObjectTypeMapping[assetType]; + const properlyTypedAssets = resolvedAssets[index].filter(({ type }) => type === expectedType); + + result[assetType] = properlyTypedAssets; + } + + return result; } + async function installKibanaSavedObjects({ savedObjectsClient, - assetType, kibanaAssets, }: { savedObjectsClient: SavedObjectsClientContract; - assetType: KibanaAssetType; kibanaAssets: ArchiveAsset[]; }) { - const isSameType = (asset: ArchiveAsset) => assetType === asset.type; - const filteredKibanaAssets = kibanaAssets.filter((asset) => isSameType(asset)); const toBeSavedObjects = await Promise.all( - filteredKibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset)) + kibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset)) ); if (toBeSavedObjects.length === 0) { @@ -105,8 +168,23 @@ async function installKibanaSavedObjects({ } } +async function installKibanaIndexPatterns({ + savedObjectsClient, + kibanaAssets, +}: { + savedObjectsClient: SavedObjectsClientContract; + kibanaAssets: ArchiveAsset[]; +}) { + // Filter out any reserved index patterns + const reservedPatterns = indexPatternTypes.map((pattern) => `${pattern}-*`); + + const nonReservedPatterns = kibanaAssets.filter((asset) => !reservedPatterns.includes(asset.id)); + + return installKibanaSavedObjects({ savedObjectsClient, kibanaAssets: nonReservedPatterns }); +} + export function toAssetReference({ id, type }: SavedObject) { - const reference: AssetReference = { id, type: type as KibanaAssetType }; + const reference: AssetReference = { id, type: type as KibanaSavedObjectType }; return reference; } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index 4ca8e9d52c337e..d18f43d62436a4 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -72,6 +72,7 @@ export interface IndexPatternField { readFromDocValues: boolean; } +export const indexPatternTypes = Object.values(dataTypes); // TODO: use a function overload and make pkgName and pkgVersion required for install/update // and not for an update removal. or separate out the functions export async function installIndexPatterns( @@ -116,7 +117,6 @@ export async function installIndexPatterns( const packageVersionsInfo = await Promise.all(packageVersionsFetchInfoPromise); // for each index pattern type, create an index pattern - const indexPatternTypes = Object.values(dataTypes); indexPatternTypes.forEach(async (indexPatternType) => { // if this is an update because a package is being uninstalled (no pkgkey argument passed) and no other packages are installed, remove the index pattern if (!pkgName && installedPackages.length === 0) { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts index aaff5df39bac31..4ad6fc96218dea 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchAssetType, Installation, KibanaAssetType } from '../../../types'; +import { ElasticsearchAssetType, Installation, KibanaSavedObjectType } from '../../../types'; import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; jest.mock('./install'); @@ -41,7 +41,7 @@ const mockInstallation: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test package', diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts index a04bfaafe7570b..a41511260c6e72 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObject } from 'src/core/server'; -import { ElasticsearchAssetType, Installation, KibanaAssetType } from '../../../types'; +import { ElasticsearchAssetType, Installation, KibanaSavedObjectType } from '../../../types'; import { getInstallType } from './install'; const mockInstallation: SavedObject = { @@ -13,7 +13,7 @@ const mockInstallation: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', @@ -30,7 +30,7 @@ const mockInstallationUpdateFail: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 23666162e91ef4..0496a6e9aeef1e 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -18,6 +18,7 @@ import { KibanaAssetReference, EsAssetReference, InstallType, + KibanaAssetType, } from '../../../types'; import * as Registry from '../registry'; import { @@ -364,9 +365,9 @@ export async function createInstallation(options: { export const saveKibanaAssetsRefs = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, - kibanaAssets: ArchiveAsset[] + kibanaAssets: Record ) => { - const assetRefs = kibanaAssets.map(toAssetReference); + const assetRefs = Object.values(kibanaAssets).flat().map(toAssetReference); await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { installed_kibana: assetRefs, }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index 4b4fe9540dd956..5db47adc983c2a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -12,6 +12,9 @@ import { AssetType, CallESAsCurrentUser, ElasticsearchAssetType, + EsAssetReference, + KibanaAssetReference, + Installation, } from '../../../types'; import { getInstallation, savedObjectTypes } from './index'; import { deletePipeline } from '../elasticsearch/ingest_pipeline/'; @@ -46,7 +49,7 @@ export async function removeInstallation(options: { // Delete the installed assets const installedAssets = [...installation.installed_kibana, ...installation.installed_es]; - await deleteAssets(installedAssets, savedObjectsClient, callCluster); + await deleteAssets(installation, savedObjectsClient, callCluster); // Delete the manager saved object with references to the asset objects // could also update with [] or some other state @@ -64,17 +67,20 @@ export async function removeInstallation(options: { // successful delete's in SO client return {}. return something more useful return installedAssets; } -async function deleteAssets( - installedObjects: AssetReference[], - savedObjectsClient: SavedObjectsClientContract, - callCluster: CallESAsCurrentUser + +function deleteKibanaAssets( + installedObjects: KibanaAssetReference[], + savedObjectsClient: SavedObjectsClientContract ) { - const logger = appContextService.getLogger(); - const deletePromises = installedObjects.map(async ({ id, type }) => { + return installedObjects.map(async ({ id, type }) => { + return savedObjectsClient.delete(type, id); + }); +} + +function deleteESAssets(installedObjects: EsAssetReference[], callCluster: CallESAsCurrentUser) { + return installedObjects.map(async ({ id, type }) => { const assetType = type as AssetType; - if (savedObjectTypes.includes(assetType)) { - return savedObjectsClient.delete(assetType, id); - } else if (assetType === ElasticsearchAssetType.ingestPipeline) { + if (assetType === ElasticsearchAssetType.ingestPipeline) { return deletePipeline(callCluster, id); } else if (assetType === ElasticsearchAssetType.indexTemplate) { return deleteTemplate(callCluster, id); @@ -82,8 +88,22 @@ async function deleteAssets( return deleteTransforms(callCluster, [id]); } }); +} + +async function deleteAssets( + { installed_es: installedEs, installed_kibana: installedKibana }: Installation, + savedObjectsClient: SavedObjectsClientContract, + callCluster: CallESAsCurrentUser +) { + const logger = appContextService.getLogger(); + + const deletePromises: Array> = [ + ...deleteESAssets(installedEs, callCluster), + ...deleteKibanaAssets(installedKibana, savedObjectsClient), + ]; + try { - await Promise.all([...deletePromises]); + await Promise.all(deletePromises); } catch (err) { logger.error(err); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index 66f28fe58599a5..0172f3bb38f510 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -242,10 +242,12 @@ export function getAsset(key: string) { } export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType { + const kibanaAssetTypes = Object.values(KibanaAssetType); + // ASK: best way, if any, to avoid `any`? const assets = paths.reduce((map: any, path) => { const parts = pathParts(path.replace(/^\/package\//, '')); - if (parts.type in KibanaAssetType) { + if (parts.service === 'kibana' && kibanaAssetTypes.includes(parts.type)) { if (!map[parts.service]) map[parts.service] = {}; if (!map[parts.service][parts.type]) map[parts.service][parts.type] = []; map[parts.service][parts.type].push(parts); diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index 3518daa1aba631..5cf43d2830489a 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -56,6 +56,7 @@ export { AssetType, Installable, KibanaAssetType, + KibanaSavedObjectType, AssetParts, AssetsGroupedByServiceByType, CategoryId, diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts index 72ea9cb4e7ef35..8e8e4f010bcb55 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts @@ -184,6 +184,16 @@ export default function (providerContext: FtrProviderContext) { resSearch = err; } expect(resSearch.response.data.statusCode).equal(404); + let resIndexPattern; + try { + resIndexPattern = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'test-*', + }); + } catch (err) { + resIndexPattern = err; + } + expect(resIndexPattern.response.data.statusCode).equal(404); }); it('should have removed the fields from the index patterns', async () => { // The reason there is an expect inside the try and inside the catch in this test case is to guard against two @@ -345,6 +355,7 @@ const expectAssetsInstalled = ({ expect(res.statusCode).equal(200); }); it('should have installed the kibana assets', async function () { + // These are installed from Fleet along with every package const resIndexPatternLogs = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'logs-*', @@ -355,6 +366,8 @@ const expectAssetsInstalled = ({ id: 'metrics-*', }); expect(resIndexPatternMetrics.id).equal('metrics-*'); + + // These are the assets from the package const resDashboard = await kibanaServer.savedObjects.get({ type: 'dashboard', id: 'sample_dashboard', @@ -375,6 +388,22 @@ const expectAssetsInstalled = ({ id: 'sample_search', }); expect(resSearch.id).equal('sample_search'); + const resIndexPattern = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'test-*', + }); + expect(resIndexPattern.id).equal('test-*'); + + let resInvalidTypeIndexPattern; + try { + resInvalidTypeIndexPattern = await kibanaServer.savedObjects.get({ + type: 'invalid-type', + id: 'invalid', + }); + } catch (err) { + resInvalidTypeIndexPattern = err; + } + expect(resInvalidTypeIndexPattern.response.data.statusCode).equal(404); }); it('should create an index pattern with the package fields', async () => { const resIndexPatternLogs = await kibanaServer.savedObjects.get({ @@ -415,6 +444,10 @@ const expectAssetsInstalled = ({ id: 'sample_dashboard2', type: 'dashboard', }, + { + id: 'test-*', + type: 'index-pattern', + }, { id: 'sample_search', type: 'search', diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts index 90dce92a2c6b56..b16cf039f0dadb 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts @@ -283,14 +283,14 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_dashboard', type: 'dashboard', }, - { - id: 'sample_search2', - type: 'search', - }, { id: 'sample_visualization', type: 'visualization', }, + { + id: 'sample_search2', + type: 'search', + }, ], installed_es: [ { diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json new file mode 100644 index 00000000000000..bffc52ded73d65 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "fieldFormatMap": "{}", + "fields": "[]", + "timeFieldName": "@timestamp", + "title": "invalid" + }, + "id": "invalid", + "references": [], + "type": "invalid-type" +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json new file mode 100644 index 00000000000000..48ba36a1167093 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "fieldFormatMap": "{}", + "fields": "[]", + "timeFieldName": "@timestamp", + "title": "test-*" + }, + "id": "test-*", + "references": [], + "type": "index-pattern" +} From 4a8f42603b42b731ef43e0e89eea0a33bb6aa7f8 Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 5 Nov 2020 12:18:06 -0800 Subject: [PATCH 07/20] [Enterprise Search] Fix/update MockRouter helper to return specific routes/paths (#82682) * Fix tests failing for route files that have more than 2 router registrations of the same method - This fix allows us to specify the route call we're testing via a path param * Update all existing uses of MockRouter to pass path param * Add helpful error messaging - e.g., in case a path gets typoed --- .../server/__mocks__/router.mock.ts | 18 +++++- .../routes/app_search/credentials.test.ts | 30 +++++++-- .../server/routes/app_search/engines.test.ts | 6 +- .../server/routes/app_search/settings.test.ts | 11 +++- .../enterprise_search/config_data.test.ts | 5 +- .../enterprise_search/telemetry.test.ts | 6 +- .../routes/workplace_search/groups.test.ts | 62 +++++++++++++++---- .../routes/workplace_search/overview.test.ts | 6 +- 8 files changed, 119 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts index e3471d7268cb17..f00e0f2807e8d3 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts @@ -21,6 +21,7 @@ type PayloadType = 'params' | 'query' | 'body'; interface IMockRouterProps { method: MethodType; + path: string; payload?: PayloadType; } interface IMockRouterRequest { @@ -33,12 +34,14 @@ type TMockRouterRequest = KibanaRequest | IMockRouterRequest; export class MockRouter { public router!: jest.Mocked; public method: MethodType; + public path: string; public payload?: PayloadType; public response = httpServerMock.createResponseFactory(); - constructor({ method, payload }: IMockRouterProps) { + constructor({ method, path, payload }: IMockRouterProps) { this.createRouter(); this.method = method; + this.path = path; this.payload = payload; } @@ -47,8 +50,13 @@ export class MockRouter { }; public callRoute = async (request: TMockRouterRequest) => { - const [, handler] = this.router[this.method].mock.calls[0]; + const routerCalls = this.router[this.method].mock.calls as any[]; + if (!routerCalls.length) throw new Error('No routes registered.'); + const route = routerCalls.find(([router]: any) => router.path === this.path); + if (!route) throw new Error('No matching registered routes found - check method/path keys'); + + const [, handler] = route; const context = {} as jest.Mocked; await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); }; @@ -81,7 +89,11 @@ export class MockRouter { /** * Example usage: */ -// const mockRouter = new MockRouter({ method: 'get', payload: 'body' }); +// const mockRouter = new MockRouter({ +// method: 'get', +// path: '/api/app_search/test', +// payload: 'body' +// }); // // beforeEach(() => { // jest.clearAllMocks(); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts index 357b49de934122..af498e346529a9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts @@ -14,7 +14,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/credentials', + payload: 'query', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -46,7 +50,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/credentials', + payload: 'body', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -155,7 +163,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/credentials/details', + payload: 'query', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -175,7 +187,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/credentials/{name}', + payload: 'body', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -292,7 +308,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'delete', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/app_search/credentials/{name}', + payload: 'params', + }); registerCredentialsRoutes({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index cd22ff98b01cee..3bfe8abf8a2dff 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -25,7 +25,11 @@ describe('engine routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines', + payload: 'query', + }); registerEnginesRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts index 095c0ac2b6ab14..be3b2632eb67d9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts @@ -14,7 +14,10 @@ describe('log settings routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/log_settings', + }); registerSettingsRoutes({ ...mockDependencies, @@ -36,7 +39,11 @@ describe('log settings routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/log_settings', + payload: 'body', + }); registerSettingsRoutes({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts index 253c9a418d60b6..b6f449ced2599f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts @@ -18,7 +18,10 @@ describe('Enterprise Search Config Data API', () => { let mockRouter: MockRouter; beforeEach(() => { - mockRouter = new MockRouter({ method: 'get' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/enterprise_search/config_data', + }); registerConfigDataRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index bd6f4b9da91fd6..2229860d87a000 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -25,7 +25,11 @@ describe('Enterprise Search Telemetry API', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/enterprise_search/stats', + payload: 'body', + }); registerTelemetryRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts index 31e055565ead12..2f244022be0378 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts @@ -25,7 +25,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups', + payload: 'query', + }); registerGroupsRoute({ ...mockDependencies, @@ -43,7 +47,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups', + payload: 'body', + }); registerGroupsRoute({ ...mockDependencies, @@ -71,7 +79,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/search', + payload: 'body', + }); registerSearchGroupsRoute({ ...mockDependencies, @@ -141,7 +153,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups/{id}', + payload: 'params', + }); registerGroupRoute({ ...mockDependencies, @@ -176,7 +192,11 @@ describe('groups routes', () => { }; it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/groups/{id}', + payload: 'body', + }); registerGroupRoute({ ...mockDependencies, @@ -204,7 +224,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'delete', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/workplace_search/groups/{id}', + payload: 'params', + }); registerGroupRoute({ ...mockDependencies, @@ -227,7 +251,7 @@ describe('groups routes', () => { }); }); - describe('GET /api/workplace_search/groups/{id}/users', () => { + describe('GET /api/workplace_search/groups/{id}/group_users', () => { let mockRouter: MockRouter; beforeEach(() => { @@ -235,7 +259,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups/{id}/group_users', + payload: 'params', + }); registerGroupUsersRoute({ ...mockDependencies, @@ -261,7 +289,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/{id}/share', + payload: 'body', + }); registerShareGroupRoute({ ...mockDependencies, @@ -291,7 +323,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/{id}/assign', + payload: 'body', + }); registerAssignGroupRoute({ ...mockDependencies, @@ -330,7 +366,11 @@ describe('groups routes', () => { }; it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/groups/{id}/boosts', + payload: 'body', + }); registerBoostsGroupRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index a387cab31c17ac..9317b1ada85afa 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -14,7 +14,11 @@ describe('Overview route', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/overview', + payload: 'query', + }); registerOverviewRoute({ ...mockDependencies, From ca04175ae9cb8305a153612e85b4403bf9d95a8c Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 5 Nov 2020 15:29:33 -0500 Subject: [PATCH 08/20] Combine related getBuffer* functions. Add tests (#82766) ## Summary Move logic from `getBufferExtractorForContentType` into `getBufferExtractor` & change the interface so one function can be used. ### Diff showing old vs new call ```diff - getBufferExtractorForContentType(contentType); + getBufferExtractor({ contentType }); ``` ```diff - getBufferExtractor(archivePath); + getBufferExtractor({ archivePath }); ``` ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../server/services/epm/archive/index.ts | 21 ++++-------- .../server/services/epm/registry/extract.ts | 24 +++++++++++-- .../services/epm/registry/index.test.ts | 34 ++++++++++++++++--- .../server/services/epm/registry/index.ts | 16 ++++----- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts index 91ed489b3a5bbe..395f9c15b3b878 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts @@ -18,7 +18,7 @@ import { import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; import { pkgToPkgKey } from '../registry'; import { cacheGet, cacheSet, setArchiveFilelist } from '../registry/cache'; -import { unzipBuffer, untarBuffer, ArchiveEntry } from '../registry/extract'; +import { ArchiveEntry, getBufferExtractor } from '../registry/extract'; export async function loadArchivePackage({ archiveBuffer, @@ -37,24 +37,17 @@ export async function loadArchivePackage({ }; } -function getBufferExtractorForContentType(contentType: string) { - if (contentType === 'application/gzip') { - return untarBuffer; - } else if (contentType === 'application/zip') { - return unzipBuffer; - } else { - throw new PackageUnsupportedMediaTypeError( - `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` - ); - } -} - export async function unpackArchiveToCache( archiveBuffer: Buffer, contentType: string, filter = (entry: ArchiveEntry): boolean => true ): Promise { - const bufferExtractor = getBufferExtractorForContentType(contentType); + const bufferExtractor = getBufferExtractor({ contentType }); + if (!bufferExtractor) { + throw new PackageUnsupportedMediaTypeError( + `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` + ); + } const paths: string[] = []; try { await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts index 6d029b54a63171..b79218638ce247 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts @@ -17,7 +17,7 @@ export async function untarBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, onEntry = (entry: ArchiveEntry): void => {} -): Promise { +): Promise { const deflatedStream = bufferToStream(buffer); // use tar.list vs .extract to avoid writing to disk const inflateStream = tar.list().on('entry', (entry: tar.FileStat) => { @@ -36,7 +36,7 @@ export async function unzipBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, onEntry = (entry: ArchiveEntry): void => {} -): Promise { +): Promise { const zipfile = await yauzlFromBuffer(buffer, { lazyEntries: true }); zipfile.readEntry(); zipfile.on('entry', async (entry: yauzl.Entry) => { @@ -50,6 +50,26 @@ export async function unzipBuffer( return new Promise((resolve, reject) => zipfile.on('end', resolve).on('error', reject)); } +type BufferExtractor = typeof unzipBuffer | typeof untarBuffer; +export function getBufferExtractor( + args: { contentType: string } | { archivePath: string } +): BufferExtractor | undefined { + if ('contentType' in args) { + if (args.contentType === 'application/gzip') { + return untarBuffer; + } else if (args.contentType === 'application/zip') { + return unzipBuffer; + } + } else if ('archivePath' in args) { + if (args.archivePath.endsWith('.zip')) { + return unzipBuffer; + } + if (args.archivePath.endsWith('.gz')) { + return untarBuffer; + } + } +} + function yauzlFromBuffer(buffer: Buffer, opts: yauzl.Options): Promise { return new Promise((resolve, reject) => yauzl.fromBuffer(buffer, opts, (err?: Error, handle?: yauzl.ZipFile) => diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts index ba51636c13f369..a2d5c8147002de 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts @@ -82,14 +82,38 @@ describe('splitPkgKey tests', () => { }); }); -describe('getBufferExtractor', () => { - it('returns unzipBuffer if the archive key ends in .zip', () => { - const extractor = getBufferExtractor('.zip'); +describe('getBufferExtractor called with { archivePath }', () => { + it('returns unzipBuffer if `archivePath` ends in .zip', () => { + const extractor = getBufferExtractor({ archivePath: '.zip' }); expect(extractor).toBe(unzipBuffer); }); - it('returns untarBuffer if the key ends in anything else', () => { - const extractor = getBufferExtractor('.xyz'); + it('returns untarBuffer if `archivePath` ends in .gz', () => { + const extractor = getBufferExtractor({ archivePath: '.gz' }); expect(extractor).toBe(untarBuffer); + const extractor2 = getBufferExtractor({ archivePath: '.tar.gz' }); + expect(extractor2).toBe(untarBuffer); + }); + + it('returns `undefined` if `archivePath` ends in anything else', () => { + const extractor = getBufferExtractor({ archivePath: '.xyz' }); + expect(extractor).toEqual(undefined); + }); +}); + +describe('getBufferExtractor called with { contentType }', () => { + it('returns unzipBuffer if `contentType` is `application/zip`', () => { + const extractor = getBufferExtractor({ contentType: 'application/zip' }); + expect(extractor).toBe(unzipBuffer); + }); + + it('returns untarBuffer if `contentType` is `application/gzip`', () => { + const extractor = getBufferExtractor({ contentType: 'application/gzip' }); + expect(extractor).toBe(untarBuffer); + }); + + it('returns `undefined` if `contentType` ends in anything else', () => { + const extractor = getBufferExtractor({ contentType: '.xyz' }); + expect(extractor).toEqual(undefined); }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index 0172f3bb38f510..e6d14a7846c225 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -26,14 +26,14 @@ import { setArchiveFilelist, deleteArchiveFilelist, } from './cache'; -import { ArchiveEntry, untarBuffer, unzipBuffer } from './extract'; +import { ArchiveEntry, getBufferExtractor } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; import { appContextService } from '../..'; import { PackageNotFoundError, PackageCacheError } from '../../../errors'; -export { ArchiveEntry } from './extract'; +export { ArchiveEntry, getBufferExtractor } from './extract'; export interface SearchParams { category?: CategoryId; @@ -139,7 +139,10 @@ export async function unpackRegistryPackageToCache( ): Promise { const paths: string[] = []; const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); - const bufferExtractor = getBufferExtractor(archivePath); + const bufferExtractor = getBufferExtractor({ archivePath }); + if (!bufferExtractor) { + throw new Error('Unknown compression format. Please use .zip or .gz'); + } await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { const { path, buffer } = entry; const { file } = pathParts(path); @@ -199,13 +202,6 @@ export function pathParts(path: string): AssetParts { } as AssetParts; } -export function getBufferExtractor(archivePath: string) { - const isZip = archivePath.endsWith('.zip'); - const bufferExtractor = isZip ? unzipBuffer : untarBuffer; - - return bufferExtractor; -} - export async function ensureCachedArchiveInfo( name: string, version: string, From 2287376aebb58ffb127b19193bbb5255a2614dd1 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Thu, 5 Nov 2020 12:37:58 -0800 Subject: [PATCH 09/20] [DOCS] Updates field formatters (#82667) * [DOCS] Updates field formatters * Update docs/management/managing-fields.asciidoc Co-authored-by: Kaarina Tungseth * [DOCS] Minor edits Co-authored-by: Kaarina Tungseth --- .../images/management-index-patterns.png | Bin 53215 -> 0 bytes docs/management/managing-fields.asciidoc | 75 ++++-------------- 2 files changed, 17 insertions(+), 58 deletions(-) delete mode 100644 docs/management/images/management-index-patterns.png diff --git a/docs/management/images/management-index-patterns.png b/docs/management/images/management-index-patterns.png deleted file mode 100644 index 232d32893b96d3e6a0ae7775fc0cdde40a1a4bc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53215 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 Roles*. - -[float] -=== Create an index pattern - -An index pattern is the glue that connects {kib} to your {es} data. Create an -index pattern whenever you load your own data into {kib}. To get started, -click *Create index pattern*, and then follow the guided steps. Refer to -<> for the types of index patterns -that you can create. - -[float] -=== Manage your index pattern - -To view the fields and associated data types in an index pattern, click its name in -the *Index patterns* overview. - -[role="screenshot"] -image::management/index-patterns/images/new-index-pattern.png["Index files and data types"] +Kibana provides these field formatters: -Use the icons to perform the following actions: - -* [[set-default-pattern]]*Set the default index pattern.* {kib} uses a badge to make users -aware of which index pattern is the default. The first pattern -you create is automatically designated as the default pattern. The default -index pattern is loaded when you open *Discover*. +* <> +* <> +* <> +* <> -* *Refresh the index fields list.* You can refresh the index fields list to -pick up any newly-added fields. Doing so also resets the {kib} popularity counters -for the fields. The popularity counters are used in *Discover* to sort fields in lists. +To format a field: -* [[delete-pattern]]*Delete the index pattern.* This action removes the pattern from the list of -Saved Objects in {kib}. You will not be able to recover field formatters, -scripted fields, source filters, and field popularity data associated with the index pattern. -Deleting an index pattern does -not remove any indices or data documents from {es}. +. Open the main menu, and click *Stack Management > Index Patterns*. +. Click the index pattern that contains the field you want to format. +. Find the field you want to format and click the edit icon (image:management/index-patterns/images/edit_icon.png[]). +. Select a format and fill in the details. + -WARNING: Deleting an index pattern breaks all visualizations, saved searches, and -other saved objects that reference the pattern. - -[float] -=== Edit a field - -To edit a field's properties, click the edit icon -image:management/index-patterns/images/edit_icon.png[] in the detail view. -You can set the field's format and popularity value. +[role="screenshot"] +image:management/index-patterns/images/edit-field-format.png["Edit field format"] -Kibana has field formatters for the following field types: -* <> -* <> -* <> -* <> [[field-formatters-string]] === String field formatters From 26f79a6a2971f95f1fc1b24691c457a9aebe09aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Fri, 6 Nov 2020 00:30:47 +0100 Subject: [PATCH 10/20] [Security Solution] Unskip Overview cypress tests (#82782) --- .../security_solution/cypress/integration/overview.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index dafcabb8e1e8df..69094cad7456e9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -11,8 +11,7 @@ import { loginAndWaitForPage } from '../tasks/login'; import { OVERVIEW_URL } from '../urls/navigation'; -// Failing: See https://github.com/elastic/kibana/issues/81848 -describe.skip('Overview Page', () => { +describe('Overview Page', () => { it('Host stats render with correct values', () => { cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); From 8cdf56636aa5fd7453922714cd0ce01040d103d4 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 5 Nov 2020 17:41:07 -0700 Subject: [PATCH 11/20] Adds cloud links to user popover (#66825) Co-authored-by: Ryan Keairns --- x-pack/plugins/cloud/kibana.json | 2 +- x-pack/plugins/cloud/public/index.ts | 2 +- x-pack/plugins/cloud/public/mocks.ts | 18 +++ x-pack/plugins/cloud/public/plugin.ts | 28 +++- .../plugins/cloud/public/user_menu_links.ts | 38 +++++ x-pack/plugins/cloud/server/config.ts | 2 + x-pack/plugins/security/public/index.ts | 1 + x-pack/plugins/security/public/mocks.ts | 7 + .../security/public/nav_control/index.mock.ts | 14 ++ .../security/public/nav_control/index.ts | 3 +- .../nav_control/nav_control_component.scss | 11 ++ .../nav_control_component.test.tsx | 38 +++++ .../nav_control/nav_control_component.tsx | 139 ++++++++++++------ .../nav_control/nav_control_service.tsx | 39 ++++- .../plugins/security/public/plugin.test.tsx | 7 +- x-pack/plugins/security/public/plugin.tsx | 4 +- 16 files changed, 292 insertions(+), 61 deletions(-) create mode 100644 x-pack/plugins/cloud/public/mocks.ts create mode 100644 x-pack/plugins/cloud/public/user_menu_links.ts create mode 100644 x-pack/plugins/security/public/nav_control/index.mock.ts create mode 100644 x-pack/plugins/security/public/nav_control/nav_control_component.scss diff --git a/x-pack/plugins/cloud/kibana.json b/x-pack/plugins/cloud/kibana.json index 27b35bcbdd88b9..9bca2f30bd23cf 100644 --- a/x-pack/plugins/cloud/kibana.json +++ b/x-pack/plugins/cloud/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "cloud"], - "optionalPlugins": ["usageCollection", "home"], + "optionalPlugins": ["usageCollection", "home", "security"], "server": true, "ui": true } diff --git a/x-pack/plugins/cloud/public/index.ts b/x-pack/plugins/cloud/public/index.ts index 39ef5f452c18b8..680b2f1ad2bd65 100644 --- a/x-pack/plugins/cloud/public/index.ts +++ b/x-pack/plugins/cloud/public/index.ts @@ -7,7 +7,7 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { CloudPlugin } from './plugin'; -export { CloudSetup } from './plugin'; +export { CloudSetup, CloudConfigType } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new CloudPlugin(initializerContext); } diff --git a/x-pack/plugins/cloud/public/mocks.ts b/x-pack/plugins/cloud/public/mocks.ts new file mode 100644 index 00000000000000..bafebbca4ecdd8 --- /dev/null +++ b/x-pack/plugins/cloud/public/mocks.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. + */ + +function createSetupMock() { + return { + cloudId: 'mock-cloud-id', + isCloudEnabled: true, + resetPasswordUrl: 'reset-password-url', + accountUrl: 'account-url', + }; +} + +export const cloudMock = { + createSetup: createSetupMock, +}; diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 45005f3f5e4227..bc410b89c30e7a 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -6,40 +6,51 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { i18n } from '@kbn/i18n'; +import { SecurityPluginStart } from '../../security/public'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { createUserMenuLinks } from './user_menu_links'; -interface CloudConfigType { +export interface CloudConfigType { id?: string; resetPasswordUrl?: string; deploymentUrl?: string; + accountUrl?: string; } interface CloudSetupDependencies { home?: HomePublicPluginSetup; } +interface CloudStartDependencies { + security?: SecurityPluginStart; +} + export interface CloudSetup { cloudId?: string; cloudDeploymentUrl?: string; isCloudEnabled: boolean; + resetPasswordUrl?: string; + accountUrl?: string; } export class CloudPlugin implements Plugin { private config!: CloudConfigType; + private isCloudEnabled: boolean; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.isCloudEnabled = false; } public async setup(core: CoreSetup, { home }: CloudSetupDependencies) { const { id, resetPasswordUrl, deploymentUrl } = this.config; - const isCloudEnabled = getIsCloudEnabled(id); + this.isCloudEnabled = getIsCloudEnabled(id); if (home) { - home.environment.update({ cloud: isCloudEnabled }); - if (isCloudEnabled) { + home.environment.update({ cloud: this.isCloudEnabled }); + if (this.isCloudEnabled) { home.tutorials.setVariable('cloud', { id, resetPasswordUrl }); } } @@ -47,11 +58,11 @@ export class CloudPlugin implements Plugin { return { cloudId: id, cloudDeploymentUrl: deploymentUrl, - isCloudEnabled, + isCloudEnabled: this.isCloudEnabled, }; } - public start(coreStart: CoreStart) { + public start(coreStart: CoreStart, { security }: CloudStartDependencies) { const { deploymentUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); if (deploymentUrl) { @@ -63,5 +74,10 @@ export class CloudPlugin implements Plugin { href: deploymentUrl, }); } + + if (security && this.isCloudEnabled) { + const userMenuLinks = createUserMenuLinks(this.config); + security.navControlService.addUserMenuLinks(userMenuLinks); + } } } diff --git a/x-pack/plugins/cloud/public/user_menu_links.ts b/x-pack/plugins/cloud/public/user_menu_links.ts new file mode 100644 index 00000000000000..15e2f14e885ba2 --- /dev/null +++ b/x-pack/plugins/cloud/public/user_menu_links.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { UserMenuLink } from '../../security/public'; +import { CloudConfigType } from '.'; + +export const createUserMenuLinks = (config: CloudConfigType): UserMenuLink[] => { + const { resetPasswordUrl, accountUrl } = config; + const userMenuLinks = [] as UserMenuLink[]; + + if (resetPasswordUrl) { + userMenuLinks.push({ + label: i18n.translate('xpack.cloud.userMenuLinks.profileLinkText', { + defaultMessage: 'Cloud profile', + }), + iconType: 'logoCloud', + href: resetPasswordUrl, + order: 100, + }); + } + + if (accountUrl) { + userMenuLinks.push({ + label: i18n.translate('xpack.cloud.userMenuLinks.accountLinkText', { + defaultMessage: 'Account & Billing', + }), + iconType: 'gear', + href: accountUrl, + order: 200, + }); + } + + return userMenuLinks; +}; diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index ff8a2c5acdf9ab..eaa4ab7a482dd6 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -23,6 +23,7 @@ const configSchema = schema.object({ apm: schema.maybe(apmConfigSchema), resetPasswordUrl: schema.maybe(schema.string()), deploymentUrl: schema.maybe(schema.string()), + accountUrl: schema.maybe(schema.string()), }); export type CloudConfigType = TypeOf; @@ -32,6 +33,7 @@ export const config: PluginConfigDescriptor = { id: true, resetPasswordUrl: true, deploymentUrl: true, + accountUrl: true, }, schema: configSchema, }; diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 8016c942240601..d0382c22ed3c67 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -16,6 +16,7 @@ import { export { SecurityPluginSetup, SecurityPluginStart }; export { AuthenticatedUser } from '../common/model'; export { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; +export { UserMenuLink } from '../public/nav_control'; export const plugin: PluginInitializer< SecurityPluginSetup, diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index 33c1d1446afba2..26a759ca522679 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -7,6 +7,7 @@ import { authenticationMock } from './authentication/index.mock'; import { createSessionTimeoutMock } from './session/session_timeout.mock'; import { licenseMock } from '../common/licensing/index.mock'; +import { navControlServiceMock } from './nav_control/index.mock'; function createSetupMock() { return { @@ -15,7 +16,13 @@ function createSetupMock() { license: licenseMock.create(), }; } +function createStartMock() { + return { + navControlService: navControlServiceMock.createStart(), + }; +} export const securityMock = { createSetup: createSetupMock, + createStart: createStartMock, }; diff --git a/x-pack/plugins/security/public/nav_control/index.mock.ts b/x-pack/plugins/security/public/nav_control/index.mock.ts new file mode 100644 index 00000000000000..1cd10810d7c8f1 --- /dev/null +++ b/x-pack/plugins/security/public/nav_control/index.mock.ts @@ -0,0 +1,14 @@ +/* + * 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 { SecurityNavControlServiceStart } from '.'; + +export const navControlServiceMock = { + createStart: (): jest.Mocked => ({ + getUserMenuLinks$: jest.fn(), + addUserMenuLinks: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/public/nav_control/index.ts b/x-pack/plugins/security/public/nav_control/index.ts index 2b0af1a45d05a9..737ae500546987 100644 --- a/x-pack/plugins/security/public/nav_control/index.ts +++ b/x-pack/plugins/security/public/nav_control/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SecurityNavControlService } from './nav_control_service'; +export { SecurityNavControlService, SecurityNavControlServiceStart } from './nav_control_service'; +export { UserMenuLink } from './nav_control_component'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.scss b/x-pack/plugins/security/public/nav_control/nav_control_component.scss new file mode 100644 index 00000000000000..a3e04b08cfac20 --- /dev/null +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.scss @@ -0,0 +1,11 @@ +.chrNavControl__userMenu { + .euiContextMenuPanelTitle { + // Uppercased by default, override to match actual username + text-transform: none; + } + + .euiContextMenuItem { + // Temp fix for EUI issue https://github.com/elastic/eui/issues/3092 + line-height: normal; + } +} \ No newline at end of file diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index c1c6a9f69b6ec9..1da91e80d062de 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { BehaviorSubject } from 'rxjs'; import { shallowWithIntl, nextTick, mountWithIntl } from 'test_utils/enzyme_helpers'; import { SecurityNavControl } from './nav_control_component'; import { AuthenticatedUser } from '../../common/model'; @@ -17,6 +18,7 @@ describe('SecurityNavControl', () => { user: new Promise(() => {}) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -42,6 +44,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -70,6 +73,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -91,6 +95,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -107,4 +112,37 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); }); + + it('renders a popover with additional user menu links registered by other plugins', async () => { + const props = { + user: Promise.resolve({ full_name: 'foo' }) as Promise, + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, + { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, + ]), + }; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(0); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 3ddabb0dc55f8c..c22308fa8a43e0 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -7,38 +7,52 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; - +import { Observable, Subscription } from 'rxjs'; import { EuiAvatar, - EuiFlexGroup, - EuiFlexItem, EuiHeaderSectionItemButton, - EuiLink, - EuiText, - EuiSpacer, EuiPopover, EuiLoadingSpinner, + EuiIcon, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + IconType, + EuiText, } from '@elastic/eui'; import { AuthenticatedUser } from '../../common/model'; +import './nav_control_component.scss'; + +export interface UserMenuLink { + label: string; + iconType: IconType; + href: string; + order?: number; +} + interface Props { user: Promise; editProfileUrl: string; logoutUrl: string; + userMenuLinks$: Observable; } interface State { isOpen: boolean; authenticatedUser: AuthenticatedUser | null; + userMenuLinks: UserMenuLink[]; } export class SecurityNavControl extends Component { + private subscription?: Subscription; + constructor(props: Props) { super(props); this.state = { isOpen: false, authenticatedUser: null, + userMenuLinks: [], }; props.user.then((authenticatedUser) => { @@ -48,6 +62,18 @@ export class SecurityNavControl extends Component { }); } + componentDidMount() { + this.subscription = this.props.userMenuLinks$.subscribe(async (userMenuLinks) => { + this.setState({ userMenuLinks }); + }); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + onMenuButtonClick = () => { if (!this.state.authenticatedUser) { return; @@ -66,13 +92,13 @@ export class SecurityNavControl extends Component { render() { const { editProfileUrl, logoutUrl } = this.props; - const { authenticatedUser } = this.state; + const { authenticatedUser, userMenuLinks } = this.state; - const name = + const username = (authenticatedUser && (authenticatedUser.full_name || authenticatedUser.username)) || ''; const buttonContents = authenticatedUser ? ( - + ) : ( ); @@ -92,6 +118,60 @@ export class SecurityNavControl extends Component { ); + const profileMenuItem = { + name: ( + + ), + icon: , + href: editProfileUrl, + 'data-test-subj': 'profileLink', + }; + + const logoutMenuItem = { + name: ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }; + + const items: EuiContextMenuPanelItemDescriptor[] = []; + + items.push(profileMenuItem); + + if (userMenuLinks.length) { + const userMenuLinkMenuItems = userMenuLinks + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) + .map(({ label, iconType, href }: UserMenuLink) => ({ + name: {label}, + icon: , + href, + 'data-test-subj': `userMenuLink__${label}`, + })); + + items.push(...userMenuLinkMenuItems, { + isSeparator: true, + key: 'securityNavControlComponent__userMenuLinksSeparator', + }); + } + + items.push(logoutMenuItem); + + const panels = [ + { + id: 0, + title: username, + items, + }, + ]; + return ( { repositionOnScroll closePopover={this.closeMenu} panelPaddingSize="none" + buffer={0} > -
- - - - - - - -

{name}

-
- - - - - - - - - - - - - - - - - - - - -
-
+
+
); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index aa3ec2e47469d0..4ae64d667ce293 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -4,12 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Subscription } from 'rxjs'; +import { sortBy } from 'lodash'; +import { Observable, Subscription, BehaviorSubject, ReplaySubject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { CoreStart } from 'src/core/public'; + import ReactDOM from 'react-dom'; import React from 'react'; + import { SecurityLicense } from '../../common/licensing'; -import { SecurityNavControl } from './nav_control_component'; +import { SecurityNavControl, UserMenuLink } from './nav_control_component'; import { AuthenticationServiceSetup } from '../authentication'; interface SetupDeps { @@ -22,6 +26,18 @@ interface StartDeps { core: CoreStart; } +export interface SecurityNavControlServiceStart { + /** + * Returns an Observable of the array of user menu links registered by other plugins + */ + getUserMenuLinks$: () => Observable; + + /** + * Registers the provided user menu links to be displayed in the user menu in the global nav + */ + addUserMenuLinks: (newUserMenuLink: UserMenuLink[]) => void; +} + export class SecurityNavControlService { private securityLicense!: SecurityLicense; private authc!: AuthenticationServiceSetup; @@ -31,13 +47,16 @@ export class SecurityNavControlService { private securityFeaturesSubscription?: Subscription; + private readonly stop$ = new ReplaySubject(1); + private userMenuLinks$ = new BehaviorSubject([]); + public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { this.securityLicense = securityLicense; this.authc = authc; this.logoutUrl = logoutUrl; } - public start({ core }: StartDeps) { + public start({ core }: StartDeps): SecurityNavControlServiceStart { this.securityFeaturesSubscription = this.securityLicense.features$.subscribe( ({ showLinks }) => { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); @@ -49,6 +68,14 @@ export class SecurityNavControlService { } } ); + + return { + getUserMenuLinks$: () => + this.userMenuLinks$.pipe(map(this.sortUserMenuLinks), takeUntil(this.stop$)), + addUserMenuLinks: (userMenuLink: UserMenuLink[]) => { + this.userMenuLinks$.next(userMenuLink); + }, + }; } public stop() { @@ -57,6 +84,7 @@ export class SecurityNavControlService { this.securityFeaturesSubscription = undefined; } this.navControlRegistered = false; + this.stop$.next(); } private registerSecurityNavControl( @@ -72,6 +100,7 @@ export class SecurityNavControlService { user: currentUserPromise, editProfileUrl: core.http.basePath.prepend('/security/account'), logoutUrl: this.logoutUrl, + userMenuLinks$: this.userMenuLinks$, }; ReactDOM.render( @@ -86,4 +115,8 @@ export class SecurityNavControlService { this.navControlRegistered = true; } + + private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) { + return sortBy(userMenuLinks, 'order'); + } } diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index d86d4812af5e3f..6f5a2a031a7b22 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -97,7 +97,12 @@ describe('Security Plugin', () => { data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }) - ).toBeUndefined(); + ).toEqual({ + navControlService: { + getUserMenuLinks$: expect.any(Function), + addUserMenuLinks: expect.any(Function), + }, + }); }); it('starts Management Service if `management` plugin is available', () => { diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 700653c4cecb8e..f94772c43dd896 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -146,11 +146,13 @@ export class SecurityPlugin public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) { this.sessionTimeout.start(); - this.navControlService.start({ core }); this.securityCheckupService.start({ securityOssStart: securityOss, docLinks: core.docLinks }); + if (management) { this.managementService.start({ capabilities: core.application.capabilities }); } + + return { navControlService: this.navControlService.start({ core }) }; } public stop() { From f3599fec4c2edd97fa555679a154cb5a8b48096b Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Thu, 5 Nov 2020 19:45:10 -0500 Subject: [PATCH 12/20] [SECURITY SOLUTIONS] Keep context of timeline when switching tabs in security solutions (#82237) * try to keep timeline context when switching tabs * fix popover * simpler solution to keep timelien context between tabs * fix timeline context with relative date * allow update on the kql bar when opening new timeline * keep detail view in context when savedObjectId of the timeline does not chnage * remove redux solution and just KISS it * add unit test for the popover * add test on timeline context cache * final commit -> to fix context of timeline between tabs * keep timerange kind to absolute when refreshing * fix bug today/thiw week to be absolute and not relative * add unit test for absolute date for today and this week * fix absolute today/this week on timeline * fix refresh between page and timeline when link * clean up * remove nit Co-authored-by: Patryk Kopycinski --- .../common/components/query_bar/index.tsx | 5 +- .../common/components/search_bar/index.tsx | 34 ++- .../super_date_picker/index.test.tsx | 20 +- .../components/super_date_picker/index.tsx | 63 ++-- .../super_date_picker/selectors.test.ts | 69 +++-- .../components/super_date_picker/selectors.ts | 15 +- .../public/common/store/inputs/actions.ts | 2 + .../public/common/store/inputs/model.ts | 4 +- .../public/common/store/inputs/reducer.ts | 23 +- .../common/store/sourcerer/selectors.ts | 21 +- .../field_renderers/field_renderers.test.tsx | 42 +++ .../field_renderers/field_renderers.tsx | 8 +- .../__snapshots__/timeline.test.tsx.snap | 1 + .../components/timeline/body/events/index.tsx | 1 - .../timeline/body/events/stateful_event.tsx | 276 ++++++------------ .../timelines/components/timeline/index.tsx | 11 +- .../components/timeline/timeline.test.tsx | 1 + .../components/timeline/timeline.tsx | 3 + .../containers/active_timeline_context.ts | 75 +++++ .../timelines/containers/index.test.tsx | 210 +++++++++++++ .../public/timelines/containers/index.tsx | 141 +++++++-- .../timeline/epic_local_storage.test.tsx | 1 + .../timelines/store/timeline/helpers.ts | 33 ++- 23 files changed, 748 insertions(+), 311 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index d68ab3a171151e..7555f6e7342145 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -62,7 +62,10 @@ export const QueryBar = memo( const [draftQuery, setDraftQuery] = useState(filterQuery); useEffect(() => { - // Reset draftQuery when `Create new timeline` is clicked + setDraftQuery(filterQuery); + }, [filterQuery]); + + useEffect(() => { if (filterQueryDraft == null) { setDraftQuery(filterQuery); } diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index 2dc44fd48e66dc..acc01ac4f76aa8 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -132,7 +132,7 @@ export const SearchBarComponent = memo( if (!isStateUpdated) { // That mean we are doing a refresh! - if (isQuickSelection) { + if (isQuickSelection && payload.dateRange.to !== payload.dateRange.from) { updateSearchBar.updateTime = true; updateSearchBar.end = payload.dateRange.to; updateSearchBar.start = payload.dateRange.from; @@ -313,7 +313,7 @@ const makeMapStateToProps = () => { fromStr: getFromStrSelector(inputsRange), filterQuery: getFilterQuerySelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - queries: getQueriesSelector(inputsRange), + queries: getQueriesSelector(state, id), savedQuery: getSavedQuerySelector(inputsRange), start: getStartSelector(inputsRange), toStr: getToStrSelector(inputsRange), @@ -351,15 +351,27 @@ export const dispatchUpdateSearch = (dispatch: Dispatch) => ({ const fromDate = formatDate(start); let toDate = formatDate(end, { roundUp: true }); if (isQuickSelection) { - dispatch( - inputsActions.setRelativeRangeDatePicker({ - id, - fromStr: start, - toStr: end, - from: fromDate, - to: toDate, - }) - ); + if (end === start) { + dispatch( + inputsActions.setAbsoluteRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } else { + dispatch( + inputsActions.setRelativeRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } } else { toDate = formatDate(end); dispatch( diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx index 956ee4b05f9d65..bcb10f8fd26c33 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx @@ -139,7 +139,7 @@ describe('SIEM Super Date Picker', () => { expect(store.getState().inputs.global.timerange.toStr).toBe('now'); }); - test('Make Sure it is Today date', () => { + test('Make Sure it is Today date is an absolute date', () => { wrapper .find('[data-test-subj="superDatePickerToggleQuickMenuButton"]') .first() @@ -151,8 +151,22 @@ describe('SIEM Super Date Picker', () => { .first() .simulate('click'); wrapper.update(); - expect(store.getState().inputs.global.timerange.fromStr).toBe('now/d'); - expect(store.getState().inputs.global.timerange.toStr).toBe('now/d'); + expect(store.getState().inputs.global.timerange.kind).toBe('absolute'); + }); + + test('Make Sure it is This Week date is an absolute date', () => { + wrapper + .find('[data-test-subj="superDatePickerToggleQuickMenuButton"]') + .first() + .simulate('click'); + wrapper.update(); + + wrapper + .find('[data-test-subj="superDatePickerCommonlyUsed_This_week"]') + .first() + .simulate('click'); + wrapper.update(); + expect(store.getState().inputs.global.timerange.kind).toBe('absolute'); }); test('Make Sure to (end date) is superior than from (start date)', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx index 4443d24531b22e..97e023176647f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx @@ -91,12 +91,12 @@ export const SuperDatePickerComponent = React.memo( toStr, updateReduxTime, }) => { - const [isQuickSelection, setIsQuickSelection] = useState(true); const [recentlyUsedRanges, setRecentlyUsedRanges] = useState( [] ); const onRefresh = useCallback( ({ start: newStart, end: newEnd }: OnRefreshProps): void => { + const isQuickSelection = newStart.includes('now') || newEnd.includes('now'); const { kqlHaveBeenUpdated } = updateReduxTime({ end: newEnd, id, @@ -117,12 +117,13 @@ export const SuperDatePickerComponent = React.memo( refetchQuery(queries); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [end, id, isQuickSelection, kqlQuery, start, timelineId] + [end, id, kqlQuery, queries, start, timelineId, updateReduxTime] ); const onRefreshChange = useCallback( ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { + const isQuickSelection = + (fromStr != null && fromStr.includes('now')) || (toStr != null && toStr.includes('now')); if (duration !== refreshInterval) { setDuration({ id, duration: refreshInterval }); } @@ -137,27 +138,22 @@ export const SuperDatePickerComponent = React.memo( refetchQuery(queries); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [id, isQuickSelection, duration, policy, toStr] + [fromStr, toStr, duration, policy, setDuration, id, stopAutoReload, startAutoReload, queries] ); - const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => { + const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); }; const onTimeChange = useCallback( - ({ - start: newStart, - end: newEnd, - isQuickSelection: newIsQuickSelection, - isInvalid, - }: OnTimeChangeProps) => { + ({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => { + const isQuickSelection = newStart.includes('now') || newEnd.includes('now'); if (!isInvalid) { updateReduxTime({ end: newEnd, id, isInvalid, - isQuickSelection: newIsQuickSelection, + isQuickSelection, kql: kqlQuery, start: newStart, timelineId, @@ -174,15 +170,13 @@ export const SuperDatePickerComponent = React.memo( ]; setRecentlyUsedRanges(newRecentlyUsedRanges); - setIsQuickSelection(newIsQuickSelection); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [recentlyUsedRanges, kqlQuery] + [updateReduxTime, id, kqlQuery, timelineId, recentlyUsedRanges] ); - const endDate = kind === 'relative' ? toStr : new Date(end).toISOString(); - const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString(); + const endDate = toStr != null ? toStr : new Date(end).toISOString(); + const startDate = fromStr != null ? fromStr : new Date(start).toISOString(); const [quickRanges] = useUiSetting$(DEFAULT_TIMEPICKER_QUICK_RANGES); const commonlyUsedRanges = isEmpty(quickRanges) @@ -232,15 +226,27 @@ export const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ const fromDate = formatDate(start); let toDate = formatDate(end, { roundUp: true }); if (isQuickSelection) { - dispatch( - inputsActions.setRelativeRangeDatePicker({ - id, - fromStr: start, - toStr: end, - from: fromDate, - to: toDate, - }) - ); + if (end === start) { + dispatch( + inputsActions.setAbsoluteRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } else { + dispatch( + inputsActions.setRelativeRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } } else { toDate = formatDate(end); dispatch( @@ -284,6 +290,7 @@ export const makeMapStateToProps = () => { const getToStrSelector = toStrSelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); + return { duration: getDurationSelector(inputsRange), end: getEndSelector(inputsRange), @@ -292,7 +299,7 @@ export const makeMapStateToProps = () => { kind: getKindSelector(inputsRange), kqlQuery: getKqlQuerySelector(inputsRange) as inputsModel.GlobalKqlQuery, policy: getPolicySelector(inputsRange), - queries: getQueriesSelector(inputsRange) as inputsModel.GlobalGraphqlQuery[], + queries: getQueriesSelector(state, id), start: getStartSelector(inputsRange), toStr: getToStrSelector(inputsRange), }; diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts index 7cb4ea9ada93fd..ee19aef717f4f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts @@ -17,6 +17,8 @@ import { } from './selectors'; import { InputsRange, AbsoluteTimeRange, RelativeTimeRange } from '../../store/inputs/model'; import { cloneDeep } from 'lodash/fp'; +import { mockGlobalState } from '../../mock'; +import { State } from '../../store'; describe('selectors', () => { let absoluteTime: AbsoluteTimeRange = { @@ -42,6 +44,8 @@ describe('selectors', () => { filters: [], }; + let mockState: State = mockGlobalState; + const getPolicySelector = policySelector(); const getDurationSelector = durationSelector(); const getKindSelector = kindSelector(); @@ -75,6 +79,8 @@ describe('selectors', () => { }, filters: [], }; + + mockState = mockGlobalState; }); describe('#policySelector', () => { @@ -375,34 +381,61 @@ describe('selectors', () => { describe('#queriesSelector', () => { test('returns the same reference given the same identical input twice', () => { - const result1 = getQueriesSelector(inputState); - const result2 = getQueriesSelector(inputState); + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const result1 = getQueriesSelector(myMock, 'global'); + const result2 = getQueriesSelector(myMock, 'global'); expect(result1).toBe(result2); }); test('DOES NOT return the same reference given different input twice but with different deep copies since the query is not a primitive', () => { - const clone = cloneDeep(inputState); - const result1 = getQueriesSelector(inputState); - const result2 = getQueriesSelector(clone); + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const clone = cloneDeep(myMock); + const result1 = getQueriesSelector(myMock, 'global'); + const result2 = getQueriesSelector(clone, 'global'); expect(result1).not.toBe(result2); }); test('returns a different reference even if the contents are the same since query is an array and not a primitive', () => { - const result1 = getQueriesSelector(inputState); - const change: InputsRange = { - ...inputState, - queries: [ - { - loading: false, - id: '1', - inspect: { dsl: [], response: [] }, - isInspected: false, - refetch: null, - selectedInspectIndex: 0, + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const result1 = getQueriesSelector(myMock, 'global'); + const myMockChange: State = { + ...myMock, + inputs: { + ...mockState.inputs, + global: { + ...mockState.inputs.global, + queries: [ + { + loading: false, + id: '1', + inspect: { dsl: [], response: [] }, + isInspected: false, + refetch: null, + selectedInspectIndex: 0, + }, + ], }, - ], + }, }; - const result2 = getQueriesSelector(change); + const result2 = getQueriesSelector(myMockChange, 'global'); expect(result1).not.toBe(result2); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts index d4b990890ebbad..840dd1f4a6b9f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash'; import { createSelector } from 'reselect'; +import { State } from '../../store'; +import { InputsModelId } from '../../store/inputs/constants'; import { Policy, InputsRange, TimeRange, GlobalQuery } from '../../store/inputs/model'; export const getPolicy = (inputState: InputsRange): Policy => inputState.policy; @@ -13,6 +16,16 @@ export const getTimerange = (inputState: InputsRange): TimeRange => inputState.t export const getQueries = (inputState: InputsRange): GlobalQuery[] => inputState.queries; +export const getGlobalQueries = (state: State, id: InputsModelId): GlobalQuery[] => { + const inputsRange = state.inputs[id]; + return !isEmpty(inputsRange.linkTo) + ? inputsRange.linkTo.reduce((acc, linkToId) => { + const linkToIdInputsRange: InputsRange = state.inputs[linkToId]; + return [...acc, ...linkToIdInputsRange.queries]; + }, inputsRange.queries) + : inputsRange.queries; +}; + export const policySelector = () => createSelector(getPolicy, (policy) => policy.kind); export const durationSelector = () => createSelector(getPolicy, (policy) => policy.duration); @@ -31,7 +44,7 @@ export const isLoadingSelector = () => createSelector(getQueries, (queries) => queries.some((i) => i.loading === true)); export const queriesSelector = () => - createSelector(getQueries, (queries) => queries.filter((q) => q.id !== 'kql')); + createSelector(getGlobalQueries, (queries) => queries.filter((q) => q.id !== 'kql')); export const kqlQuerySelector = () => createSelector(getQueries, (queries) => queries.find((q) => q.id === 'kql')); diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts index 5d00882f778c07..db911365972157 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts @@ -16,6 +16,8 @@ export const setAbsoluteRangeDatePicker = actionCreator<{ id: InputsModelId; from: string; to: string; + fromStr?: string; + toStr?: string; }>('SET_ABSOLUTE_RANGE_DATE_PICKER'); export const setTimelineRangeDatePicker = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts index a8db48c7b31bba..f4e2c2f28f4776 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts @@ -11,8 +11,8 @@ import { Query, Filter, SavedQuery } from '../../../../../../../src/plugins/data export interface AbsoluteTimeRange { kind: 'absolute'; - fromStr: undefined; - toStr: undefined; + fromStr?: string; + toStr?: string; from: string; to: string; } diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts index a94f0f6ca24ee5..59ae029a9207e7 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts @@ -149,16 +149,19 @@ export const inputsReducer = reducerWithInitialState(initialInputsState) }, }; }) - .case(setAbsoluteRangeDatePicker, (state, { id, from, to }) => { - const timerange: TimeRange = { - kind: 'absolute', - fromStr: undefined, - toStr: undefined, - from, - to, - }; - return updateInputTimerange(id, timerange, state); - }) + .case( + setAbsoluteRangeDatePicker, + (state, { id, from, to, fromStr = undefined, toStr = undefined }) => { + const timerange: TimeRange = { + kind: 'absolute', + fromStr, + toStr, + from, + to, + }; + return updateInputTimerange(id, timerange, state); + } + ) .case(setRelativeRangeDatePicker, (state, { id, fromStr, from, to, toStr }) => { const timerange: TimeRange = { kind: 'relative', diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index e7bd6234cb207b..6ebc00133c0cdc 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -86,18 +86,25 @@ export const defaultIndexNamesSelector = () => { return mapStateToProps; }; -const EXLCUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; +const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; export const getSourcererScopeSelector = () => { const getScopesSelector = scopesSelector(); - const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => ({ - ...getScopesSelector(state)[scopeId], - selectedPatterns: getScopesSelector(state)[scopeId].selectedPatterns.some( + const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { + const selectedPatterns = getScopesSelector(state)[scopeId].selectedPatterns.some( (index) => index === 'logs-*' ) - ? [...getScopesSelector(state)[scopeId].selectedPatterns, EXLCUDE_ELASTIC_CLOUD_INDEX] - : getScopesSelector(state)[scopeId].selectedPatterns, - }); + ? [...getScopesSelector(state)[scopeId].selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX] + : getScopesSelector(state)[scopeId].selectedPatterns; + return { + ...getScopesSelector(state)[scopeId], + selectedPatterns, + indexPattern: { + ...getScopesSelector(state)[scopeId].indexPattern, + title: selectedPatterns.join(), + }, + }; + }; return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx index bf89cc7fa9084a..1d8d0f789d6b7e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx @@ -20,6 +20,7 @@ import { reputationRenderer, DefaultFieldRenderer, DEFAULT_MORE_MAX_HEIGHT, + DefaultFieldRendererOverflow, MoreContainer, } from './field_renderers'; import { mockData } from '../../../network/components/details/mock'; @@ -330,4 +331,45 @@ describe('Field Renderers', () => { expect(render).toHaveBeenCalledTimes(2); }); }); + + describe('DefaultFieldRendererOverflow', () => { + const idPrefix = 'prefix-1'; + const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7']; + + test('it should render the length of items after the overflowIndexStart', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' ,+2 More'); + expect(wrapper.find('[data-test-subj="more-container"]').first().exists()).toBe(false); + }); + + test('it should render the items after overflowIndexStart in the popover', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button').first().simulate('click'); + wrapper.update(); + expect(wrapper.find('.euiPopover').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="more-container"]').first().text()).toEqual( + 'item6item7' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx index cb913287b24d89..7f543ab859bb4a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx @@ -260,12 +260,12 @@ MoreContainer.displayName = 'MoreContainer'; export const DefaultFieldRendererOverflow = React.memo( ({ idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => { const [isOpen, setIsOpen] = useState(false); - const handleClose = useCallback(() => setIsOpen(false), []); + const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []); const button = useMemo( () => ( <> {' ,'} - + {`+${rowItems.length - overflowIndexStart} `} ), - [handleClose, overflowIndexStart, rowItems.length] + [togglePopover, overflowIndexStart, rowItems.length] ); return ( @@ -284,7 +284,7 @@ export const DefaultFieldRendererOverflow = React.memo = ({ columnHeaders={columnHeaders} columnRenderers={columnRenderers} containerElementRef={containerElementRef} - disableSensorVisibility={data != null && data.length < 101} docValueFields={docValueFields} event={event} eventIdToNoteIds={eventIdToNoteIds} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 4f385a46564833..83e824aa2450a6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -6,7 +6,6 @@ import React, { useRef, useState, useCallback } from 'react'; import uuid from 'uuid'; -import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; @@ -19,7 +18,6 @@ import { import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { SkeletonRow } from '../../skeleton_row'; import { OnColumnResized, OnPinEvent, @@ -38,6 +36,8 @@ import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import { inputsModel } from '../../../../../common/store'; +import { TimelineId } from '../../../../../../common/types/timeline'; +import { activeTimeline } from '../../../../containers/active_timeline_context'; interface Props { actionsColumnWidth: number; @@ -46,7 +46,6 @@ interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; - disableSensorVisibility: boolean; docValueFields: DocValueFields[]; event: TimelineItem; eventIdToNoteIds: Readonly>; @@ -73,33 +72,6 @@ export const getNewNoteId = (): string => uuid.v4(); const emptyDetails: TimelineEventsDetailsItem[] = []; -/** - * This is the default row height whenever it is a plain row renderer and not a custom row height. - * We use this value when we do not know the height of a particular row. - */ -const DEFAULT_ROW_HEIGHT = '32px'; - -/** - * This is the top offset in pixels of the top part of the timeline. The UI area where you do your - * drag and drop and filtering. It is a positive number in pixels of _PART_ of the header but not - * the entire header. We leave room for some rows to render behind the drag and drop so they might be - * visible by the time the user scrolls upwards. All other DOM elements are replaced with their "blank" - * rows. - */ -const TOP_OFFSET = 50; - -/** - * This is the bottom offset in pixels of the bottom part of the timeline. The UI area right below the - * timeline which is the footer. Since the footer is so incredibly small we don't have enough room to - * render around 5 rows below the timeline to get the user the best chance of always scrolling without seeing - * "blank rows". The negative number is to give the bottom of the browser window a bit of invisible space to - * keep around 5 rows rendering below it. All other DOM elements are replaced with their "blank" - * rows. - */ -const BOTTOM_OFFSET = -500; - -const VISIBILITY_SENSOR_OFFSET = { top: TOP_OFFSET, bottom: BOTTOM_OFFSET }; - const emptyNotes: string[] = []; const EventsTrSupplementContainerWrapper = React.memo(({ children }) => { @@ -116,7 +88,6 @@ const StatefulEventComponent: React.FC = ({ containerElementRef, columnHeaders, columnRenderers, - disableSensorVisibility = true, docValueFields, event, eventIdToNoteIds, @@ -138,7 +109,9 @@ const StatefulEventComponent: React.FC = ({ toggleColumn, updateNote, }) => { - const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); + const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>( + timelineId === TimelineId.active ? activeTimeline.getExpandedEventIds() : {} + ); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const { status: timelineStatus } = useShallowEqualSelector( (state) => state.timeline.timelineById[timelineId] @@ -148,21 +121,21 @@ const StatefulEventComponent: React.FC = ({ docValueFields, indexName: event._index!, eventId: event._id, - skip: !expanded[event._id], + skip: !expanded || !expanded[event._id], }); const onToggleShowNotes = useCallback(() => { const eventId = event._id; - setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] }); - }, [event, showNotes]); + setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] })); + }, [event]); const onToggleExpanded = useCallback(() => { const eventId = event._id; - setExpanded({ - ...expanded, - [eventId]: !expanded[eventId], - }); - }, [event, expanded]); + setExpanded((prevExpanded) => ({ ...prevExpanded, [eventId]: !prevExpanded[eventId] })); + if (timelineId === TimelineId.active) { + activeTimeline.toggleExpandedEvent(eventId); + } + }, [event._id, timelineId]); const associateNote = useCallback( (noteId: string) => { @@ -174,152 +147,87 @@ const StatefulEventComponent: React.FC = ({ [addNoteToEvent, event, isEventPinned, onPinEvent] ); - // Number of current columns plus one for actions. - const columnCount = columnHeaders.length + 1; - - const VisibilitySensorContent = useCallback( - ({ isVisible }) => { - if (isVisible || disableSensorVisibility) { - return ( - - - - - - - - - {getRowRenderer(event.ecs, rowRenderers).renderRow({ - browserFields, - data: event.ecs, - timelineId, - })} - - - - - - - ); - } else { - // Height place holder for visibility detection as well as re-rendering sections. - const height = - divElement.current != null && divElement.current!.clientHeight - ? `${divElement.current!.clientHeight}px` - : DEFAULT_ROW_HEIGHT; - - return ; - } - }, - [ - actionsColumnWidth, - associateNote, - browserFields, - columnCount, - columnHeaders, - columnRenderers, - detailsData, - disableSensorVisibility, - event._id, - event.data, - event.ecs, - eventIdToNoteIds, - expanded, - getNotesByIds, - isEventPinned, - isEventViewer, - loading, - loadingEventIds, - onColumnResized, - onPinEvent, - onRowSelected, - onToggleExpanded, - onToggleShowNotes, - onUnPinEvent, - onUpdateColumns, - refetch, - onRuleChange, - rowRenderers, - selectedEventIds, - showCheckboxes, - showNotes, - timelineId, - timelineStatus, - toggleColumn, - updateNote, - ] - ); - return ( - - {VisibilitySensorContent} - + + + + + + + + {getRowRenderer(event.ecs, rowRenderers).renderRow({ + browserFields, + data: event.ecs, + timelineId, + })} + + + + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 0c7b1e0cdecd5e..35d31e034e7f38 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -27,6 +27,11 @@ export interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const isTimerangeSame = (prevProps: Props, nextProps: Props) => + prevProps.end === nextProps.end && + prevProps.start === nextProps.start && + prevProps.timerangeKind === nextProps.timerangeKind; + const StatefulTimelineComponent = React.memo( ({ columns, @@ -51,6 +56,7 @@ const StatefulTimelineComponent = React.memo( start, status, timelineType, + timerangeKind, updateItemsPerPage, upsertColumn, usersViewing, @@ -125,13 +131,14 @@ const StatefulTimelineComponent = React.memo( status={status} toggleColumn={toggleColumn} timelineType={timelineType} + timerangeKind={timerangeKind} usersViewing={usersViewing} /> ); }, (prevProps, nextProps) => { return ( - prevProps.end === nextProps.end && + isTimerangeSame(prevProps, nextProps) && prevProps.graphEventId === nextProps.graphEventId && prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && @@ -142,7 +149,6 @@ const StatefulTimelineComponent = React.memo( prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.start === nextProps.start && prevProps.timelineType === nextProps.timelineType && prevProps.status === nextProps.status && deepEqual(prevProps.columns, nextProps.columns) && @@ -209,6 +215,7 @@ const makeMapStateToProps = () => { start: input.timerange.from, status, timelineType, + timerangeKind: input.timerange.kind, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 630a71693d182c..7fc269c954ac40 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -116,6 +116,7 @@ describe('Timeline', () => { start: startDate, status: TimelineStatus.active, timelineType: TimelineType.default, + timerangeKind: 'absolute', toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index b860011c2ddaff..f7c76c110ac3f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -112,6 +112,7 @@ export interface Props { start: string; status: TimelineStatusLiteral; timelineType: TimelineType; + timerangeKind: 'absolute' | 'relative'; toggleColumn: (column: ColumnHeaderOptions) => void; usersViewing: string[]; } @@ -143,6 +144,7 @@ export const TimelineComponent: React.FC = ({ status, sort, timelineType, + timerangeKind, toggleColumn, usersViewing, }) => { @@ -212,6 +214,7 @@ export const TimelineComponent: React.FC = ({ startDate: start, skip: !canQueryTimeline, sort: timelineQuerySortField, + timerangeKind, }); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts new file mode 100644 index 00000000000000..50bf8b37adf28d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -0,0 +1,75 @@ +/* + * 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 { TimelineArgs } from '.'; +import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; + +/* + * Future Engineer + * This class is just there to manage temporarily the reload of the active timeline when switching tabs + * because of the bootstrap of the security solution app, we will always trigger the query + * to avoid it we will cache its request and response so we can go back where the user was before switching tabs + * + * !!! Important !!! this is just there until, we will have a better way to bootstrap the app + * I did not want to put in the store because I was feeling it will feel less temporarily and I did not want other engineer using it + * + */ +class ActiveTimelineEvents { + private _activePage: number = 0; + private _expandedEventIds: Record = {}; + private _pageName: string = ''; + private _request: TimelineEventsAllRequestOptions | null = null; + private _response: TimelineArgs | null = null; + + getActivePage() { + return this._activePage; + } + + setActivePage(activePage: number) { + this._activePage = activePage; + } + + getExpandedEventIds() { + return this._expandedEventIds; + } + + toggleExpandedEvent(eventId: string) { + this._expandedEventIds = { + ...this._expandedEventIds, + [eventId]: !this._expandedEventIds[eventId], + }; + } + + setExpandedEventIds(expandedEventIds: Record) { + this._expandedEventIds = expandedEventIds; + } + + getPageName() { + return this._pageName; + } + + setPageName(pageName: string) { + this._pageName = pageName; + } + + getRequest() { + return this._request; + } + + setRequest(req: TimelineEventsAllRequestOptions) { + this._request = req; + } + + getResponse() { + return this._response; + } + + setResponse(resp: TimelineArgs | null) { + this._response = resp; + } +} + +export const activeTimeline = new ActiveTimelineEvents(); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx new file mode 100644 index 00000000000000..a5f8300546b5bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -0,0 +1,210 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { initSortDefault, TimelineArgs, useTimelineEvents, UseTimelineEventsProps } from '.'; +import { SecurityPageName } from '../../../common/constants'; +import { TimelineId } from '../../../common/types/timeline'; +import { mockTimelineData } from '../../common/mock'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockEvents = mockTimelineData.filter((i, index) => index <= 11); + +const mockSearch = jest.fn(); + +jest.mock('../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + capabilities: { + siem: { + crud: true, + }, + }, + }, + data: { + search: { + search: jest.fn().mockImplementation((args) => { + mockSearch(); + return { + subscribe: jest.fn().mockImplementation(({ next }) => { + next({ + isRunning: false, + isPartial: false, + inspect: { + dsl: [], + response: [], + }, + edges: mockEvents.map((item) => ({ node: item })), + pageInfo: { + activePage: 0, + totalPages: 10, + }, + rawResponse: {}, + totalCount: mockTimelineData.length, + }); + return { unsubscribe: jest.fn() }; + }), + }; + }), + }, + }, + notifications: { + toasts: { + addWarning: jest.fn(), + }, + }, + }, + }), +})); + +const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock; +jest.mock('../../common/utils/route/use_route_spy', () => ({ + useRouteSpy: jest.fn(), +})); + +mockUseRouteSpy.mockReturnValue([ + { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/overview', + }, +]); + +describe('useTimelineEvents', () => { + beforeEach(() => { + mockSearch.mockReset(); + }); + + const startDate: string = '2020-07-07T08:20:18.966Z'; + const endDate: string = '3000-01-01T00:00:00.000Z'; + const props: UseTimelineEventsProps = { + docValueFields: [], + endDate: '', + id: TimelineId.active, + indexNames: ['filebeat-*'], + fields: [], + filterQuery: '', + startDate: '', + limit: 25, + sort: initSortDefault, + skip: false, + }; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { + events: [], + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: -1, + updatedAt: 0, + }, + ]); + }); + }); + + test('happy path query', async () => { + await act(async () => { + const { result, waitForNextUpdate, rerender } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitForNextUpdate(); + + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(result.current).toEqual([ + false, + { + events: mockEvents, + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: 31, + updatedAt: result.current[1].updatedAt, + }, + ]); + }); + }); + + test('Mock cache for active timeline when switching page', async () => { + await act(async () => { + const { result, waitForNextUpdate, rerender } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitForNextUpdate(); + + mockUseRouteSpy.mockReturnValue([ + { + pageName: SecurityPageName.timelines, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/timelines', + }, + ]); + + expect(mockSearch).toHaveBeenCalledTimes(1); + + expect(result.current).toEqual([ + false, + { + events: mockEvents, + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: 31, + updatedAt: result.current[1].updatedAt, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 65f8a3dc78e4db..5f92596f033114 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -30,6 +30,9 @@ import { } from '../../../common/search_strategy'; import { InspectResponse } from '../../types'; import * as i18n from './translations'; +import { TimelineId } from '../../../common/types/timeline'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; +import { activeTimeline } from './active_timeline_context'; export interface TimelineArgs { events: TimelineItem[]; @@ -44,7 +47,7 @@ export interface TimelineArgs { type LoadPage = (newActivePage: number) => void; -interface UseTimelineEventsProps { +export interface UseTimelineEventsProps { docValueFields?: DocValueFields[]; filterQuery?: ESQuery | string; skip?: boolean; @@ -55,17 +58,26 @@ interface UseTimelineEventsProps { limit: number; sort: SortField; startDate: string; + timerangeKind?: 'absolute' | 'relative'; } const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] => timelineEdges.map((e: TimelineEdges) => e.node); const ID = 'timelineEventsQuery'; -const initSortDefault = { +export const initSortDefault = { field: '@timestamp', direction: Direction.asc, }; +function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + export const useTimelineEvents = ({ docValueFields, endDate, @@ -77,13 +89,17 @@ export const useTimelineEvents = ({ limit, sort = initSortDefault, skip = false, + timerangeKind, }: UseTimelineEventsProps): [boolean, TimelineArgs] => { + const [{ pageName }] = useRouteSpy(); const dispatch = useDispatch(); const { data, notifications } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [activePage, setActivePage] = useState(0); + const [activePage, setActivePage] = useState( + id === TimelineId.active ? activeTimeline.getActivePage() : 0 + ); const [timelineRequest, setTimelineRequest] = useState( !skip ? { @@ -106,6 +122,7 @@ export const useTimelineEvents = ({ } : null ); + const prevTimelineRequest = usePreviousRequest(timelineRequest); const clearSignalsState = useCallback(() => { if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) { @@ -117,18 +134,31 @@ export const useTimelineEvents = ({ const wrappedLoadPage = useCallback( (newActivePage: number) => { clearSignalsState(); + + if (id === TimelineId.active) { + activeTimeline.setExpandedEventIds({}); + activeTimeline.setActivePage(newActivePage); + } + setActivePage(newActivePage); }, - [clearSignalsState] + [clearSignalsState, id] ); + const refetchGrid = useCallback(() => { + if (refetch.current != null) { + refetch.current(); + } + wrappedLoadPage(0); + }, [wrappedLoadPage]); + const [timelineResponse, setTimelineResponse] = useState({ - id: ID, + id, inspect: { dsl: [], response: [], }, - refetch: refetch.current, + refetch: refetchGrid, totalCount: -1, pageInfo: { activePage: 0, @@ -141,15 +171,13 @@ export const useTimelineEvents = ({ const timelineSearch = useCallback( (request: TimelineEventsAllRequestOptions | null) => { - if (request == null) { + if (request == null || pageName === '') { return; } - let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - const searchSubscription$ = data.search .search(request, { strategy: 'securitySolutionTimelineSearchStrategy', @@ -157,26 +185,39 @@ export const useTimelineEvents = ({ }) .subscribe({ next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setTimelineResponse((prevResponse) => ({ - ...prevResponse, - events: getTimelineEvents(response.edges), - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - refetch: refetch.current, - totalCount: response.totalCount, - updatedAt: Date.now(), - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); + try { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + + setTimelineResponse((prevResponse) => { + const newTimelineResponse = { + ...prevResponse, + events: getTimelineEvents(response.edges), + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + updatedAt: Date.now(), + }; + if (id === TimelineId.active) { + activeTimeline.setExpandedEventIds({}); + activeTimeline.setPageName(pageName); + activeTimeline.setRequest(request); + activeTimeline.setResponse(newTimelineResponse); + } + return newTimelineResponse; + }); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS); + searchSubscription$.unsubscribe(); } + } catch { notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS); - searchSubscription$.unsubscribe(); } }, error: (msg) => { @@ -189,15 +230,43 @@ export const useTimelineEvents = ({ }, }); }; + + if ( + id === TimelineId.active && + activeTimeline.getPageName() !== '' && + pageName !== activeTimeline.getPageName() + ) { + activeTimeline.setPageName(pageName); + + abortCtrl.current.abort(); + setLoading(false); + refetch.current = asyncSearch.bind(null, activeTimeline.getRequest()); + setTimelineResponse((prevResp) => { + const resp = activeTimeline.getResponse(); + if (resp != null) { + return { + ...resp, + refetch: refetchGrid, + loadPage: wrappedLoadPage, + }; + } + return prevResp; + }); + if (activeTimeline.getResponse() != null) { + return; + } + } + abortCtrl.current.abort(); asyncSearch(); refetch.current = asyncSearch; + return () => { didCancel = true; abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, id, notifications.toasts, pageName, refetchGrid, wrappedLoadPage] ); useEffect(() => { @@ -251,8 +320,10 @@ export const useTimelineEvents = ({ if (activePage !== newActivePage) { setActivePage(newActivePage); + if (id === TimelineId.active) { + activeTimeline.setActivePage(newActivePage); + } } - if ( !skip && !skipQueryForDetectionsPage(id, indexNames) && @@ -263,12 +334,13 @@ export const useTimelineEvents = ({ return prevRequest; }); }, [ + dispatch, + indexNames, activePage, docValueFields, endDate, filterQuery, id, - indexNames, limit, startDate, sort, @@ -277,8 +349,13 @@ export const useTimelineEvents = ({ ]); useEffect(() => { - timelineSearch(timelineRequest); - }, [timelineRequest, timelineSearch]); + if ( + id !== TimelineId.active || + timerangeKind === 'absolute' || + !deepEqual(prevTimelineRequest, timelineRequest) + ) + timelineSearch(timelineRequest); + }, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]); return [loading, timelineResponse]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 1992b1f88f0641..d6597df71526f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -102,6 +102,7 @@ describe('epicLocalStorage', () => { status: TimelineStatus.active, sort, timelineType: TimelineType.default, + timerangeKind: 'absolute', toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 30d0796443ab50..d4e807b4a9a073 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -26,12 +26,14 @@ import { TimelineTypeLiteral, TimelineType, RowRendererId, + TimelineId, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model'; import { TimelineById } from './types'; +import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -113,6 +115,17 @@ interface AddTimelineParams { timelineById: TimelineById; } +export const shouldResetActiveTimelineContext = ( + id: string, + oldTimeline: TimelineModel, + newTimeline: TimelineModel +) => { + if (id === TimelineId.active && oldTimeline.savedObjectId !== newTimeline.savedObjectId) { + return true; + } + return false; +}; + /** * Add a saved object timeline to the store * and default the value to what need to be if values are null @@ -121,13 +134,19 @@ export const addTimelineToStore = ({ id, timeline, timelineById, -}: AddTimelineParams): TimelineById => ({ - ...timelineById, - [id]: { - ...timeline, - isLoading: timelineById[id].isLoading, - }, -}); +}: AddTimelineParams): TimelineById => { + if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { + activeTimeline.setActivePage(0); + activeTimeline.setExpandedEventIds({}); + } + return { + ...timelineById, + [id]: { + ...timeline, + isLoading: timelineById[id].isLoading, + }, + }; +}; interface AddNewTimelineParams { columns: ColumnHeaderOptions[]; From 1ecd12cdf32ef41a370af7064467dd3047529f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 5 Nov 2020 19:50:50 -0500 Subject: [PATCH 13/20] Add description and documentation link in alert flyout (#81526) * Add description and documentation URL in alert flyout * Add unit tests * Fix type check * Add horizontal rule * Design fixes * Fix uptime alert link * Fix uptime urls * Add anchor tag * Fix jest test failures * Fix monitoring links --- .../public/alert_types/always_firing.tsx | 1 + .../public/alert_types/astros.tsx | 1 + .../alerting/register_apm_alerts.ts | 12 +++++++ .../infra/public/alerting/inventory/index.ts | 3 ++ .../log_threshold/log_threshold_alert_type.ts | 3 ++ .../public/alerting/metric_threshold/index.ts | 3 ++ .../cpu_usage_alert/cpu_usage_alert.tsx | 3 ++ .../public/alerts/disk_usage_alert/index.tsx | 3 ++ .../alerts/legacy_alert/legacy_alert.tsx | 3 ++ .../alerts/memory_usage_alert/index.tsx | 3 ++ .../missing_monitoring_data_alert.tsx | 3 ++ .../thread_pool_rejections_alert/index.tsx | 3 ++ .../geo_threshold/index.ts | 2 ++ .../threshold/expression.tsx | 1 - .../builtin_alert_types/threshold/index.ts | 3 ++ .../sections/alert_form/alert_add.test.tsx | 1 + .../sections/alert_form/alert_edit.test.tsx | 1 + .../sections/alert_form/alert_form.test.tsx | 36 ++++++++++++++++++- .../sections/alert_form/alert_form.tsx | 29 +++++++++++++++ .../components/alerts_list.test.tsx | 1 + .../public/application/type_registry.test.ts | 1 + .../triggers_actions_ui/public/types.ts | 1 + .../__tests__/monitor_status.test.ts | 1 + .../lib/alert_types/duration_anomaly.tsx | 3 ++ .../public/lib/alert_types/monitor_status.tsx | 3 ++ .../uptime/public/lib/alert_types/tls.tsx | 3 ++ .../fixtures/plugins/alerts/public/plugin.ts | 2 ++ 27 files changed, 127 insertions(+), 2 deletions(-) diff --git a/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx b/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx index 9c420f4425d04e..a5d158fca836b3 100644 --- a/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx @@ -22,6 +22,7 @@ export function getAlertType(): AlertTypeModel { name: 'Always Fires', description: 'Alert when called', iconClass: 'bolt', + documentationUrl: null, alertParamsExpression: AlwaysFiringExpression, validate: (alertParams: AlwaysFiringParamsProps['alertParams']) => { const { instances } = alertParams; diff --git a/x-pack/examples/alerting_example/public/alert_types/astros.tsx b/x-pack/examples/alerting_example/public/alert_types/astros.tsx index 343f6b10ef85bb..73c7dfea1263bb 100644 --- a/x-pack/examples/alerting_example/public/alert_types/astros.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/astros.tsx @@ -47,6 +47,7 @@ export function getAlertType(): AlertTypeModel { name: 'People Are In Space Right Now', description: 'Alert when people are in space right now', iconClass: 'globe', + documentationUrl: null, alertParamsExpression: PeopleinSpaceExpression, validate: (alertParams: PeopleinSpaceParamsProps['alertParams']) => { const { outerSpaceCapacity, craft, op } = alertParams; diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 0eeb31927b2f59..988e335af5b7cc 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -22,6 +22,9 @@ export function registerApmAlerts( 'Alert when the number of errors in a service exceeds a defined threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), validate: () => ({ errors: [], @@ -53,6 +56,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionDurationAlertTrigger') ), @@ -87,6 +93,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionErrorRateAlertTrigger') ), @@ -121,6 +130,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionDurationAnomalyAlertTrigger') ), diff --git a/x-pack/plugins/infra/public/alerting/inventory/index.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts index b49465db051356..d7afd73c0e3a71 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/index.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -21,6 +21,9 @@ export function createInventoryMetricAlertType(): AlertTypeModel { defaultMessage: 'Alert when the inventory exceeds a defined threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/infrastructure-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts index 2e4cb2a53b6b58..60c22c42c00b6f 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts @@ -19,6 +19,9 @@ export function getAlertType(): AlertTypeModel { defaultMessage: 'Alert when the log aggregation exceeds the threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/logs-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression_editor/editor')), validate: validateExpression, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts index a48837792a3ccb..05c69e5ccb78bd 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts @@ -21,6 +21,9 @@ export function createMetricThresholdAlertType(): AlertTypeModel { defaultMessage: 'Alert when the metrics aggregation exceeds the threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/metrics-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx index 11ba8214ff81eb..5054c47245f0fc 100644 --- a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx @@ -16,6 +16,9 @@ export function createCpuUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_CPU_USAGE].label, description: ALERT_DETAILS[ALERT_CPU_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-cpu-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx index 7c44e37904ec50..00b70658e4289a 100644 --- a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx @@ -18,6 +18,9 @@ export function createDiskUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_DISK_USAGE].label, description: ALERT_DETAILS[ALERT_DISK_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-disk-usage-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx index ca7af2fe64e782..c8d0a7a5d49f2a 100644 --- a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx @@ -18,6 +18,9 @@ export function createLegacyAlertTypes(): AlertTypeModel[] { name: LEGACY_ALERT_DETAILS[legacyAlert].label, description: LEGACY_ALERT_DETAILS[legacyAlert].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/cluster-alerts.html`; + }, alertParamsExpression: () => ( diff --git a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx index 14fb7147179c13..062c32c7587942 100644 --- a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx @@ -18,6 +18,9 @@ export function createMemoryUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_MEMORY_USAGE].label, description: ALERT_DETAILS[ALERT_MEMORY_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-jvm-memory-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx index 4c8f00f8385c26..ec97a45a8a8005 100644 --- a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx @@ -16,6 +16,9 @@ export function createMissingMonitoringDataAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].label, description: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`; + }, alertParamsExpression: (props: any) => ( ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts index 9f33e2c2495c57..00d9ebbbbc0660 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts @@ -20,6 +20,8 @@ export function getAlertType(): AlertTypeModel import('./query_builder')), validate: validateExpression, requiresAppContext: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index 7c42c43dc79a2b..e309d97b57f341 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -281,7 +281,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ) : null} -
import('./expression')), validate: validateExpression, requiresAppContext: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 2a69580d7185c9..d66c5ba5121b83 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -99,6 +99,7 @@ describe('alert_add', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 34f9f29274f8f1..31c61f0bba7688 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -52,6 +52,7 @@ describe('alert_edit', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 98eaea64797b2d..4041f6f451a23c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -31,7 +31,8 @@ describe('alert_form', () => { id: 'my-alert-type', iconClass: 'test', name: 'test-alert', - description: 'test', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', validate: (): ValidationResult => { return { errors: {} }; }, @@ -59,6 +60,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'non edit alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -182,6 +184,22 @@ describe('alert_form', () => { ); expect(alertTypeSelectOptions.exists()).toBeFalsy(); }); + + it('renders alert type description', async () => { + await setup(); + wrapper.find('[data-test-subj="my-alert-type-SelectOption"]').first().simulate('click'); + const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); + expect(alertDescription.exists()).toBeTruthy(); + expect(alertDescription.first().text()).toContain('Alert when testing'); + }); + + it('renders alert type documentation link', async () => { + await setup(); + wrapper.find('[data-test-subj="my-alert-type-SelectOption"]').first().simulate('click'); + const alertDocumentationLink = wrapper.find('[data-test-subj="alertDocumentationLink"]'); + expect(alertDocumentationLink.exists()).toBeTruthy(); + expect(alertDocumentationLink.first().prop('href')).toBe('https://localhost.local/docs'); + }); }); describe('alert_form create alert non alerting consumer and producer', () => { @@ -244,6 +262,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -255,6 +274,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -423,5 +443,19 @@ describe('alert_form', () => { const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); }); + + it('renders alert type description', async () => { + await setup(); + const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); + expect(alertDescription.exists()).toBeTruthy(); + expect(alertDescription.first().text()).toContain('Alert when testing'); + }); + + it('renders alert type documentation link', async () => { + await setup(); + const alertDocumentationLink = wrapper.find('[data-test-subj="alertDocumentationLink"]'); + expect(alertDocumentationLink.exists()).toBeTruthy(); + expect(alertDocumentationLink.first().prop('href')).toBe('https://localhost.local/docs'); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index bdc11fd543ee14..9a637ea750f815 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -25,6 +25,8 @@ import { EuiHorizontalRule, EuiLoadingSpinner, EuiEmptyPrompt, + EuiLink, + EuiText, } from '@elastic/eui'; import { some, filter, map, fold } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -247,6 +249,33 @@ export const AlertForm = ({ ) : null} + {alertTypeModel?.description && ( + + + + {alertTypeModel.description}  + {alertTypeModel?.documentationUrl && ( + + + + )} + + + + )} + {AlertParamsExpressionComponent ? ( }> { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 311f366df74e05..f875bcabdcde82 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -17,6 +17,7 @@ const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { name: name || 'Test alert type', description: 'Test description', iconClass: iconClass || 'icon', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index bf1ff26af42e2a..1a6b68080c9a4b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -176,6 +176,7 @@ export interface AlertTypeModel name: string | JSX.Element; description: string; iconClass: string; + documentationUrl: string | ((docLinks: DocLinksStart) => string) | null; validate: (alertParams: AlertParamsType) => ValidationResult; alertParamsExpression: | React.FunctionComponent diff --git a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts index 5106fcbc97bcd0..8da45276fa532f 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -204,6 +204,7 @@ describe('monitor status alert type', () => { "alertParamsExpression": [Function], "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}", "description": "Alert when a monitor is down or an availability threshold is breached.", + "documentationUrl": [Function], "iconClass": "uptimeApp", "id": "xpack.uptime.alerts.monitorStatus", "name": ({ id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html`; + }, alertParamsExpression: (params: unknown) => ( ), diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx index 4e3d9a3c6e0ac0..43aaa26d86642a 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -31,6 +31,9 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({ ), description, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html#_monitor_status_alerts`; + }, alertParamsExpression: (params: any) => ( ), diff --git a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx index 41ff08b0da97cd..83c4792e26f597 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx @@ -15,6 +15,9 @@ const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); export const initTlsAlertType: AlertTypeInitializer = ({ core, plugins }): AlertTypeModel => ({ id: CLIENT_ALERT_TYPES.TLS, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html#_tls_alerts`; + }, alertParamsExpression: (params: any) => ( ), diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts index c738ce0697f750..af4aedda06ef75 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts @@ -31,6 +31,7 @@ export class AlertingFixturePlugin implements Plugin React.createElement('div', null, 'Test Always Firing'), validate: () => { return { errors: {} }; @@ -43,6 +44,7 @@ export class AlertingFixturePlugin implements Plugin React.createElement('div', null, 'Test Noop'), validate: () => { return { errors: {} }; From d6200462c6ecd8d80d47291a3d0a9b7b85e56f68 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Thu, 5 Nov 2020 19:05:41 -0600 Subject: [PATCH 14/20] Add APM OSS README (#82754) --- docs/developer/plugin-list.asciidoc | 4 +--- src/plugins/apm_oss/README.asciidoc | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 src/plugins/apm_oss/README.asciidoc diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 9235fc1198b12a..b59545cbb85a64 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -28,9 +28,7 @@ allowing users to configure their advanced settings, also known as uiSettings within the code. -|{kib-repo}blob/{branch}/src/plugins/apm_oss[apmOss] -|WARNING: Missing README. - +|{kib-repo}blob/{branch}/src/plugins/apm_oss/README.asciidoc[apmOss] |{kib-repo}blob/{branch}/src/plugins/bfetch/README.md[bfetch] |bfetch allows to batch HTTP requests and streams responses back. diff --git a/src/plugins/apm_oss/README.asciidoc b/src/plugins/apm_oss/README.asciidoc new file mode 100644 index 00000000000000..c3c060a99ee272 --- /dev/null +++ b/src/plugins/apm_oss/README.asciidoc @@ -0,0 +1,5 @@ +# APM OSS plugin + +OSS plugin for APM. Includes index configuration and tutorial resources. + +See <<../../x-pack/plugins/apm/readme.md,the X-Pack APM plugin README>> for information about the main APM plugin. From e378555971afeac14bead8949da95389144bafe5 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 5 Nov 2020 21:25:57 -0700 Subject: [PATCH 15/20] Revert "Adds cloud links to user popover (#66825)" (#82802) This reverts commit 8cdf56636aa5fd7453922714cd0ce01040d103d4. --- x-pack/plugins/cloud/kibana.json | 2 +- x-pack/plugins/cloud/public/index.ts | 2 +- x-pack/plugins/cloud/public/mocks.ts | 18 --- x-pack/plugins/cloud/public/plugin.ts | 28 +--- .../plugins/cloud/public/user_menu_links.ts | 38 ----- x-pack/plugins/cloud/server/config.ts | 2 - x-pack/plugins/security/public/index.ts | 1 - x-pack/plugins/security/public/mocks.ts | 7 - .../security/public/nav_control/index.mock.ts | 14 -- .../security/public/nav_control/index.ts | 3 +- .../nav_control/nav_control_component.scss | 11 -- .../nav_control_component.test.tsx | 38 ----- .../nav_control/nav_control_component.tsx | 139 ++++++------------ .../nav_control/nav_control_service.tsx | 39 +---- .../plugins/security/public/plugin.test.tsx | 7 +- x-pack/plugins/security/public/plugin.tsx | 4 +- 16 files changed, 61 insertions(+), 292 deletions(-) delete mode 100644 x-pack/plugins/cloud/public/mocks.ts delete mode 100644 x-pack/plugins/cloud/public/user_menu_links.ts delete mode 100644 x-pack/plugins/security/public/nav_control/index.mock.ts delete mode 100644 x-pack/plugins/security/public/nav_control/nav_control_component.scss diff --git a/x-pack/plugins/cloud/kibana.json b/x-pack/plugins/cloud/kibana.json index 9bca2f30bd23cf..27b35bcbdd88b9 100644 --- a/x-pack/plugins/cloud/kibana.json +++ b/x-pack/plugins/cloud/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "cloud"], - "optionalPlugins": ["usageCollection", "home", "security"], + "optionalPlugins": ["usageCollection", "home"], "server": true, "ui": true } diff --git a/x-pack/plugins/cloud/public/index.ts b/x-pack/plugins/cloud/public/index.ts index 680b2f1ad2bd65..39ef5f452c18b8 100644 --- a/x-pack/plugins/cloud/public/index.ts +++ b/x-pack/plugins/cloud/public/index.ts @@ -7,7 +7,7 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { CloudPlugin } from './plugin'; -export { CloudSetup, CloudConfigType } from './plugin'; +export { CloudSetup } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new CloudPlugin(initializerContext); } diff --git a/x-pack/plugins/cloud/public/mocks.ts b/x-pack/plugins/cloud/public/mocks.ts deleted file mode 100644 index bafebbca4ecdd8..00000000000000 --- a/x-pack/plugins/cloud/public/mocks.ts +++ /dev/null @@ -1,18 +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. - */ - -function createSetupMock() { - return { - cloudId: 'mock-cloud-id', - isCloudEnabled: true, - resetPasswordUrl: 'reset-password-url', - accountUrl: 'account-url', - }; -} - -export const cloudMock = { - createSetup: createSetupMock, -}; diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index bc410b89c30e7a..45005f3f5e4227 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -6,51 +6,40 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { i18n } from '@kbn/i18n'; -import { SecurityPluginStart } from '../../security/public'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; -import { createUserMenuLinks } from './user_menu_links'; -export interface CloudConfigType { +interface CloudConfigType { id?: string; resetPasswordUrl?: string; deploymentUrl?: string; - accountUrl?: string; } interface CloudSetupDependencies { home?: HomePublicPluginSetup; } -interface CloudStartDependencies { - security?: SecurityPluginStart; -} - export interface CloudSetup { cloudId?: string; cloudDeploymentUrl?: string; isCloudEnabled: boolean; - resetPasswordUrl?: string; - accountUrl?: string; } export class CloudPlugin implements Plugin { private config!: CloudConfigType; - private isCloudEnabled: boolean; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); - this.isCloudEnabled = false; } public async setup(core: CoreSetup, { home }: CloudSetupDependencies) { const { id, resetPasswordUrl, deploymentUrl } = this.config; - this.isCloudEnabled = getIsCloudEnabled(id); + const isCloudEnabled = getIsCloudEnabled(id); if (home) { - home.environment.update({ cloud: this.isCloudEnabled }); - if (this.isCloudEnabled) { + home.environment.update({ cloud: isCloudEnabled }); + if (isCloudEnabled) { home.tutorials.setVariable('cloud', { id, resetPasswordUrl }); } } @@ -58,11 +47,11 @@ export class CloudPlugin implements Plugin { return { cloudId: id, cloudDeploymentUrl: deploymentUrl, - isCloudEnabled: this.isCloudEnabled, + isCloudEnabled, }; } - public start(coreStart: CoreStart, { security }: CloudStartDependencies) { + public start(coreStart: CoreStart) { const { deploymentUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); if (deploymentUrl) { @@ -74,10 +63,5 @@ export class CloudPlugin implements Plugin { href: deploymentUrl, }); } - - if (security && this.isCloudEnabled) { - const userMenuLinks = createUserMenuLinks(this.config); - security.navControlService.addUserMenuLinks(userMenuLinks); - } } } diff --git a/x-pack/plugins/cloud/public/user_menu_links.ts b/x-pack/plugins/cloud/public/user_menu_links.ts deleted file mode 100644 index 15e2f14e885ba2..00000000000000 --- a/x-pack/plugins/cloud/public/user_menu_links.ts +++ /dev/null @@ -1,38 +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 { i18n } from '@kbn/i18n'; -import { UserMenuLink } from '../../security/public'; -import { CloudConfigType } from '.'; - -export const createUserMenuLinks = (config: CloudConfigType): UserMenuLink[] => { - const { resetPasswordUrl, accountUrl } = config; - const userMenuLinks = [] as UserMenuLink[]; - - if (resetPasswordUrl) { - userMenuLinks.push({ - label: i18n.translate('xpack.cloud.userMenuLinks.profileLinkText', { - defaultMessage: 'Cloud profile', - }), - iconType: 'logoCloud', - href: resetPasswordUrl, - order: 100, - }); - } - - if (accountUrl) { - userMenuLinks.push({ - label: i18n.translate('xpack.cloud.userMenuLinks.accountLinkText', { - defaultMessage: 'Account & Billing', - }), - iconType: 'gear', - href: accountUrl, - order: 200, - }); - } - - return userMenuLinks; -}; diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index eaa4ab7a482dd6..ff8a2c5acdf9ab 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -23,7 +23,6 @@ const configSchema = schema.object({ apm: schema.maybe(apmConfigSchema), resetPasswordUrl: schema.maybe(schema.string()), deploymentUrl: schema.maybe(schema.string()), - accountUrl: schema.maybe(schema.string()), }); export type CloudConfigType = TypeOf; @@ -33,7 +32,6 @@ export const config: PluginConfigDescriptor = { id: true, resetPasswordUrl: true, deploymentUrl: true, - accountUrl: true, }, schema: configSchema, }; diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index d0382c22ed3c67..8016c942240601 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -16,7 +16,6 @@ import { export { SecurityPluginSetup, SecurityPluginStart }; export { AuthenticatedUser } from '../common/model'; export { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; -export { UserMenuLink } from '../public/nav_control'; export const plugin: PluginInitializer< SecurityPluginSetup, diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index 26a759ca522679..33c1d1446afba2 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -7,7 +7,6 @@ import { authenticationMock } from './authentication/index.mock'; import { createSessionTimeoutMock } from './session/session_timeout.mock'; import { licenseMock } from '../common/licensing/index.mock'; -import { navControlServiceMock } from './nav_control/index.mock'; function createSetupMock() { return { @@ -16,13 +15,7 @@ function createSetupMock() { license: licenseMock.create(), }; } -function createStartMock() { - return { - navControlService: navControlServiceMock.createStart(), - }; -} export const securityMock = { createSetup: createSetupMock, - createStart: createStartMock, }; diff --git a/x-pack/plugins/security/public/nav_control/index.mock.ts b/x-pack/plugins/security/public/nav_control/index.mock.ts deleted file mode 100644 index 1cd10810d7c8f1..00000000000000 --- a/x-pack/plugins/security/public/nav_control/index.mock.ts +++ /dev/null @@ -1,14 +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 { SecurityNavControlServiceStart } from '.'; - -export const navControlServiceMock = { - createStart: (): jest.Mocked => ({ - getUserMenuLinks$: jest.fn(), - addUserMenuLinks: jest.fn(), - }), -}; diff --git a/x-pack/plugins/security/public/nav_control/index.ts b/x-pack/plugins/security/public/nav_control/index.ts index 737ae500546987..2b0af1a45d05a9 100644 --- a/x-pack/plugins/security/public/nav_control/index.ts +++ b/x-pack/plugins/security/public/nav_control/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SecurityNavControlService, SecurityNavControlServiceStart } from './nav_control_service'; -export { UserMenuLink } from './nav_control_component'; +export { SecurityNavControlService } from './nav_control_service'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.scss b/x-pack/plugins/security/public/nav_control/nav_control_component.scss deleted file mode 100644 index a3e04b08cfac20..00000000000000 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.scss +++ /dev/null @@ -1,11 +0,0 @@ -.chrNavControl__userMenu { - .euiContextMenuPanelTitle { - // Uppercased by default, override to match actual username - text-transform: none; - } - - .euiContextMenuItem { - // Temp fix for EUI issue https://github.com/elastic/eui/issues/3092 - line-height: normal; - } -} \ No newline at end of file diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 1da91e80d062de..c1c6a9f69b6ec9 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { BehaviorSubject } from 'rxjs'; import { shallowWithIntl, nextTick, mountWithIntl } from 'test_utils/enzyme_helpers'; import { SecurityNavControl } from './nav_control_component'; import { AuthenticatedUser } from '../../common/model'; @@ -18,7 +17,6 @@ describe('SecurityNavControl', () => { user: new Promise(() => {}) as Promise, editProfileUrl: '', logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -44,7 +42,6 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -73,7 +70,6 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -95,7 +91,6 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -112,37 +107,4 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); }); - - it('renders a popover with additional user menu links registered by other plugins', async () => { - const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise, - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); - }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c22308fa8a43e0..3ddabb0dc55f8c 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -7,52 +7,38 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { Observable, Subscription } from 'rxjs'; + import { EuiAvatar, + EuiFlexGroup, + EuiFlexItem, EuiHeaderSectionItemButton, + EuiLink, + EuiText, + EuiSpacer, EuiPopover, EuiLoadingSpinner, - EuiIcon, - EuiContextMenu, - EuiContextMenuPanelItemDescriptor, - IconType, - EuiText, } from '@elastic/eui'; import { AuthenticatedUser } from '../../common/model'; -import './nav_control_component.scss'; - -export interface UserMenuLink { - label: string; - iconType: IconType; - href: string; - order?: number; -} - interface Props { user: Promise; editProfileUrl: string; logoutUrl: string; - userMenuLinks$: Observable; } interface State { isOpen: boolean; authenticatedUser: AuthenticatedUser | null; - userMenuLinks: UserMenuLink[]; } export class SecurityNavControl extends Component { - private subscription?: Subscription; - constructor(props: Props) { super(props); this.state = { isOpen: false, authenticatedUser: null, - userMenuLinks: [], }; props.user.then((authenticatedUser) => { @@ -62,18 +48,6 @@ export class SecurityNavControl extends Component { }); } - componentDidMount() { - this.subscription = this.props.userMenuLinks$.subscribe(async (userMenuLinks) => { - this.setState({ userMenuLinks }); - }); - } - - componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - onMenuButtonClick = () => { if (!this.state.authenticatedUser) { return; @@ -92,13 +66,13 @@ export class SecurityNavControl extends Component { render() { const { editProfileUrl, logoutUrl } = this.props; - const { authenticatedUser, userMenuLinks } = this.state; + const { authenticatedUser } = this.state; - const username = + const name = (authenticatedUser && (authenticatedUser.full_name || authenticatedUser.username)) || ''; const buttonContents = authenticatedUser ? ( - + ) : ( ); @@ -118,60 +92,6 @@ export class SecurityNavControl extends Component { ); - const profileMenuItem = { - name: ( - - ), - icon: , - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }; - - const logoutMenuItem = { - name: ( - - ), - icon: , - href: logoutUrl, - 'data-test-subj': 'logoutLink', - }; - - const items: EuiContextMenuPanelItemDescriptor[] = []; - - items.push(profileMenuItem); - - if (userMenuLinks.length) { - const userMenuLinkMenuItems = userMenuLinks - .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href }: UserMenuLink) => ({ - name: {label}, - icon: , - href, - 'data-test-subj': `userMenuLink__${label}`, - })); - - items.push(...userMenuLinkMenuItems, { - isSeparator: true, - key: 'securityNavControlComponent__userMenuLinksSeparator', - }); - } - - items.push(logoutMenuItem); - - const panels = [ - { - id: 0, - title: username, - items, - }, - ]; - return ( { repositionOnScroll closePopover={this.closeMenu} panelPaddingSize="none" - buffer={0} > -
- +
+ + + + + + + +

{name}

+
+ + + + + + + + + + + + + + + + + + + + +
+
); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 4ae64d667ce293..aa3ec2e47469d0 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -4,16 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sortBy } from 'lodash'; -import { Observable, Subscription, BehaviorSubject, ReplaySubject } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; import { CoreStart } from 'src/core/public'; - import ReactDOM from 'react-dom'; import React from 'react'; - import { SecurityLicense } from '../../common/licensing'; -import { SecurityNavControl, UserMenuLink } from './nav_control_component'; +import { SecurityNavControl } from './nav_control_component'; import { AuthenticationServiceSetup } from '../authentication'; interface SetupDeps { @@ -26,18 +22,6 @@ interface StartDeps { core: CoreStart; } -export interface SecurityNavControlServiceStart { - /** - * Returns an Observable of the array of user menu links registered by other plugins - */ - getUserMenuLinks$: () => Observable; - - /** - * Registers the provided user menu links to be displayed in the user menu in the global nav - */ - addUserMenuLinks: (newUserMenuLink: UserMenuLink[]) => void; -} - export class SecurityNavControlService { private securityLicense!: SecurityLicense; private authc!: AuthenticationServiceSetup; @@ -47,16 +31,13 @@ export class SecurityNavControlService { private securityFeaturesSubscription?: Subscription; - private readonly stop$ = new ReplaySubject(1); - private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { this.securityLicense = securityLicense; this.authc = authc; this.logoutUrl = logoutUrl; } - public start({ core }: StartDeps): SecurityNavControlServiceStart { + public start({ core }: StartDeps) { this.securityFeaturesSubscription = this.securityLicense.features$.subscribe( ({ showLinks }) => { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); @@ -68,14 +49,6 @@ export class SecurityNavControlService { } } ); - - return { - getUserMenuLinks$: () => - this.userMenuLinks$.pipe(map(this.sortUserMenuLinks), takeUntil(this.stop$)), - addUserMenuLinks: (userMenuLink: UserMenuLink[]) => { - this.userMenuLinks$.next(userMenuLink); - }, - }; } public stop() { @@ -84,7 +57,6 @@ export class SecurityNavControlService { this.securityFeaturesSubscription = undefined; } this.navControlRegistered = false; - this.stop$.next(); } private registerSecurityNavControl( @@ -100,7 +72,6 @@ export class SecurityNavControlService { user: currentUserPromise, editProfileUrl: core.http.basePath.prepend('/security/account'), logoutUrl: this.logoutUrl, - userMenuLinks$: this.userMenuLinks$, }; ReactDOM.render( @@ -115,8 +86,4 @@ export class SecurityNavControlService { this.navControlRegistered = true; } - - private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) { - return sortBy(userMenuLinks, 'order'); - } } diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 6f5a2a031a7b22..d86d4812af5e3f 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -97,12 +97,7 @@ describe('Security Plugin', () => { data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }) - ).toEqual({ - navControlService: { - getUserMenuLinks$: expect.any(Function), - addUserMenuLinks: expect.any(Function), - }, - }); + ).toBeUndefined(); }); it('starts Management Service if `management` plugin is available', () => { diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index f94772c43dd896..700653c4cecb8e 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -146,13 +146,11 @@ export class SecurityPlugin public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) { this.sessionTimeout.start(); + this.navControlService.start({ core }); this.securityCheckupService.start({ securityOssStart: securityOss, docLinks: core.docLinks }); - if (management) { this.managementService.start({ capabilities: core.application.capabilities }); } - - return { navControlService: this.navControlService.start({ core }) }; } public stop() { From 0faf8c24eec2906363c4538f6dd1060aba15b2d3 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Fri, 6 Nov 2020 11:34:57 +0300 Subject: [PATCH 16/20] Use monacco editor in the inspector request panel (#82272) * Use monacco editor in the inspector request panel Closes: #81921 * insRequestCodeViewer -> insRequestCodeViewer * remove uiSettings from props * fix functional tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/inspector/public/plugin.tsx | 11 +- .../inspector_panel.test.tsx.snap | 361 +++++++++--------- .../inspector/public/ui/inspector_panel.scss | 12 +- .../public/ui/inspector_panel.test.tsx | 10 +- .../inspector/public/ui/inspector_panel.tsx | 32 +- .../__snapshots__/data_view.test.tsx.snap | 243 ++---------- .../views/data/components/data_view.test.tsx | 14 +- .../views/data/components/data_view.tsx | 18 +- .../public/views/data/{index.tsx => index.ts} | 16 +- .../components/details/req_code_viewer.tsx | 82 ++++ .../details/req_details_request.tsx | 13 +- .../details/req_details_response.tsx | 13 +- .../requests/components/requests_view.tsx | 4 + .../inspector/public/views/requests/index.ts | 4 +- test/functional/page_objects/tile_map_page.ts | 4 +- test/functional/services/inspector.ts | 13 + .../maps/documents_source/docvalue_fields.js | 2 +- 17 files changed, 402 insertions(+), 450 deletions(-) rename src/plugins/inspector/public/views/data/{index.tsx => index.ts} (72%) create mode 100644 src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx diff --git a/src/plugins/inspector/public/plugin.tsx b/src/plugins/inspector/public/plugin.tsx index f906dbcab80439..07ef7c8fbab0d4 100644 --- a/src/plugins/inspector/public/plugin.tsx +++ b/src/plugins/inspector/public/plugin.tsx @@ -70,7 +70,7 @@ export class InspectorPublicPlugin implements Plugin { public async setup(core: CoreSetup) { this.views = new InspectorViewRegistry(); - this.views.register(getDataViewDescription(core.uiSettings)); + this.views.register(getDataViewDescription()); this.views.register(getRequestsViewDescription()); return { @@ -101,7 +101,14 @@ export class InspectorPublicPlugin implements Plugin { } return core.overlays.openFlyout( - toMountPoint(), + toMountPoint( + + ), { 'data-test-subj': 'inspectorPanel', closeButtonAriaLabel: closeButtonLabel, diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 709c0bfe69f0bd..7fb00fe8d40c41 100644 --- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -10,6 +10,11 @@ exports[`InspectorPanel should render as expected 1`] = ` }, } } + dependencies={ + Object { + "uiSettings": Object {}, + } + } intl={ Object { "defaultFormats": Object {}, @@ -135,216 +140,228 @@ exports[`InspectorPanel should render as expected 1`] = ` ] } > - -
- -
- -
- -

- Inspector -

-
-
-
- -
+ Inspector +
+
+
+ + - + - - - + } + } + views={ + Array [ + Object { + "component": [Function], + "order": 200, + "title": "View 1", + }, + Object { + "component": [Function], + "order": 100, + "shouldShow": [Function], + "title": "Foo View", + }, + Object { + "component": [Function], + "order": 200, + "shouldShow": [Function], + "title": "Never", + }, + ] } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorViewChooser" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} > - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="inspectorViewChooser" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + repositionOnScroll={true} > -
- - - + + + +
-
- -
- - - - - - - - -
+ + +
+ + + + + +
- -

- View 1 -

-
+ } + > + +

+ View 1 +

+
+
+
- -
+
+ `; diff --git a/src/plugins/inspector/public/ui/inspector_panel.scss b/src/plugins/inspector/public/ui/inspector_panel.scss index ff0b491e1222b9..2a6cfed66e4ff8 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.scss +++ b/src/plugins/inspector/public/ui/inspector_panel.scss @@ -1,11 +1,15 @@ .insInspectorPanel__flyoutBody { - // TODO: EUI to allow for custom classNames to inner elements - // Or supply this as default - > div { + .euiFlyoutBody__overflowContent { + height: 100%; display: flex; + flex-wrap: nowrap; flex-direction: column; - > div { + >div { + flex-grow: 0; + } + + .insRequestCodeViewer { flex-grow: 1; } } diff --git a/src/plugins/inspector/public/ui/inspector_panel.test.tsx b/src/plugins/inspector/public/ui/inspector_panel.test.tsx index 23f698c23793b1..67e197abe7134e 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.test.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.test.tsx @@ -22,10 +22,12 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { InspectorPanel } from './inspector_panel'; import { InspectorViewDescription } from '../types'; import { Adapters } from '../../common'; +import type { IUiSettingsClient } from 'kibana/public'; describe('InspectorPanel', () => { let adapters: Adapters; let views: InspectorViewDescription[]; + const uiSettings: IUiSettingsClient = {} as IUiSettingsClient; beforeEach(() => { adapters = { @@ -62,12 +64,16 @@ describe('InspectorPanel', () => { }); it('should render as expected', () => { - const component = mountWithIntl(); + const component = mountWithIntl( + + ); expect(component).toMatchSnapshot(); }); it('should not allow updating adapters', () => { - const component = mountWithIntl(); + const component = mountWithIntl( + + ); adapters.notAllowed = {}; expect(() => component.setProps({ adapters })).toThrow(); }); diff --git a/src/plugins/inspector/public/ui/inspector_panel.tsx b/src/plugins/inspector/public/ui/inspector_panel.tsx index 37a51257112d63..dbad202953b0b5 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.tsx @@ -19,12 +19,21 @@ import './inspector_panel.scss'; import { i18n } from '@kbn/i18n'; -import React, { Component } from 'react'; +import React, { Component, Suspense } from 'react'; import PropTypes from 'prop-types'; -import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { IUiSettingsClient } from 'kibana/public'; import { InspectorViewDescription } from '../types'; import { Adapters } from '../../common'; import { InspectorViewChooser } from './inspector_view_chooser'; +import { KibanaContextProvider } from '../../../kibana_react/public'; function hasAdaptersChanged(oldAdapters: Adapters, newAdapters: Adapters) { return ( @@ -41,6 +50,9 @@ interface InspectorPanelProps { adapters: Adapters; title?: string; views: InspectorViewDescription[]; + dependencies: { + uiSettings: IUiSettingsClient; + }; } interface InspectorPanelState { @@ -95,19 +107,21 @@ export class InspectorPanel extends Component + }> + + ); } render() { - const { views, title } = this.props; + const { views, title, dependencies } = this.props; const { selectedView } = this.state; return ( - + @@ -127,7 +141,7 @@ export class InspectorPanel extends Component {this.renderSelectedPanel()} - + ); } } diff --git a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap index 2632afff2f63be..3bd3bb6531cc7f 100644 --- a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Inspector Data View component should render empty state 1`] = ` - - + `; exports[`Inspector Data View component should render loading state 1`] = ` - + loading + } intl={ Object { @@ -431,204 +439,9 @@ exports[`Inspector Data View component should render loading state 1`] = ` "timeZone": null, } } - title="Test Data" > - - -
- -
- -
- - - - - - - - - -
- - -
-

- - Gathering data - -

-
-
-
- -
- -
- - - +
+ loading +
+ `; diff --git a/src/plugins/inspector/public/views/data/components/data_view.test.tsx b/src/plugins/inspector/public/views/data/components/data_view.test.tsx index bd78bca42c4796..6a7f878ef807e8 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.test.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.test.tsx @@ -17,11 +17,10 @@ * under the License. */ -import React from 'react'; +import React, { Suspense } from 'react'; import { getDataViewDescription } from '../index'; import { DataAdapter } from '../../../../common/adapters/data'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { IUiSettingsClient } from '../../../../../../core/public'; jest.mock('../lib/export_csv', () => ({ exportAsCsv: jest.fn(), @@ -31,9 +30,7 @@ describe('Inspector Data View', () => { let DataView: any; beforeEach(() => { - const uiSettings = {} as IUiSettingsClient; - - DataView = getDataViewDescription(uiSettings); + DataView = getDataViewDescription(); }); it('should only show if data adapter is present', () => { @@ -51,7 +48,12 @@ describe('Inspector Data View', () => { }); it('should render loading state', () => { - const component = mountWithIntl(); // eslint-disable-line react/jsx-pascal-case + const DataViewComponent = DataView.component; + const component = mountWithIntl( + loading
}> + + + ); expect(component).toMatchSnapshot(); }); diff --git a/src/plugins/inspector/public/views/data/components/data_view.tsx b/src/plugins/inspector/public/views/data/components/data_view.tsx index 1a2b6f9922d2d0..100fa7787321ca 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.tsx @@ -38,6 +38,7 @@ import { TabularCallback, } from '../../../../common/adapters/data/types'; import { IUiSettingsClient } from '../../../../../../core/public'; +import { withKibana, KibanaReactContextValue } from '../../../../../kibana_react/public'; interface DataViewComponentState { tabularData: TabularData | null; @@ -47,20 +48,23 @@ interface DataViewComponentState { } interface DataViewComponentProps extends InspectorViewProps { - uiSettings: IUiSettingsClient; + kibana: KibanaReactContextValue<{ uiSettings: IUiSettingsClient }>; } -export class DataViewComponent extends Component { +class DataViewComponent extends Component { static propTypes = { - uiSettings: PropTypes.object.isRequired, adapters: PropTypes.object.isRequired, title: PropTypes.string.isRequired, + kibana: PropTypes.object, }; state = {} as DataViewComponentState; _isMounted = false; - static getDerivedStateFromProps(nextProps: InspectorViewProps, state: DataViewComponentState) { + static getDerivedStateFromProps( + nextProps: DataViewComponentProps, + state: DataViewComponentState + ) { if (state && nextProps.adapters === state.adapters) { return null; } @@ -172,8 +176,12 @@ export class DataViewComponent extends Component ); } } + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export default withKibana(DataViewComponent); diff --git a/src/plugins/inspector/public/views/data/index.tsx b/src/plugins/inspector/public/views/data/index.ts similarity index 72% rename from src/plugins/inspector/public/views/data/index.tsx rename to src/plugins/inspector/public/views/data/index.ts index b02e02bbe6b6b6..d201ad89022be9 100644 --- a/src/plugins/inspector/public/views/data/index.tsx +++ b/src/plugins/inspector/public/views/data/index.ts @@ -16,17 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; -import { DataViewComponent } from './components/data_view'; -import { InspectorViewDescription, InspectorViewProps } from '../../types'; +import { InspectorViewDescription } from '../../types'; import { Adapters } from '../../../common'; -import { IUiSettingsClient } from '../../../../../core/public'; -export const getDataViewDescription = ( - uiSettings: IUiSettingsClient -): InspectorViewDescription => ({ +const DataViewComponent = lazy(() => import('./components/data_view')); + +export const getDataViewDescription = (): InspectorViewDescription => ({ title: i18n.translate('inspector.data.dataTitle', { defaultMessage: 'Data', }), @@ -37,7 +35,5 @@ export const getDataViewDescription = ( shouldShow(adapters: Adapters) { return Boolean(adapters.data); }, - component: (props: InspectorViewProps) => ( - - ), + component: DataViewComponent, }); diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx new file mode 100644 index 00000000000000..71499d46071c87 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiFlexGroup, EuiCopy, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; + +import { CodeEditor } from '../../../../../../kibana_react/public'; + +interface RequestCodeViewerProps { + json: string; +} + +const copyToClipboardLabel = i18n.translate('inspector.requests.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +/** + * @internal + */ +export const RequestCodeViewer = ({ json }: RequestCodeViewerProps) => ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + options={{ + readOnly: true, + lineNumbers: 'off', + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + +
+); diff --git a/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx b/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx index d7cb8f57456138..47ed226c24a5ce 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx @@ -19,9 +19,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiCodeBlock } from '@elastic/eui'; import { Request } from '../../../../../common/adapters/request/types'; import { RequestDetailsProps } from '../types'; +import { RequestCodeViewer } from './req_code_viewer'; export class RequestDetailsRequest extends Component { static propTypes = { @@ -37,15 +37,6 @@ export class RequestDetailsRequest extends Component { return null; } - return ( - - {JSON.stringify(json, null, 2)} - - ); + return ; } } diff --git a/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx b/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx index 933495ff473961..5ad5cc0537adaa 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx @@ -19,9 +19,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiCodeBlock } from '@elastic/eui'; import { Request } from '../../../../../common/adapters/request/types'; import { RequestDetailsProps } from '../types'; +import { RequestCodeViewer } from './req_code_viewer'; export class RequestDetailsResponse extends Component { static propTypes = { @@ -40,15 +40,6 @@ export class RequestDetailsResponse extends Component { return null; } - return ( - - {JSON.stringify(responseJSON, null, 2)} - - ); + return ; } } diff --git a/src/plugins/inspector/public/views/requests/components/requests_view.tsx b/src/plugins/inspector/public/views/requests/components/requests_view.tsx index 13575de0c5064f..7762689daf4e68 100644 --- a/src/plugins/inspector/public/views/requests/components/requests_view.tsx +++ b/src/plugins/inspector/public/views/requests/components/requests_view.tsx @@ -175,3 +175,7 @@ export class RequestsViewComponent extends Component import('./components/requests_view')); + export const getRequestsViewDescription = (): InspectorViewDescription => ({ title: i18n.translate('inspector.requests.requestsTitle', { defaultMessage: 'Requests', diff --git a/test/functional/page_objects/tile_map_page.ts b/test/functional/page_objects/tile_map_page.ts index 609e6ebddd50ac..7881c9b1f7155c 100644 --- a/test/functional/page_objects/tile_map_page.ts +++ b/test/functional/page_objects/tile_map_page.ts @@ -50,12 +50,14 @@ export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderC await testSubjects.click('inspectorViewChooser'); await testSubjects.click('inspectorViewChooserRequests'); await testSubjects.click('inspectorRequestDetailRequest'); - return await testSubjects.getVisibleText('inspectorRequestBody'); + + return await inspector.getCodeEditorValue(); } public async getMapBounds(): Promise { const request = await this.getVisualizationRequest(); const requestObject = JSON.parse(request); + return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; } diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index 1c0bf7ad46df15..e256cf14541a7e 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -23,6 +23,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function InspectorProvider({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); + const browser = getService('browser'); const renderable = getService('renderable'); const flyout = getService('flyout'); const testSubjects = getService('testSubjects'); @@ -245,6 +246,18 @@ export function InspectorProvider({ getService }: FtrProviderContext) { public getOpenRequestDetailResponseButton() { return testSubjects.find('inspectorRequestDetailResponse'); } + + public async getCodeEditorValue() { + let request: string = ''; + + await retry.try(async () => { + request = await browser.execute( + () => (window as any).monaco.editor.getModels()[0].getValue() as string + ); + }); + + return request; + } } return new Inspector(); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index 4edee0a0b78ba8..a336ebc0d57dbb 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -26,7 +26,7 @@ export default function ({ getPageObjects, getService }) { await inspector.open(); await inspector.openInspectorRequestsView(); await testSubjects.click('inspectorRequestDetailResponse'); - const responseBody = await testSubjects.getVisibleText('inspectorResponseBody'); + const responseBody = await inspector.getCodeEditorValue(); await inspector.close(); return JSON.parse(responseBody); } From 1b65a674d0ee0478e7502262c7e74ee72afec585 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Fri, 6 Nov 2020 11:17:01 +0100 Subject: [PATCH 17/20] [Dashboard] Fix cloning panels reactive issue (#74253) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../actions/add_to_library_action.test.tsx | 21 +++----- .../actions/clone_panel_action.test.tsx | 7 ++- .../unlink_from_library_action.test.tsx | 20 ++----- .../embeddable/dashboard_container.test.tsx | 43 +++++++++++++++ .../embeddable/dashboard_container.tsx | 54 +++++++++---------- .../embeddable/grid/dashboard_grid.tsx | 3 ++ .../public/lib/containers/container.ts | 40 ++++++++++---- 7 files changed, 120 insertions(+), 68 deletions(-) diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index 650a273314412d..feb30b248c066f 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -134,19 +134,15 @@ test('Add to library is not compatible when embeddable is not in a dashboard con expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); -test('Add to library replaces embeddableId but retains panel count', async () => { +test('Add to library replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -162,15 +158,10 @@ test('Add to library returns reference type input', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput, }); - const dashboard = embeddable.getRoot() as IContainer; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toBeUndefined(); expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx index 193376ae97c0b0..25179fd7ccd387 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx @@ -108,7 +108,12 @@ test('Clone adds a new embeddable', async () => { ); expect(newPanelId).toBeDefined(); const newPanel = container.getInput().panels[newPanelId!]; - expect(newPanel.type).toEqual(embeddable.type); + expect(newPanel.type).toEqual('placeholder'); + // let the placeholder load + await dashboard.untilEmbeddableLoaded(newPanelId!); + // now wait for the full embeddable to replace it + const loadedPanel = await dashboard.untilEmbeddableLoaded(newPanelId!); + expect(loadedPanel.type).toEqual(embeddable.type); }); test('Clones an embeddable without a saved object ID', async () => { diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 4f668ec9ea04c9..f191be6f7baad3 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -132,19 +132,14 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); -test('Unlink replaces embeddableId but retains panel count', async () => { +test('Unlink replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -164,15 +159,10 @@ test('Unlink unwraps all attributes from savedObject', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, }); - const dashboard = embeddable.getRoot() as IContainer; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); }); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 89aacf2a84029c..caa8321d7b8b23 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -27,6 +27,7 @@ import { ContactCardEmbeddableInput, ContactCardEmbeddable, ContactCardEmbeddableOutput, + EMPTY_EMBEDDABLE, } from '../../embeddable_plugin_test_samples'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; @@ -100,6 +101,48 @@ test('DashboardContainer.addNewEmbeddable', async () => { expect(embeddableInContainer.id).toBe(embeddable.id); }); +test('DashboardContainer.replacePanel', async (done) => { + const ID = '123'; + const initialInput = getSampleDashboardInput({ + panels: { + [ID]: getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: ID }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, + }); + + const container = new DashboardContainer(initialInput, options); + let counter = 0; + + const subscriptionHandler = jest.fn(({ panels }) => { + counter++; + expect(panels[ID]).toBeDefined(); + // It should be called exactly 2 times and exit the second time + switch (counter) { + case 1: + return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); + + case 2: { + expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); + subscription.unsubscribe(); + done(); + } + + default: + throw Error('Called too many times!'); + } + }); + + const subscription = container.getInput$().subscribe(subscriptionHandler); + + // replace the panel now + container.replacePanel(container.getInput().panels[ID], { + type: EMPTY_EMBEDDABLE, + explicitInput: { id: ID }, + }); +}); + test('Container view mode change propagates to existing children', async () => { const initialInput = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 757488185fe8e5..051a7ef8bfb929 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -154,42 +154,43 @@ export class DashboardContainer extends Container) => - this.replacePanel(placeholderPanelState, newPanelState) - ); + + // wait until the placeholder is ready, then replace it with new panel + // this is useful as sometimes panels can load faster than the placeholder one (i.e. by value embeddables) + this.untilEmbeddableLoaded(originalPanelState.explicitInput.id) + .then(() => newStateComplete) + .then((newPanelState: Partial) => + this.replacePanel(placeholderPanelState, newPanelState) + ); } public replacePanel( previousPanelState: DashboardPanelState, newPanelState: Partial ) { - // TODO: In the current infrastructure, embeddables in a container do not react properly to - // changes. Removing the existing embeddable, and adding a new one is a temporary workaround - // until the container logic is fixed. - - const finalPanels = { ...this.input.panels }; - delete finalPanels[previousPanelState.explicitInput.id]; - const newPanelId = newPanelState.explicitInput?.id ? newPanelState.explicitInput.id : uuid.v4(); - finalPanels[newPanelId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: newPanelId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: newPanelId, + // Because the embeddable type can change, we have to operate at the container level here + return this.updateInput({ + panels: { + ...this.input.panels, + [previousPanelState.explicitInput.id]: { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + }, }, - }; - this.updateInput({ - panels: finalPanels, lastReloadRequestTime: new Date().getTime(), }); } @@ -201,16 +202,15 @@ export class DashboardContainer extends Container(type: string, explicitInput: Partial, embeddableId?: string) { const idToReplace = embeddableId || explicitInput.id; if (idToReplace && this.input.panels[idToReplace]) { - this.replacePanel(this.input.panels[idToReplace], { + return this.replacePanel(this.input.panels[idToReplace], { type, explicitInput: { ...explicitInput, - id: uuid.v4(), + id: idToReplace, }, }); - } else { - this.addNewEmbeddable(type, explicitInput); } + return this.addNewEmbeddable(type, explicitInput); } public render(dom: HTMLElement) { diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index d4d8fd0a4374b9..03c92d91a80ccb 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -265,6 +265,7 @@ class DashboardGridUi extends React.Component {
{ @@ -272,6 +273,8 @@ class DashboardGridUi extends React.Component { }} > this.maybeUpdateChildren()); + this.subscription = this.getInput$() + // At each update event, get both the previous and current state + .pipe(startWith(input), pairwise()) + .subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => { + this.maybeUpdateChildren(currentPanels, prevPanels); + }); } public updateInputForChild( @@ -329,16 +335,30 @@ export abstract class Container< return embeddable; } - private maybeUpdateChildren() { - const allIds = Object.keys({ ...this.input.panels, ...this.output.embeddableLoaded }); + private panelHasChanged(currentPanel: PanelState, prevPanel: PanelState) { + if (currentPanel.type !== prevPanel.type) { + return true; + } + } + + private maybeUpdateChildren( + currentPanels: TContainerInput['panels'], + prevPanels: TContainerInput['panels'] + ) { + const allIds = Object.keys({ ...currentPanels, ...this.output.embeddableLoaded }); allIds.forEach((id) => { - if (this.input.panels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) { - this.onPanelAdded(this.input.panels[id]); - } else if ( - this.input.panels[id] === undefined && - this.output.embeddableLoaded[id] !== undefined - ) { - this.onPanelRemoved(id); + if (currentPanels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) { + return this.onPanelAdded(currentPanels[id]); + } + if (currentPanels[id] === undefined && this.output.embeddableLoaded[id] !== undefined) { + return this.onPanelRemoved(id); + } + // In case of type change, remove and add a panel with the same id + if (currentPanels[id] && prevPanels[id]) { + if (this.panelHasChanged(currentPanels[id], prevPanels[id])) { + this.onPanelRemoved(id); + this.onPanelAdded(currentPanels[id]); + } } }); } From 814603455937e7cf4d9c9de0aedae0c8dabbcef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Fri, 6 Nov 2020 11:18:42 +0100 Subject: [PATCH 18/20] [Security Solution] Bump why-did-you-render (#82591) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 44a0c833eae278..1c218307b35c39 100644 --- a/package.json +++ b/package.json @@ -567,7 +567,7 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", - "@welldone-software/why-did-you-render": "^4.0.0", + "@welldone-software/why-did-you-render": "^5.0.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^1.0.4", "angular-aria": "^1.8.0", diff --git a/yarn.lock b/yarn.lock index 6ba53d0e4dd43e..b79e246b27851b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6242,10 +6242,10 @@ text-table "^0.2.0" webpack-log "^1.1.2" -"@welldone-software/why-did-you-render@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@welldone-software/why-did-you-render/-/why-did-you-render-4.0.0.tgz#cc98c996f5a06ea55bd07dc99ba4b4d68af93332" - integrity sha512-PjqriZ8Ak9biP2+kOcIrg+NwsFwWVhGV03Hm+ns84YBCArn+hWBKM9rMBEU6e62I1qyrYF2/G9yktNpEmfWfJA== +"@welldone-software/why-did-you-render@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@welldone-software/why-did-you-render/-/why-did-you-render-5.0.0.tgz#5dd8d20ad9f00fd500de852dd06eea0c057a0bce" + integrity sha512-A6xUP/55vJQwA1+L6iZbG81cQanSQQVR15yPcjLIp6lHmybXEOXsYcuXaDZHYqiNStZRzv64YPcYJC9wdphfhw== dependencies: lodash "^4" From b0eb2779838e339cc8c1d6a3562abf4299ad09eb Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 6 Nov 2020 11:57:12 +0100 Subject: [PATCH 19/20] Add steps to migrate from a legacy kibana index (#82161) * Add steps to migrate from a legacy kibana index * Clarify data loss from legacy index to alias with same name * Use aliases api to safely add a .kibana alias to a legacy index --- rfcs/text/0013_saved_object_migrations.md | 53 ++++++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index c5069625cb8a66..1a0967d110d06a 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -212,39 +212,68 @@ Note: If none of the aliases exists, this is a new Elasticsearch cluster and no migrations are necessary. Create the `.kibana_7.10.0_001` index with the following aliases: `.kibana_current` and `.kibana_7.10.0`. -2. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. +2. If the source is a < v6.5 `.kibana` index or < 7.4 `.kibana_task_manager` + index prepare the legacy index for a migration: + 1. Mark the legacy index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. + 2. Clone the legacy index into a new index which has writes enabled. Use a fixed index name i.e `.kibana_pre6.5.0_001` or `.kibana_task_manager_pre7.4.0_001`. `POST /.kibana/_clone/.kibana_pre6.5.0_001?wait_for_active_shards=all {"settings": {"index.blocks.write": false}}`. Ignore errors if the clone already exists. Ignore errors if the legacy source doesn't exist. + 3. Wait for the cloning to complete `GET /_cluster/health/.kibana_pre6.5.0_001?wait_for_status=green&timeout=60s` If cloning doesn’t complete within the 60s timeout, log a warning for visibility and poll again. + 4. Apply the `convertToAlias` script if defined `POST /.kibana_pre6.5.0_001/_update_by_query?conflicts=proceed {"script": {...}}`. The `convertToAlias` script will have to be idempotent, preferably setting `ctx.op="noop"` on subsequent runs to avoid unecessary writes. + 5. Delete the legacy index and replace it with an alias of the same name + ``` + POST /_aliases + { + "actions" : [ + { "add": { "index": ".kibana_pre6.5.0_001", "alias": ".kibana" } }, + { "remove_index": { "index": ".kibana" } } + ] + } + ```. + Unlike the delete index API, the `remove_index` action will fail if + provided with an _alias_. Ignore "The provided expression [.kibana] + matches an alias, specify the corresponding concrete indices instead." + or "index_not_found_exception" errors. These actions are applied + atomically so that other Kibana instances will always see either a + `.kibana` index or an alias, but never neither. + 6. Use the cloned `.kibana_pre6.5.0_001` as the source for the rest of the migration algorithm. +3. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. 1. Because the same version can have plugins enabled at any point in time, perform the mappings update in step (6) and migrate outdated documents with step (7). 2. Skip to step (9) to start serving traffic. -3. Fail the migration if: +4. Fail the migration if: 1. `.kibana_current` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` 2. (Only in 8.x) The source index contains documents that belong to an unknown Saved Object type (from a disabled plugin). Log an error explaining that the plugin that created these documents needs to be enabled again or that these objects should be deleted. See section (4.2.1.4). -4. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. -5. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. +5. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. +6. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. 1. `POST /.kibana_n/_clone/.kibana_7.10.0_001?wait_for_active_shards=all {"settings": {"index.blocks.write": false}}`. Ignore errors if the clone already exists. 2. Wait for the cloning to complete `GET /_cluster/health/.kibana_7.10.0_001?wait_for_status=green&timeout=60s` If cloning doesn’t complete within the 60s timeout, log a warning for visibility and poll again. -6. Update the mappings of the target index +7. Update the mappings of the target index 1. Retrieve the existing mappings including the `migrationMappingPropertyHashes` metadata. 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that were enabled in a previous version but are now disabled. 3. Ensure that fields are correctly indexed using the target index's latest mappings `POST /.kibana_7.10.0_001/_update_by_query?conflicts=proceed`. In the future we could optimize this query by only targeting documents: 1. That belong to a known saved object type. 2. Which don't have outdated migrationVersion numbers since these will be transformed anyway. 3. That belong to a type whose mappings were changed by comparing the `migrationMappingPropertyHashes`. (Metadata, unlike the mappings isn't commutative, so there is a small chance that the metadata hashes do not accurately reflect the latest mappings, however, this will just result in an less efficient query). -7. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. +8. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. 1. Ignore any version conflict errors. 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. -8. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: - 1. Checks that `.kibana-current` alias is still pointing to the source index - 2. Points the `.kibana-7.10.0` and `.kibana_current` aliases to the target index. - 3. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: +9. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: + 3. Checks that `.kibana_current` alias is still pointing to the source index + 4. Points the `.kibana_7.10.0` and `.kibana_current` aliases to the target index. + 5. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: 1. If `.kibana_current` is _not_ pointing to our target index fail the migration. 2. If `.kibana_current` is pointing to our target index the migration has succeeded and we can proceed to step (9). -9. Start serving traffic. +10. Start serving traffic. + +This algorithm shares a weakness with our existing migration algorithm +(since v7.4). When the task manager index gets reindexed a reindex script is +applied. Because we delete the original task manager index there is no way to +rollback a failed task manager migration without a snapshot. Together with the limitations, this algorithm ensures that migrations are idempotent. If two nodes are started simultaneously, both of them will start -transforming documents in that version's target index, but because migrations are idempotent, it doesn’t matter which node’s writes win. +transforming documents in that version's target index, but because migrations +are idempotent, it doesn’t matter which node’s writes win.
In the future, this algorithm could enable (2.6) "read-only functionality during the downtime window" but this is outside of the scope of this RFC. From d83167629c60a4263e1479591a9188adc25e76b0 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 6 Nov 2020 12:18:54 +0100 Subject: [PATCH 20/20] fix underlying data drilldown for Lens (#82737) --- .../embeddable/embeddable.test.tsx | 38 +++++++++++++++++++ .../embeddable/embeddable.tsx | 6 ++- x-pack/test/functional/apps/lens/dashboard.ts | 35 ++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 5658f029c48ab8..9f9d7fef9c7b4f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -139,6 +139,44 @@ describe('embeddable', () => { | expression`); }); + it('should initialize output with deduped list of index patterns', async () => { + attributeService = attributeServiceMockFromSavedVis({ + ...savedVis, + references: [ + { type: 'index-pattern', id: '123', name: 'abc' }, + { type: 'index-pattern', id: '123', name: 'def' }, + { type: 'index-pattern', id: '456', name: 'ghi' }, + ], + }); + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: ({ + get: (id: string) => Promise.resolve({ id }), + } as unknown) as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + {} as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({} as LensEmbeddableInput); + const outputIndexPatterns = embeddable.getOutput().indexPatterns!; + expect(outputIndexPatterns.length).toEqual(2); + expect(outputIndexPatterns[0].id).toEqual('123'); + expect(outputIndexPatterns[1].id).toEqual('456'); + }); + it('should re-render if new input is pushed', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index fdb267835f44c9..33e5dee99081ff 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -259,8 +259,10 @@ export class Embeddable if (!this.savedVis) { return; } - const promises = this.savedVis.references - .filter(({ type }) => type === 'index-pattern') + const promises = _.uniqBy( + this.savedVis.references.filter(({ type }) => type === 'index-pattern'), + 'id' + ) .map(async ({ id }) => { try { return await this.deps.indexPatternService.get(id); diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index fa13d013ea1157..c24f4ccf01bcd5 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -8,7 +8,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['header', 'common', 'dashboard', 'timePicker', 'lens']); + const PageObjects = getPageObjects([ + 'header', + 'common', + 'dashboard', + 'timePicker', + 'lens', + 'discover', + ]); const find = getService('find'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -18,6 +25,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const security = getService('security'); + const panelActions = getService('dashboardPanelActions'); async function clickInChart(x: number, y: number) { const el = await elasticChart.getCanvas(); @@ -27,7 +35,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('lens dashboard tests', () => { before(async () => { await PageObjects.common.navigateToApp('dashboard'); - await security.testUser.setRoles(['global_dashboard_all', 'test_logstash_reader'], false); + await security.testUser.setRoles( + ['global_dashboard_all', 'global_discover_all', 'test_logstash_reader'], + false + ); }); after(async () => { await security.testUser.restoreDefaults(); @@ -68,6 +79,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const hasIpFilter = await filterBar.hasFilter('ip', '97.220.3.248'); expect(hasIpFilter).to.be(true); }); + + it('should be able to drill down to discover', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsXYvis'); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.dashboard.saveDashboard('lnsDrilldown'); + await panelActions.openContextMenu(); + await testSubjects.clickWhenNotDisabled('embeddablePanelAction-ACTION_EXPLORE_DATA'); + await PageObjects.discover.waitForDiscoverAppOnScreen(); + + const el = await testSubjects.find('indexPattern-switch-link'); + const text = await el.getVisibleText(); + + expect(text).to.be('logstash-*'); + }); + it('should be able to add filters by clicking in pie chart', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard();