From 06f87bb838818554d8db3d2960dc485fc4a433ac Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 6 Oct 2020 13:29:16 -0400 Subject: [PATCH 01/12] [ML] DF Analytics creation wizard: resolve clone usability issues (#79048) * show error when clone fails due to no index pattern * default results_field unless specified * results field switch set to on if resultsField for cloned job is default * ensure cloned job config not overwritten in advanced editor * show errorToast if unable to clone anomalyDetection job due to no indexPattern * ensure jobConfig query getting saved in form state * ensure index patterns with commas handled correctly * clone should accept comma separated index patterns * use nullish coalescing operator when checking for undefined analysisFields --- .../details_step/details_step_form.tsx | 56 ++++++++++++++----- .../action_clone/clone_action_name.tsx | 10 +++- .../action_clone/use_clone_action.tsx | 4 +- .../use_create_analytics_form/reducer.ts | 3 +- .../hooks/use_create_analytics_form/state.ts | 4 +- .../use_create_analytics_form.ts | 2 +- .../components/job_actions/management.js | 24 +++++--- .../routes/schemas/data_analytics_schema.ts | 2 +- 8 files changed, 75 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 1d6a603caa81..b59fbe926aa4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -25,6 +25,8 @@ import { ANALYTICS_STEPS } from '../../page'; import { ml } from '../../../../../services/ml_api_service'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; +const DEFAULT_RESULTS_FIELD = 'ml'; + const indexNameExistsMessage = i18n.translate( 'xpack.ml.dataframe.analytics.create.destinationIndexHelpText', { @@ -64,6 +66,10 @@ export const DetailsStepForm: FC = ({ const [destIndexSameAsId, setDestIndexSameAsId] = useState( cloneJob === undefined && hasSwitchedToEditor === false ); + const [useResultsFieldDefault, setUseResultsFieldDefault] = useState( + (cloneJob === undefined && hasSwitchedToEditor === false && resultsField === undefined) || + (cloneJob !== undefined && resultsField === DEFAULT_RESULTS_FIELD) + ); const forceInput = useRef(null); @@ -266,22 +272,46 @@ export const DetailsStepForm: FC = ({ /> )} - - + setFormState({ resultsField: e.target.value })} - data-test-subj="mlAnalyticsCreateJobWizardResultsFieldInput" + name="mlDataFrameAnalyticsUseResultsFieldDefault" + label={i18n.translate('xpack.ml.dataframe.analytics.create.UseResultsFieldDefaultLabel', { + defaultMessage: 'Use results field default value "{defaultValue}"', + values: { defaultValue: DEFAULT_RESULTS_FIELD }, + })} + checked={useResultsFieldDefault === true} + onChange={() => setUseResultsFieldDefault(!useResultsFieldDefault)} + data-test-subj="mlAnalyticsCreateJobWizardUseResultsFieldDefault" /> + {useResultsFieldDefault === false && ( + + setFormState({ resultsField: e.target.value })} + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.create.resultsFieldInputAriaLabel', + { + defaultMessage: + 'The name of the field in which to store the results of the analysis.', + } + )} + data-test-subj="mlAnalyticsCreateJobWizardResultsFieldInput" + /> + + )} { return async (item: DataFrameAnalyticsListRow) => { const sourceIndex = Array.isArray(item.config.source.index) - ? item.config.source.index[0] + ? item.config.source.index.join(',') : item.config.source.index; let sourceIndexId; @@ -363,6 +363,14 @@ export const useNavigateToWizardWithClonedJob = () => { ); if (ip !== undefined) { sourceIndexId = ip.id; + } else { + toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analyticsList.noSourceIndexPatternForClone', { + defaultMessage: + 'Unable to clone the analytics job. No index pattern exists for index {indexPattern}.', + values: { indexPattern: sourceIndex }, + }) + ); } } catch (e) { const error = extractErrorMessage(e); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx index 53043d4e503f..ab069c2d42e8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/use_clone_action.tsx @@ -24,9 +24,7 @@ export const useCloneAction = (canCreateDataFrameAnalytics: boolean) => { const action: DataFrameAnalyticsListAction = useMemo( () => ({ - name: (item: DataFrameAnalyticsListRow) => ( - - ), + name: () => , enabled: () => canCreateDataFrameAnalytics, description: cloneActionNameText, icon: 'copy', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 6fb3904e76ec..a277ae6e6a66 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -549,8 +549,7 @@ export function reducer(state: State, action: Action): State { } case ACTION.SWITCH_TO_ADVANCED_EDITOR: - let { jobConfig } = state; - jobConfig = getJobConfigFromFormState(state.form); + const jobConfig = getJobConfigFromFormState(state.form); const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); return validateAdvancedEditor({ diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 2a89c5a5fd68..f12427a8c3de 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -292,7 +292,6 @@ export function getFormStateFromJobConfig( isClone: boolean = true ): Partial { const jobType = getAnalysisType(analyticsJobConfig.analysis) as DataFrameAnalysisConfigType; - const resultState: Partial = { jobType, description: analyticsJobConfig.description ?? '', @@ -302,7 +301,8 @@ export function getFormStateFromJobConfig( : analyticsJobConfig.source.index, modelMemoryLimit: analyticsJobConfig.model_memory_limit, maxNumThreads: analyticsJobConfig.max_num_threads, - includes: analyticsJobConfig.analyzed_fields.includes, + includes: analyticsJobConfig.analyzed_fields?.includes ?? [], + jobConfigQuery: analyticsJobConfig.source.query || defaultSearchQuery, }; if (isClone === false) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 1c8bfafeb10f..0b88f52e555c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -285,7 +285,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { resetForm(); const config = extractCloningConfig(cloneJob); if (isAdvancedConfig(config)) { - setJobConfig(config); + setFormState(getFormStateFromJobConfig(config)); switchToAdvancedEditor(); } else { setFormState(getFormStateFromJobConfig(config)); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 254c546df65b..7c94bfb746b9 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -9,6 +9,7 @@ import { mlNodesAvailable } from '../../../../ml_nodes_check/check_ml_nodes'; import { getIndexPatternNames } from '../../../../util/index_utils'; import { stopDatafeeds, cloneJob, closeJobs, isStartable, isStoppable, isClosable } from '../utils'; +import { getToastNotifications } from '../../../../util/dependency_cache'; import { i18n } from '@kbn/i18n'; export function actionsMenuContent( @@ -86,15 +87,24 @@ export function actionsMenuContent( // the indexPattern the job was created for. An indexPattern could either have been deleted // since the the job was created or the current user doesn't have the required permissions to // access the indexPattern. - const indexPatternNames = getIndexPatternNames(); - const jobIndicesAvailable = item.datafeedIndices.every((dfiName) => { - return indexPatternNames.some((ipName) => ipName === dfiName); - }); - - return item.deleting !== true && canCreateJob && jobIndicesAvailable; + return item.deleting !== true && canCreateJob; }, onClick: (item) => { - cloneJob(item.id); + const indexPatternNames = getIndexPatternNames(); + const indexPatternTitle = item.datafeedIndices.join(','); + const jobIndicesAvailable = indexPatternNames.includes(indexPatternTitle); + + if (!jobIndicesAvailable) { + getToastNotifications().addDanger( + i18n.translate('xpack.ml.jobsList.managementActions.noSourceIndexPatternForClone', { + defaultMessage: + 'Unable to clone the anomaly detection job {jobId}. No index pattern exists for index {indexPatternTitle}.', + values: { jobId: item.id, indexPatternTitle }, + }) + ); + } else { + cloneJob(item.id); + } closeMenu(true); }, 'data-test-subj': 'mlActionButtonCloneJob', diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 0c3e186c314c..08387a8ffa8a 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -47,7 +47,7 @@ export const dataAnalyticsExplainSchema = schema.object({ dest: schema.maybe(schema.any()), /** Source */ source: schema.object({ - index: schema.string(), + index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), query: schema.maybe(schema.any()), }), analysis: schema.any(), From d845922a1a9c68096293bbf62a26a6a42736be5b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 6 Oct 2020 19:29:41 +0200 Subject: [PATCH 02/12] [UX] Update url filter (#79497) --- .../step_definitions/csm/url_search_filter.ts | 2 +- .../app/RumDashboard/Panels/MainFilters.tsx | 55 ++++++++++ .../app/RumDashboard/RumHeader/index.tsx | 22 ---- .../components/app/RumDashboard/RumHome.tsx | 35 +++--- .../URLFilter/ServiceNameFilter/index.tsx | 24 ++--- .../URLFilter/URLSearch/SelectableUrlList.tsx | 100 +++++++++++++++--- .../URLFilter/URLSearch/index.tsx | 14 ++- .../app/RumDashboard/URLFilter/index.tsx | 67 +++--------- .../app/RumDashboard/UserPercentile/index.tsx | 3 +- .../app/RumDashboard/hooks/useUxQuery.ts | 2 +- .../components/app/RumDashboard/index.tsx | 51 +-------- .../app/RumDashboard/translations.ts | 5 +- 12 files changed, 200 insertions(+), 180 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts index b8bfeffb2293..75b9a9c80416 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts @@ -55,7 +55,7 @@ Then(`it should filter results based on query`, () => { listOfUrls.should('have.length', 1); const actualUrlsText = [ - 'http://opbeans-node:3000/customersPage views: 10Page load duration: 76 ms ', + 'http://opbeans-node:3000/customersPage views: 10Page load duration: 76 ms', ]; cy.get('li.euiSelectableListItem') diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx new file mode 100644 index 000000000000..efc52e7cb426 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { EnvironmentFilter } from '../../../shared/EnvironmentFilter'; +import { ServiceNameFilter } from '../URLFilter/ServiceNameFilter'; +import { useFetcher } from '../../../../hooks/useFetcher'; +import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { UserPercentile } from '../UserPercentile'; + +export function MainFilters() { + const { + urlParams: { start, end }, + } = useUrlParams(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/rum-client/services', + params: { + query: { + start, + end, + uiFilters: JSON.stringify({ agentName: RUM_AGENT_NAMES }), + }, + }, + }); + } + }, + [start, end] + ); + + return ( + <> + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx deleted file mode 100644 index 6b3fcb3b0346..000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHeader/index.tsx +++ /dev/null @@ -1,22 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { DatePicker } from '../../../shared/DatePicker'; - -export function RumHeader({ children }: { children: ReactNode }) { - return ( - <> - - {children} - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index f30f9ba5af25..d1cfe1d63f88 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -8,9 +8,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { RumOverview } from '../RumDashboard'; -import { RumHeader } from './RumHeader'; -import { UserPercentile } from './UserPercentile'; import { CsmSharedContextProvider } from './CsmSharedContext'; +import { MainFilters } from './Panels/MainFilters'; +import { DatePicker } from '../../shared/DatePicker'; export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { defaultMessage: 'User Experience', @@ -19,18 +19,25 @@ export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { export function RumHome() { return ( - - - - -

{UX_LABEL}

-
-
- - - -
-
+ + + +

{UX_LABEL}

+
+
+ + + + + + + + +
); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx index f10c9e888a19..cf419f6edffc 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/ServiceNameFilter/index.tsx @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiHorizontalRule, - EuiSelect, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; @@ -66,22 +61,17 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { return ( <> - -

- {i18n.translate('xpack.apm.localFilters.titles.serviceName', { - defaultMessage: 'Service name', - })} -

-
- - - { updateServiceName(event.target.value); }} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index ebca1df17038..d9d3d2329937 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FormEvent, SetStateAction, useRef, useState } from 'react'; +import React, { + FormEvent, + SetStateAction, + useRef, + useState, + KeyboardEvent, +} from 'react'; import { - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, @@ -14,13 +19,41 @@ import { EuiPopoverTitle, EuiSelectable, EuiSelectableMessage, + EuiPopoverFooter, + EuiButton, + EuiText, + EuiIcon, + EuiBadge, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { useEvent } from 'react-use'; import { formatOptions, selectableRenderOptions, UrlOption, } from './RenderOption'; import { I18LABELS } from '../../translations'; +import { useUiSetting$ } from '../../../../../../../../../src/plugins/kibana_react/public'; + +const StyledRow = styled.div<{ + darkMode: boolean; +}>` + text-align: center; + padding: 8px 0px; + background-color: ${(props) => + props.darkMode + ? euiDarkVars.euiPageBackgroundColor + : euiLightVars.euiPageBackgroundColor}; + border-bottom: 1px solid + ${(props) => + props.darkMode + ? euiDarkVars.euiColorLightestShade + : euiLightVars.euiColorLightestShade}; +`; interface Props { data: { @@ -48,11 +81,23 @@ export function SelectableUrlList({ popoverIsOpen, setPopoverIsOpen, }: Props) { + const [darkMode] = useUiSetting$('theme:darkMode'); + const [popoverRef, setPopoverRef] = useState(null); const [searchRef, setSearchRef] = useState(null); const titleRef = useRef(null); + const onEnterKey = (evt: KeyboardEvent) => { + if (evt.key.toLowerCase() === 'enter') { + onTermChange(); + setPopoverIsOpen(false); + } + }; + + // @ts-ignore - not sure, why it's not working + useEvent('keydown', onEnterKey, searchRef); + const searchOnFocus = (e: React.FocusEvent) => { setPopoverIsOpen(true); }; @@ -102,22 +147,10 @@ export function SelectableUrlList({ function PopOverTitle() { return ( - + {loading ? : titleText} - - { - onTermChange(); - setPopoverIsOpen(false); - }} - > - {I18LABELS.matchThisQuery} - - ); @@ -142,6 +175,7 @@ export function SelectableUrlList({ listProps={{ rowHeight: 68, showIcons: true, + onFocusBadge: false, }} loadingMessage={loadingMessage} emptyMessage={emptyMessage} @@ -158,7 +192,43 @@ export function SelectableUrlList({ >
+ {searchValue && ( + + + {searchValue}, + icon: ( + + Enter + + ), + }} + /> + + + )} {list} + + + + { + onTermChange(); + closePopover(); + }} + > + {i18n.translate('xpack.apm.apply.label', { + defaultMessage: 'Apply', + })} + + + +
)} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx index 5ad666cd466b..661f4406990f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/index.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTitle } from '@elastic/eui'; import useDebounce from 'react-use/lib/useDebounce'; import React, { useEffect, useState, FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { EuiTitle } from '@elastic/eui'; import { useUrlParams } from '../../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../../hooks/useFetcher'; import { I18LABELS } from '../../translations'; @@ -24,7 +24,9 @@ interface Props { export function URLSearch({ onChange: onFilterChange }: Props) { const history = useHistory(); - const { uiFilters } = useUrlParams(); + const { uiFilters, urlParams } = useUrlParams(); + + const { searchTerm } = urlParams; const [popoverIsOpen, setPopoverIsOpen] = useState(false); @@ -84,6 +86,12 @@ export function URLSearch({ onChange: onFilterChange }: Props) { setCheckedUrls(uiFilters.transactionUrl || []); }, [uiFilters]); + useEffect(() => { + if (searchTerm && searchValue === '') { + updateSearchTerm(''); + } + }, [searchValue, updateSearchTerm, searchTerm]); + const onChange = (updatedOptions: UrlOption[]) => { const clickedItems = updatedOptions.filter( (option) => option.checked === 'on' @@ -121,7 +129,7 @@ export function URLSearch({ onChange: onFilterChange }: Props) { return ( <> - +

{I18LABELS.url}

{ const search = omit(toQuery(history.location.search), name); @@ -42,20 +32,6 @@ export function URLFilter() { }); }; - const updateSearchTerm = useCallback( - (searchTermN?: string) => { - const newLocation = { - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - searchTerm: searchTermN, - }), - }; - history.push(newLocation); - }, - [history] - ); - const name = 'transactionUrl'; const { uiFilters } = useUrlParams(); @@ -65,44 +41,25 @@ export function URLFilter() { return ( - { setFilterValue('transactionUrl', value); }} /> - - {searchTerm && ( + {filterValue.length > 0 && ( <> - { - updateSearchTerm(); - }} - onClickAriaLabel={removeSearchTermLabel} - iconOnClick={() => { - updateSearchTerm(); - }} - iconOnClickAriaLabel={removeSearchTermLabel} - iconType="cross" - iconSide="right" - > - *{searchTerm}* - + { + setFilterValue( + name, + filterValue.filter((v) => val !== v) + ); + }} + value={filterValue} + /> )} - {filterValue.length > 0 && ( - { - setFilterValue( - name, - filterValue.filter((v) => val !== v) - ); - }} - value={filterValue} - /> - )} - ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx index 2ce724e7fec8..75a018afa13d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx @@ -44,8 +44,7 @@ export function UserPercentile() { if (!percentile) { updatePercentile(DEFAULT_P); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); const options = [ { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts index da2ac5260219..16396dc9fc15 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useUxQuery.ts @@ -15,7 +15,7 @@ export function useUxQuery() { const queryParams = useMemo(() => { const { serviceName } = uiFilters; - if (start && end && serviceName) { + if (start && end && serviceName && percentile) { return { start, end, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index a04d145555b1..ed084a91df6d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -5,22 +5,13 @@ */ import React, { useMemo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiSpacer, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { RumDashboard } from './RumDashboard'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { RUM_AGENT_NAMES } from '../../../../common/agent_name'; -import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; -import { URLFilter } from './URLFilter'; + import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { ServiceNameFilter } from './URLFilter/ServiceNameFilter'; +import { URLFilter } from './URLFilter'; export function RumOverview() { useTrackPageview({ app: 'ux', path: 'home' }); @@ -35,46 +26,14 @@ export function RumOverview() { return config; }, []); - const { - urlParams: { start, end }, - } = useUrlParams(); - - const { data, status } = useFetcher( - (callApmApi) => { - if (start && end) { - return callApmApi({ - pathname: '/api/apm/rum-client/services', - params: { - query: { - start, - end, - uiFilters: JSON.stringify({ agentName: RUM_AGENT_NAMES }), - }, - }, - }); - } - }, - [start, end] - ); - return ( <> - - - - <> - - - - {' '} - + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index afb09db7bd97..c8db011874a8 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -92,7 +92,7 @@ export const I18LABELS = { } ), searchByUrl: i18n.translate('xpack.apm.rum.filters.searchByUrl', { - defaultMessage: 'Search by url', + defaultMessage: 'Search by URL', }), getSearchResultsLabel: (total: number) => i18n.translate('xpack.apm.rum.filters.searchResults', { @@ -108,9 +108,6 @@ export const I18LABELS = { url: i18n.translate('xpack.apm.rum.filters.url', { defaultMessage: 'Url', }), - matchThisQuery: i18n.translate('xpack.apm.rum.filters.url.matchThisQuery', { - defaultMessage: 'Match this query', - }), loadingResults: i18n.translate('xpack.apm.rum.filters.url.loadingResults', { defaultMessage: 'Loading results', }), From 3002108c401cfdf382b099a724bec9b305457c76 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 6 Oct 2020 19:31:59 +0200 Subject: [PATCH 03/12] [ML] rename inference to trained_models (#79676) --- .../types/{inference.ts => trained_models.ts} | 4 +- .../common/use_results_view_config.ts | 6 +- .../models_management/delete_models_modal.tsx | 8 +-- .../models_management/expanded_row.tsx | 26 ++++----- .../models_management/models_list.tsx | 58 +++++++++---------- .../{inference.ts => trained_models.ts} | 32 +++++----- .../data_frame_analytics/models_provider.ts | 2 +- x-pack/plugins/ml/server/plugin.ts | 4 +- .../{inference.ts => trained_models.ts} | 20 +++---- 9 files changed, 80 insertions(+), 80 deletions(-) rename x-pack/plugins/ml/common/types/{inference.ts => trained_models.ts} (95%) rename x-pack/plugins/ml/public/application/services/ml_api_service/{inference.ts => trained_models.ts} (73%) rename x-pack/plugins/ml/server/routes/{inference.ts => trained_models.ts} (85%) diff --git a/x-pack/plugins/ml/common/types/inference.ts b/x-pack/plugins/ml/common/types/trained_models.ts similarity index 95% rename from x-pack/plugins/ml/common/types/inference.ts rename to x-pack/plugins/ml/common/types/trained_models.ts index ce2cfb1f78fd..35425e74759d 100644 --- a/x-pack/plugins/ml/common/types/inference.ts +++ b/x-pack/plugins/ml/common/types/trained_models.ts @@ -44,7 +44,7 @@ export interface TrainedModelStat { }; } -export interface ModelConfigResponse { +export interface TrainedModelConfigResponse { created_by: string; create_time: string; default_field_map: Record; @@ -79,5 +79,5 @@ export interface ModelPipelines { * Get inference response from the ES endpoint */ export interface InferenceConfigResponse { - trained_model_configs: ModelConfigResponse[]; + trained_model_configs: TrainedModelConfigResponse[]; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts index 50be009d288f..7d2ca86a3808 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts @@ -19,7 +19,7 @@ import { DataFrameAnalyticsConfig } from '../common'; import { isGetDataFrameAnalyticsStatsResponseOk } from '../pages/analytics_management/services/analytics_service/get_analytics'; import { DATA_FRAME_TASK_STATE } from '../pages/analytics_management/components/analytics_list/common'; -import { useInferenceApiService } from '../../services/ml_api_service/inference'; +import { useTrainedModelsApiService } from '../../services/ml_api_service/trained_models'; import { TotalFeatureImportance } from '../../../../common/types/feature_importance'; import { getToastNotificationService } from '../../services/toast_notification_service'; import { @@ -29,7 +29,7 @@ import { export const useResultsViewConfig = (jobId: string) => { const mlContext = useMlContext(); - const inferenceApiService = useInferenceApiService(); + const trainedModelsApiService = useTrainedModelsApiService(); const [indexPattern, setIndexPattern] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); @@ -74,7 +74,7 @@ export const useResultsViewConfig = (jobId: string) => { isRegressionAnalysis(jobConfigUpdate.analysis) ) { try { - const inferenceModels = await inferenceApiService.getInferenceModel(`${jobId}*`, { + const inferenceModels = await trainedModelsApiService.getTrainedModels(`${jobId}*`, { include: 'total_feature_importance', }); const inferenceModel = inferenceModels.find( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx index 3c2ba13a1db2..571bda871d7e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/delete_models_modal.tsx @@ -35,7 +35,7 @@ export const DeleteModelsModal: FC = ({ models, onClose = ({ models, onClose size="s" > = ({ models, onClose diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx index 803a2523a55e..f18f293ff953 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/expanded_row.tsx @@ -120,7 +120,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'details', name: ( ), @@ -133,7 +133,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -156,7 +156,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'config', name: ( ), @@ -169,7 +169,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -190,7 +190,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -214,7 +214,7 @@ export const ExpandedRow: FC = ({ item }) => { id: 'stats', name: ( ), @@ -228,7 +228,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -248,7 +248,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -266,7 +266,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -300,7 +300,7 @@ export const ExpandedRow: FC = ({ item }) => {
@@ -354,7 +354,7 @@ export const ExpandedRow: FC = ({ item }) => { name: ( <> {' '} {stats.pipeline_count} @@ -390,7 +390,7 @@ export const ExpandedRow: FC = ({ item }) => { }} > @@ -402,7 +402,7 @@ export const ExpandedRow: FC = ({ item }) => {
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index d5a7ca6e96c0..6dd55f1881d7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -24,16 +24,16 @@ import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/bas import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; import { Action } from '@elastic/eui/src/components/basic_table/action_types'; import { StatsBar, ModelsBarStats } from '../../../../../components/stats_bar'; -import { useInferenceApiService } from '../../../../../services/ml_api_service/inference'; +import { useTrainedModelsApiService } from '../../../../../services/ml_api_service/trained_models'; import { ModelsTableToConfigMapping } from './index'; import { DeleteModelsModal } from './delete_models_modal'; import { useMlKibana, useMlUrlGenerator, useNotifications } from '../../../../../contexts/kibana'; import { ExpandedRow } from './expanded_row'; import { - ModelConfigResponse, + TrainedModelConfigResponse, ModelPipelines, TrainedModelStat, -} from '../../../../../../../common/types/inference'; +} from '../../../../../../../common/types/trained_models'; import { getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, @@ -48,7 +48,7 @@ import { timeFormatter } from '../../../../../../../common/util/date_utils'; type Stats = Omit; -export type ModelItem = ModelConfigResponse & { +export type ModelItem = TrainedModelConfigResponse & { type?: string; stats?: Stats; pipelines?: ModelPipelines['pipelines'] | null; @@ -66,7 +66,7 @@ export const ModelsList: FC = () => { const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; - const inferenceApiService = useInferenceApiService(); + const trainedModelsApiService = useTrainedModelsApiService(); const { toasts } = useNotifications(); const [searchQueryText, setSearchQueryText] = useState(''); @@ -110,7 +110,7 @@ export const ModelsList: FC = () => { */ const fetchData = useCallback(async () => { try { - const response = await inferenceApiService.getInferenceModel(undefined, { + const response = await trainedModelsApiService.getTrainedModels(undefined, { with_pipelines: true, size: 1000, }); @@ -146,7 +146,7 @@ export const ModelsList: FC = () => { } } catch (error) { toasts.addError(new Error(error.body?.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchFailedErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchFailedErrorMessage', { defaultMessage: 'Models fetch failed', }), }); @@ -166,8 +166,8 @@ export const ModelsList: FC = () => { total: { show: true, value: items.length, - label: i18n.translate('xpack.ml.inference.modelsList.totalAmountLabel', { - defaultMessage: 'Total inference trained models', + label: i18n.translate('xpack.ml.trainedModels.modelsList.totalAmountLabel', { + defaultMessage: 'Total trained models', }), }, }; @@ -182,7 +182,7 @@ export const ModelsList: FC = () => { try { const { trained_model_stats: modelsStatsResponse, - } = await inferenceApiService.getInferenceModelStats(modelIdsToFetch); + } = await trainedModelsApiService.getTrainedModelStats(modelIdsToFetch); for (const { model_id: id, ...stats } of modelsStatsResponse) { const model = models.find((m) => m.model_id === id); @@ -191,7 +191,7 @@ export const ModelsList: FC = () => { return true; } catch (error) { toasts.addError(new Error(error.body.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchModelStatsErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchModelStatsErrorMessage', { defaultMessage: 'Fetch model stats failed', }), }); @@ -221,7 +221,7 @@ export const ModelsList: FC = () => { setModelsToDelete(models as ModelItemFull[]); } else { toasts.addDanger( - i18n.translate('xpack.ml.inference.modelsList.unableToDeleteModelsErrorMessage', { + i18n.translate('xpack.ml.trainedModels.modelsList.unableToDeleteModelsErrorMessage', { defaultMessage: 'Unable to delete models', }) ); @@ -236,7 +236,7 @@ export const ModelsList: FC = () => { try { await Promise.all( - modelsToDeleteIds.map((modelId) => inferenceApiService.deleteInferenceModel(modelId)) + modelsToDeleteIds.map((modelId) => trainedModelsApiService.deleteTrainedModel(modelId)) ); setItems( items.filter( @@ -244,7 +244,7 @@ export const ModelsList: FC = () => { ) ); toasts.addSuccess( - i18n.translate('xpack.ml.inference.modelsList.successfullyDeletedMessage', { + i18n.translate('xpack.ml.trainedModels.modelsList.successfullyDeletedMessage', { defaultMessage: '{modelsCount, plural, one {Model {modelsToDeleteIds}} other {# models}} {modelsCount, plural, one {has} other {have}} been successfully deleted', values: { @@ -255,7 +255,7 @@ export const ModelsList: FC = () => { ); } catch (error) { toasts.addError(new Error(error?.body?.message), { - title: i18n.translate('xpack.ml.inference.modelsList.fetchDeletionErrorMessage', { + title: i18n.translate('xpack.ml.trainedModels.modelsList.fetchDeletionErrorMessage', { defaultMessage: '{modelsCount, plural, one {Model} other {Models}} deletion failed', values: { modelsCount: modelsToDeleteIds.length, @@ -270,10 +270,10 @@ export const ModelsList: FC = () => { */ const actions: Array> = [ { - name: i18n.translate('xpack.ml.inference.modelsList.viewTrainingDataActionLabel', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { defaultMessage: 'View training data', }), - description: i18n.translate('xpack.ml.inference.modelsList.viewTrainingDataActionLabel', { + description: i18n.translate('xpack.ml.trainedModels.modelsList.viewTrainingDataActionLabel', { defaultMessage: 'View training data', }), icon: 'visTable', @@ -298,10 +298,10 @@ export const ModelsList: FC = () => { isPrimary: true, }, { - name: i18n.translate('xpack.ml.inference.modelsList.deleteModelActionLabel', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { defaultMessage: 'Delete model', }), - description: i18n.translate('xpack.ml.inference.modelsList.deleteModelActionLabel', { + description: i18n.translate('xpack.ml.trainedModels.modelsList.deleteModelActionLabel', { defaultMessage: 'Delete model', }), icon: 'trash', @@ -341,10 +341,10 @@ export const ModelsList: FC = () => { onClick={toggleDetails.bind(null, item)} aria-label={ itemIdToExpandedRowMap[item.model_id] - ? i18n.translate('xpack.ml.inference.modelsList.collapseRow', { + ? i18n.translate('xpack.ml.trainedModels.modelsList.collapseRow', { defaultMessage: 'Collapse', }) - : i18n.translate('xpack.ml.inference.modelsList.expandRow', { + : i18n.translate('xpack.ml.trainedModels.modelsList.expandRow', { defaultMessage: 'Expand', }) } @@ -354,7 +354,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.id, - name: i18n.translate('xpack.ml.inference.modelsList.modelIdHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.modelIdHeader', { defaultMessage: 'ID', }), sortable: true, @@ -362,7 +362,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.type, - name: i18n.translate('xpack.ml.inference.modelsList.typeHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.typeHeader', { defaultMessage: 'Type', }), sortable: true, @@ -371,7 +371,7 @@ export const ModelsList: FC = () => { }, { field: ModelsTableToConfigMapping.createdAt, - name: i18n.translate('xpack.ml.inference.modelsList.createdAtHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.createdAtHeader', { defaultMessage: 'Created at', }), dataType: 'date', @@ -379,7 +379,7 @@ export const ModelsList: FC = () => { sortable: true, }, { - name: i18n.translate('xpack.ml.inference.modelsList.actionsHeader', { + name: i18n.translate('xpack.ml.trainedModels.modelsList.actionsHeader', { defaultMessage: 'Actions', }), actions, @@ -413,7 +413,7 @@ export const ModelsList: FC = () => {
@@ -423,7 +423,7 @@ export const ModelsList: FC = () => { @@ -438,10 +438,10 @@ export const ModelsList: FC = () => { ? { selectableMessage: (selectable, item) => { return selectable - ? i18n.translate('xpack.ml.inference.modelsList.selectableMessage', { + ? i18n.translate('xpack.ml.trainedModels.modelsList.selectableMessage', { defaultMessage: 'Select a model', }) - : i18n.translate('xpack.ml.inference.modelsList.disableSelectableMessage', { + : i18n.translate('xpack.ml.trainedModels.modelsList.disableSelectableMessage', { defaultMessage: 'Model has associated pipelines', }); }, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts similarity index 73% rename from x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts rename to x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index ce211612fba6..ed5d7e37cd1c 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/inference.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -10,10 +10,10 @@ import { HttpService } from '../http_service'; import { basePath } from './index'; import { useMlKibana } from '../../contexts/kibana'; import { - ModelConfigResponse, + TrainedModelConfigResponse, ModelPipelines, TrainedModelStat, -} from '../../../../common/types/inference'; +} from '../../../../common/types/trained_models'; export interface InferenceQueryParams { decompress_definition?: boolean; @@ -47,7 +47,7 @@ export interface InferenceStatsResponse { * Service with APIs calls to perform inference operations. * @param httpService */ -export function inferenceApiProvider(httpService: HttpService) { +export function trainedModelsApiProvider(httpService: HttpService) { const apiBasePath = basePath(); return { @@ -58,14 +58,14 @@ export function inferenceApiProvider(httpService: HttpService) { * Fetches all In case nothing is provided. * @param params - Optional query params */ - getInferenceModel(modelId?: string | string[], params?: InferenceQueryParams) { + getTrainedModels(modelId?: string | string[], params?: InferenceQueryParams) { let model = modelId ?? ''; if (Array.isArray(modelId)) { model = modelId.join(','); } - return httpService.http({ - path: `${apiBasePath}/inference${model && `/${model}`}`, + return httpService.http({ + path: `${apiBasePath}/trained_models${model && `/${model}`}`, method: 'GET', ...(params ? { query: params as HttpFetchQuery } : {}), }); @@ -78,14 +78,14 @@ export function inferenceApiProvider(httpService: HttpService) { * Fetches all In case nothing is provided. * @param params - Optional query params */ - getInferenceModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { + getTrainedModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { let model = modelId ?? '_all'; if (Array.isArray(modelId)) { model = modelId.join(','); } return httpService.http({ - path: `${apiBasePath}/inference/${model}/_stats`, + path: `${apiBasePath}/trained_models/${model}/_stats`, method: 'GET', }); }, @@ -95,14 +95,14 @@ export function inferenceApiProvider(httpService: HttpService) { * * @param modelId - Model ID, collection of Model IDs. */ - getInferenceModelPipelines(modelId: string | string[]) { + getTrainedModelPipelines(modelId: string | string[]) { let model = modelId; if (Array.isArray(modelId)) { model = modelId.join(','); } return httpService.http({ - path: `${apiBasePath}/inference/${model}/pipelines`, + path: `${apiBasePath}/trained_models/${model}/pipelines`, method: 'GET', }); }, @@ -112,25 +112,25 @@ export function inferenceApiProvider(httpService: HttpService) { * * @param modelId - Model ID */ - deleteInferenceModel(modelId: string) { + deleteTrainedModel(modelId: string) { return httpService.http({ - path: `${apiBasePath}/inference/${modelId}`, + path: `${apiBasePath}/trained_models/${modelId}`, method: 'DELETE', }); }, }; } -type InferenceApiService = ReturnType; +type TrainedModelsApiService = ReturnType; /** - * Hooks for accessing {@link InferenceApiService} in React components. + * Hooks for accessing {@link TrainedModelsApiService} in React components. */ -export function useInferenceApiService(): InferenceApiService { +export function useTrainedModelsApiService(): TrainedModelsApiService { const { services: { mlServices: { httpService }, }, } = useMlKibana(); - return useMemo(() => inferenceApiProvider(httpService), [httpService]); + return useMemo(() => trainedModelsApiProvider(httpService), [httpService]); } diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts index b2a4ccdab43d..e10c460bbfbb 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/models_provider.ts @@ -5,7 +5,7 @@ */ import { IScopedClusterClient } from 'kibana/server'; -import { PipelineDefinition } from '../../../common/types/inference'; +import { PipelineDefinition } from '../../../common/types/trained_models'; export function modelsProvider(client: IScopedClusterClient) { return { diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 7224eacf84e9..d0c837b7a1ca 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -47,7 +47,7 @@ import { createSharedServices, SharedServices } from './shared_services'; import { getPluginPrivileges } from '../common/types/capabilities'; import { setupCapabilitiesSwitcher } from './lib/capabilities'; import { registerKibanaSettings } from './lib/register_settings'; -import { inferenceRoutes } from './routes/inference'; +import { trainedModelsRoutes } from './routes/trained_models'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; @@ -153,7 +153,7 @@ export class MlServerPlugin implements Plugin Date: Tue, 6 Oct 2020 19:43:07 +0200 Subject: [PATCH 04/12] Handle errors better in spaces selector (#79471) --- .../public/space_selector/space_selector.scss | 8 +++ .../public/space_selector/space_selector.tsx | 50 ++++++++++++++----- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/spaces/public/space_selector/space_selector.scss b/x-pack/plugins/spaces/public/space_selector/space_selector.scss index 85480e0471af..b888fb765b88 100644 --- a/x-pack/plugins/spaces/public/space_selector/space_selector.scss +++ b/x-pack/plugins/spaces/public/space_selector/space_selector.scss @@ -29,3 +29,11 @@ width: $euiFormMaxWidth; // make sure it's as wide as our default form element width max-width: 100%; } + +.spcSpaceSelector__errorPanel { + position: relative; + margin: auto; + padding-left: $euiSizeXL; + padding-right: $euiSizeXL; + max-width: 700px; +} diff --git a/x-pack/plugins/spaces/public/space_selector/space_selector.tsx b/x-pack/plugins/spaces/public/space_selector/space_selector.tsx index 131e10bcedff..494e68e0b15a 100644 --- a/x-pack/plugins/spaces/public/space_selector/space_selector.tsx +++ b/x-pack/plugins/spaces/public/space_selector/space_selector.tsx @@ -15,6 +15,7 @@ import { EuiPageBody, EuiPageContent, EuiPageHeader, + EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -39,6 +40,7 @@ interface State { loading: boolean; searchTerm: string; spaces: Space[]; + error?: Error; } export class SpaceSelector extends Component { @@ -71,12 +73,20 @@ export class SpaceSelector extends Component { this.setState({ loading: true }); const { spacesManager } = this.props; - spacesManager.getSpaces().then((spaces) => { - this.setState({ - loading: false, - spaces, + spacesManager + .getSpaces() + .then((spaces) => { + this.setState({ + loading: false, + spaces, + }); + }) + .catch((err) => { + this.setState({ + loading: false, + error: err, + }); }); - }); } public render() { @@ -135,14 +145,10 @@ export class SpaceSelector extends Component { )} - {!this.state.loading && filteredSpaces.length === 0 && ( + {!this.state.loading && !this.state.error && filteredSpaces.length === 0 && ( - + { )} + + {!this.state.loading && this.state.error && ( + + + + + + + + + + + + )} @@ -163,7 +190,6 @@ export class SpaceSelector extends Component { return ( { - // @ts-ignore onSearch doesn't exist on EuiFieldSearch Date: Tue, 6 Oct 2020 18:52:44 +0100 Subject: [PATCH 05/12] [Security Solution] Update export timeline success message by exported timelines' type (#79469) * init tests * fix export on success message * add cypress tests * fix unit tests * fix unit tests * Update x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> --- .../timeline_template_creation.spec.ts | 100 ++++++++++++++++++ .../timeline_templates_export.spec.ts | 47 ++++++++ .../cypress/screens/timeline.ts | 2 + .../cypress/tasks/timeline.ts | 6 ++ .../cypress/urls/navigation.ts | 1 + .../components/generic_downloader/index.tsx | 6 +- .../export_timeline/export_timeline.test.tsx | 70 ++++++++++++ .../export_timeline/export_timeline.tsx | 13 ++- .../open_timeline/open_timeline.test.tsx | 9 ++ .../components/open_timeline/translations.ts | 10 ++ .../components/timeline/pin/index.tsx | 2 +- 11 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts new file mode 100644 index 000000000000..e262d12770d3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts @@ -0,0 +1,100 @@ +/* + * 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 { timeline } from '../objects/timeline'; + +import { + FAVORITE_TIMELINE, + LOCKED_ICON, + NOTES, + NOTES_BUTTON, + NOTES_COUNT, + NOTES_TEXT_AREA, + TIMELINE_DESCRIPTION, + // TIMELINE_FILTER, + TIMELINE_QUERY, + TIMELINE_TITLE, +} from '../screens/timeline'; +import { + TIMELINES_DESCRIPTION, + TIMELINES_PINNED_EVENT_COUNT, + TIMELINES_NOTES_COUNT, + TIMELINES_FAVORITE, +} from '../screens/timelines'; + +import { loginAndWaitForPage } from '../tasks/login'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { + addDescriptionToTimeline, + addFilter, + addNameToTimeline, + addNotesToTimeline, + closeNotes, + closeTimeline, + createNewTimelineTemplate, + markAsFavorite, + openTimelineFromSettings, + populateTimeline, + waitForTimelineChanges, +} from '../tasks/timeline'; +import { openTimeline } from '../tasks/timelines'; + +import { OVERVIEW_URL } from '../urls/navigation'; + +describe('Timeline Templates', () => { + before(() => { + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + }); + + it('Creates a timeline template', async () => { + loginAndWaitForPage(OVERVIEW_URL); + openTimelineUsingToggle(); + createNewTimelineTemplate(); + populateTimeline(); + addFilter(timeline.filter); + // To fix + // cy.get(PIN_EVENT).should( + // 'have.attr', + // 'aria-label', + // 'This event may not be pinned while editing a template timeline' + // ); + cy.get(LOCKED_ICON).should('be.visible'); + + addNameToTimeline(timeline.title); + + const response = await cy.wait('@timeline').promisify(); + const timelineId = JSON.parse(response.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + + addDescriptionToTimeline(timeline.description); + addNotesToTimeline(timeline.notes); + closeNotes(); + markAsFavorite(); + waitForTimelineChanges(); + createNewTimelineTemplate(); + closeTimeline(); + openTimelineFromSettings(); + + cy.contains(timeline.title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).first().should('exist'); + + openTimeline(timelineId); + + cy.get(FAVORITE_TIMELINE).should('exist'); + cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title); + cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', timeline.description); + cy.get(TIMELINE_QUERY).should('have.text', timeline.query); + // Comments this assertion until we agreed what to do with the filters. + // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + cy.get(NOTES_COUNT).should('have.text', '1'); + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).should('have.attr', 'placeholder', 'Add a Note'); + cy.get(NOTES).should('have.text', timeline.notes); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts new file mode 100644 index 000000000000..8dcb5e144c24 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts @@ -0,0 +1,47 @@ +/* + * 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 { exportTimeline } from '../tasks/timelines'; +import { esArchiverLoad } from '../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { timeline as timelineTemplate } from '../objects/timeline'; + +import { TIMELINE_TEMPLATES_URL } from '../urls/navigation'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { addNameToTimeline, closeTimeline, createNewTimelineTemplate } from '../tasks/timeline'; + +describe('Export timelines', () => { + before(() => { + esArchiverLoad('timeline'); + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + cy.route('POST', '**api/timeline/_export?file_name=timelines_export.ndjson*').as('export'); + }); + + it('Exports a custom timeline template', async () => { + loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL); + openTimelineUsingToggle(); + createNewTimelineTemplate(); + addNameToTimeline(timelineTemplate.title); + closeTimeline(); + + const result = await cy.wait('@timeline').promisify(); + + const timelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + const templateTimelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline + .templateTimelineId; + + await exportTimeline(timelineId); + + cy.wait('@export').then((response) => { + cy.wrap(JSON.parse(response.xhr.responseText).templateTimelineId).should( + 'eql', + templateTimelineId + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 94255a2af897..e397dd9b5a41 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -29,6 +29,8 @@ export const COMBO_BOX = '.euiComboBoxOption__content'; export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; +export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]'; + export const DRAGGABLE_HEADER = '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 438700bdfca8..7c9c95427a4d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -43,6 +43,7 @@ import { TIMELINE_TITLE, TIMESTAMP_TOGGLE_FIELD, TOGGLE_TIMELINE_EXPAND_EVENT, + CREATE_NEW_TIMELINE_TEMPLATE, } from '../screens/timeline'; import { TIMELINES_TABLE } from '../screens/timelines'; @@ -114,6 +115,11 @@ export const createNewTimeline = () => { cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); }; +export const createNewTimelineTemplate = () => { + cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(CREATE_NEW_TIMELINE_TEMPLATE).click(); +}; + export const executeTimelineKQL = (query: string) => { cy.get(`${SEARCH_OR_FILTER_CONTAINER} textarea`).type(`${query} {enter}`); }; diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index b53b06db5bed..66b9bf8e111a 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -20,3 +20,4 @@ export const ADMINISTRATION_URL = '/app/security/administration'; export const NETWORK_URL = '/app/security/network'; export const OVERVIEW_URL = '/app/security/overview'; export const TIMELINES_URL = '/app/security/timelines'; +export const TIMELINE_TEMPLATES_URL = '/app/security/timelines/template'; diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx index 33e26cd4db03..c8efc366643e 100644 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx @@ -71,9 +71,11 @@ export const GenericDownloaderComponent = ({ anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates anchorRef.current.click(); - window.URL.revokeObjectURL(objectURL); - } + if (typeof window.URL.revokeObjectURL === 'function') { + window.URL.revokeObjectURL(objectURL); + } + } if (onExportSuccess != null) { onExportSuccess(ids.length); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx index b8a7cfd59d22..d0cfbaccde7d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -5,9 +5,15 @@ */ import React from 'react'; +import { useStateToaster } from '../../../../common/components/toasters'; + import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; +import * as i18n from '../translations'; + import { ReactWrapper, mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { useParams } from 'react-router-dom'; jest.mock('../translations', () => { return { @@ -22,6 +28,23 @@ jest.mock('.', () => { }; }); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + + return { + ...actual, + useParams: jest.fn(), + }; +}); + +jest.mock('../../../../common/components/toasters', () => { + const actual = jest.requireActual('../../../../common/components/toasters'); + return { + ...actual, + useStateToaster: jest.fn(), + }; +}); + describe('TimelineDownloader', () => { let wrapper: ReactWrapper; const defaultTestProps = { @@ -30,6 +53,20 @@ describe('TimelineDownloader', () => { isEnableDownloader: true, onComplete: jest.fn(), }; + const mockDispatchToaster = jest.fn(); + + beforeEach(() => { + (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]); + (useParams as jest.Mock).mockReturnValue({ tabName: 'default' }); + }); + + afterEach(() => { + (useStateToaster as jest.Mock).mockClear(); + (useParams as jest.Mock).mockReset(); + + (mockDispatchToaster as jest.Mock).mockClear(); + }); + describe('should not render a downloader', () => { test('Without exportedIds', () => { const testProps = { @@ -59,5 +96,38 @@ describe('TimelineDownloader', () => { wrapper = mount(); expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); }); + + test('With correct toast message on success for exported timelines', async () => { + const testProps = { + ...defaultTestProps, + }; + + await act(() => { + wrapper = mount(); + }); + + wrapper.update(); + + expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINES + ); + }); + + test('With correct toast message on success for exported templates', async () => { + const testProps = { + ...defaultTestProps, + }; + (useParams as jest.Mock).mockReturnValue({ tabName: 'template' }); + + await act(() => { + wrapper = mount(); + }); + + wrapper.update(); + + expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINES + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx index e6eec7198d11..cc62d29cde34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx @@ -6,12 +6,15 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; +import { useParams } from 'react-router-dom'; + import { GenericDownloader, ExportSelectedData, } from '../../../../common/components/generic_downloader'; import * as i18n from '../translations'; import { useStateToaster } from '../../../../common/components/toasters'; +import { TimelineType } from '../../../../../common/types/timeline'; const ExportTimeline: React.FC<{ exportedIds: string[] | undefined; @@ -20,22 +23,28 @@ const ExportTimeline: React.FC<{ onComplete?: () => void; }> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { const [, dispatchToaster] = useStateToaster(); + const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); + const onExportSuccess = useCallback( (exportCount) => { if (onComplete != null) { onComplete(); } + dispatchToaster({ type: 'addToaster', toast: { id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportCount) + : i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), color: 'success', iconType: 'check', }, }); }, - [dispatchToaster, onComplete] + [dispatchToaster, onComplete, timelineType] ); const onExportFailure = useCallback(() => { if (onComplete != null) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index 2d5849463270..07f22bd47a9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -22,6 +22,15 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline' jest.mock('../../../common/lib/kibana'); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + + return { + ...actual, + useParams: jest.fn().mockReturnValue({ tabName: 'default' }), + }; +}); + describe('OpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index bad2fe7e02e1..3f391714bb05 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -270,6 +270,16 @@ export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); +export const SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES = (totalTimelineTemplates: number) => + i18n.translate( + 'xpack.securitySolution.open.timeline.successfullyExportedTimelineTemplatesTitle', + { + values: { totalTimelineTemplates }, + defaultMessage: + 'Successfully exported {totalTimelineTemplates, plural, =0 {all timelines} =1 {{totalTimelineTemplates} timeline template} other {{totalTimelineTemplates} timeline templates}}', + } + ); + export const FILTER_TIMELINES = (timelineType: string) => i18n.translate('xpack.securitySolution.open.timeline.filterByTimelineTypesTitle', { values: { timelineType }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx index 27780c7754d0..df7fd7befb81 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx @@ -29,7 +29,7 @@ export const Pin = React.memo( const isTemplate = timelineType === TimelineType.template; return ( Date: Tue, 6 Oct 2020 13:57:00 -0400 Subject: [PATCH 06/12] Fixes Fleet API path (#79724) --- .../ingest_manager_api_integration/apis/fleet/agents/upgrade.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts index ea254cb5f386..090279eaa7c3 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts @@ -112,7 +112,7 @@ export default function (providerContext: FtrProviderContext) { it('should respond 400 if trying to upgrade an agent that is not upgradeable', async () => { const kibanaVersion = await kibanaServer.version.get(); const res = await supertest - .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .post(`/api/fleet/agents/agent1/upgrade`) .set('kbn-xsrf', 'xxx') .send({ version: kibanaVersion, From 0e89431825d93a9f8968a0b9053cfc09af6223bc Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Tue, 6 Oct 2020 19:20:53 +0100 Subject: [PATCH 07/12] [Visualize Editor] Add cancel button when navigating from Dashboard (#77608) * Add cancel button in the visualize editor * Fixing i18n namespace * Always show cancel button * Always show cancel button * Adding a fucntional test * Show confirm dialog only if there are unsaved changes * Show confirm modal only if there are changes * Add onAppLeave handler and ditch confirmModal * Fix functional test * Only use onAppLeave if coming from dashboard/canvas * Add actions.default to onSave and onSaveAndReturn Co-authored-by: Elastic Machine --- .../application/top_nav/get_top_nav_config.ts | 4 +- .../visualize/public/application/app.tsx | 11 +- .../components/visualize_byvalue_editor.tsx | 6 +- .../components/visualize_editor.tsx | 4 +- .../components/visualize_editor_common.tsx | 4 + .../components/visualize_top_nav.tsx | 37 ++++++- .../visualize/public/application/index.tsx | 7 +- .../application/utils/get_top_nav_config.tsx | 40 +++++++ .../apps/dashboard/edit_visualizations.js | 100 ++++++++++++++++++ test/functional/apps/dashboard/index.js | 1 + .../functional/page_objects/visualize_page.ts | 14 +++ 11 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 test/functional/apps/dashboard/edit_visualizations.js diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 77c4a2235d47..5713996ca9f7 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { AppMountParameters } from 'kibana/public'; import { ViewMode } from '../../embeddable_plugin'; import { TopNavIds } from './top_nav_ids'; import { NavAction } from '../../types'; @@ -31,7 +32,8 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + hideWriteControls: boolean, + onAppLeave?: AppMountParameters['onAppLeave'] ) { switch (dashboardMode) { case ViewMode.VIEW: diff --git a/src/plugins/visualize/public/application/app.tsx b/src/plugins/visualize/public/application/app.tsx index 8dd6b2ace841..bf11cde3115a 100644 --- a/src/plugins/visualize/public/application/app.tsx +++ b/src/plugins/visualize/public/application/app.tsx @@ -21,6 +21,7 @@ import './app.scss'; import React, { useEffect } from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; +import { AppMountParameters } from 'kibana/public'; import { syncQueryStateWithUrl } from '../../../data/public'; import { useKibana } from '../../../kibana_react/public'; import { VisualizeServices } from './types'; @@ -32,7 +33,11 @@ import { } from './components'; import { VisualizeConstants } from './visualize_constants'; -export const VisualizeApp = () => { +export interface VisualizeAppProps { + onAppLeave: AppMountParameters['onAppLeave']; +} + +export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { const { services: { data: { query }, @@ -54,10 +59,10 @@ export const VisualizeApp = () => { return ( - + - + { +export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { const [originatingApp, setOriginatingApp] = useState(); const { services } = useKibana(); const [eventEmitter] = useState(new EventEmitter()); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [embeddableId, setEmbeddableId] = useState(); const [valueInput, setValueInput] = useState(); @@ -100,6 +101,7 @@ export const VisualizeByValueEditor = () => { setHasUnsavedChanges={setHasUnsavedChanges} visEditorRef={visEditorRef} embeddableId={embeddableId} + onAppLeave={onAppLeave} /> ); }; diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx index 6a0bd26a16fa..7c0fa065c3a7 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -32,8 +32,9 @@ import { } from '../utils'; import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; +import { VisualizeAppProps } from '../app'; -export const VisualizeEditor = () => { +export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { const { id: visualizationIdFromUrl } = useParams<{ id: string }>(); const [originatingApp, setOriginatingApp] = useState(); const { services } = useKibana(); @@ -91,6 +92,7 @@ export const VisualizeEditor = () => { visualizationIdFromUrl={visualizationIdFromUrl} setHasUnsavedChanges={setHasUnsavedChanges} visEditorRef={visEditorRef} + onAppLeave={onAppLeave} /> ); }; diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index 545552b90555..947385b05d45 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -20,6 +20,7 @@ import './visualize_editor.scss'; import React, { RefObject } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiScreenReaderOnly } from '@elastic/eui'; +import { AppMountParameters } from 'kibana/public'; import { VisualizeTopNav } from './visualize_top_nav'; import { ExperimentalVisInfo } from './experimental_vis_info'; import { @@ -38,6 +39,7 @@ interface VisualizeEditorCommonProps { setHasUnsavedChanges: (value: boolean) => void; hasUnappliedChanges: boolean; isEmbeddableRendered: boolean; + onAppLeave: AppMountParameters['onAppLeave']; visEditorRef: RefObject; originatingApp?: string; setOriginatingApp?: (originatingApp: string | undefined) => void; @@ -54,6 +56,7 @@ export const VisualizeEditorCommon = ({ setHasUnsavedChanges, hasUnappliedChanges, isEmbeddableRendered, + onAppLeave, originatingApp, setOriginatingApp, visualizationIdFromUrl, @@ -76,6 +79,7 @@ export const VisualizeEditorCommon = ({ stateContainer={appState} visualizationIdFromUrl={visualizationIdFromUrl} embeddableId={embeddableId} + onAppLeave={onAppLeave} /> )} {visInstance?.vis?.type?.stage === 'experimental' && } diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index dfd3c09f51ed..b207529c456a 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -19,7 +19,9 @@ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; -import { OverlayRef } from 'kibana/public'; +import { AppMountParameters, OverlayRef } from 'kibana/public'; +import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices, @@ -43,6 +45,7 @@ interface VisualizeTopNavProps { stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; embeddableId?: string; + onAppLeave: AppMountParameters['onAppLeave']; } const TopNav = ({ @@ -58,10 +61,11 @@ const TopNav = ({ stateContainer, visualizationIdFromUrl, embeddableId, + onAppLeave, }: VisualizeTopNavProps) => { const { services } = useKibana(); const { TopNavMenu } = services.navigation.ui; - const { setHeaderActionMenu } = services; + const { setHeaderActionMenu, visualizeCapabilities } = services; const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); const openInspector = useCallback(() => { @@ -93,6 +97,7 @@ const TopNav = ({ visualizationIdFromUrl, stateTransfer, embeddableId, + onAppLeave, }, services ); @@ -111,6 +116,7 @@ const TopNav = ({ services, embeddableId, stateTransfer, + onAppLeave, ]); const [indexPattern, setIndexPattern] = useState(vis.data.indexPattern); const showDatePicker = () => { @@ -131,6 +137,33 @@ const TopNav = ({ }; }, [inspectorSession]); + useEffect(() => { + onAppLeave((actions) => { + // Confirm when the user has made any changes to an existing visualizations + // or when the user has configured something without saving + if ( + ((originatingApp && originatingApp === 'dashboards') || originatingApp === 'canvas') && + (hasUnappliedChanges || hasUnsavedChanges) + ) { + return actions.confirm( + i18n.translate('visualize.confirmModal.confirmTextDescription', { + defaultMessage: 'Leave Visualize editor with unsaved changes?', + }), + i18n.translate('visualize.confirmModal.title', { + defaultMessage: 'Unsaved changes', + }) + ); + } + return actions.default(); + }); + }, [ + onAppLeave, + hasUnappliedChanges, + hasUnsavedChanges, + visualizeCapabilities.save, + originatingApp, + ]); + useEffect(() => { if (!vis.data.indexPattern) { services.data.indexPatterns.getDefault().then((index) => { diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx index 4bec244e6efc..1067fe613e46 100644 --- a/src/plugins/visualize/public/application/index.tsx +++ b/src/plugins/visualize/public/application/index.tsx @@ -27,7 +27,10 @@ import { VisualizeApp } from './app'; import { VisualizeServices } from './types'; import { addHelpMenuToAppChrome, addBadgeToAppChrome } from './utils'; -export const renderApp = ({ element }: AppMountParameters, services: VisualizeServices) => { +export const renderApp = ( + { element, onAppLeave }: AppMountParameters, + services: VisualizeServices +) => { // add help link to visualize docs into app chrome menu addHelpMenuToAppChrome(services.chrome, services.docLinks); // add readonly badge if saving restricted @@ -39,7 +42,7 @@ export const renderApp = ({ element }: AppMountParameters, services: VisualizeSe - + diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index cb68a647cb81..eadf404daf91 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -21,6 +21,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { TopNavMenuData } from 'src/plugins/navigation/public'; +import { AppMountParameters } from 'kibana/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { showSaveModal, @@ -51,6 +52,7 @@ interface TopNavConfigParams { visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; embeddableId?: string; + onAppLeave: AppMountParameters['onAppLeave']; } export const getTopNavConfig = ( @@ -66,6 +68,7 @@ export const getTopNavConfig = ( visualizationIdFromUrl, stateTransfer, embeddableId, + onAppLeave, }: TopNavConfigParams, { application, @@ -174,6 +177,12 @@ export const getTopNavConfig = ( stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { state }); }; + const navigateToOriginatingApp = () => { + if (originatingApp) { + application.navigateToApp(originatingApp); + } + }; + const topNavMenu: TopNavMenuData[] = [ { id: 'inspector', @@ -225,6 +234,31 @@ export const getTopNavConfig = ( // disable the Share button if no action specified disableButton: !share || !!embeddableId, }, + ...(originatingApp === 'dashboards' || originatingApp === 'canvas' + ? [ + { + id: 'cancel', + label: i18n.translate('visualize.topNavMenu.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + emphasize: false, + description: i18n.translate('visualize.topNavMenu.cancelButtonAriaLabel', { + defaultMessage: 'Return to the last app without saving changes', + }), + testId: 'visualizeCancelAndReturnButton', + tooltip() { + if (hasUnappliedChanges || hasUnsavedChanges) { + return i18n.translate('visualize.topNavMenu.cancelAndReturnButtonTooltip', { + defaultMessage: 'Discard your changes before finishing', + }); + } + }, + run: async () => { + return navigateToOriginatingApp(); + }, + }, + ] + : []), ...(visualizeCapabilities.save && !embeddableId ? [ { @@ -297,6 +331,9 @@ export const getTopNavConfig = ( /> ); const isSaveAsButton = anchorElement.classList.contains('saveAsButton'); + onAppLeave((actions) => { + return actions.default(); + }); if ( originatingApp === 'dashboards' && dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && @@ -342,6 +379,9 @@ export const getTopNavConfig = ( confirmOverwrite: false, returnToOrigin: true, }; + onAppLeave((actions) => { + return actions.default(); + }); if ( originatingApp === 'dashboards' && dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables && diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js new file mode 100644 index 000000000000..a9bd2e87bcad --- /dev/null +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -0,0 +1,100 @@ +/* + * 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 expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'visEditor']); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardVisualizations = getService('dashboardVisualizations'); + + const originalMarkdownText = 'Original markdown text'; + const modifiedMarkdownText = 'Modified markdown text'; + + const createMarkdownVis = async (title) => { + await testSubjects.click('dashboardAddNewPanelButton'); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visEditor.setMarkdownTxt(originalMarkdownText); + await PageObjects.visEditor.clickGo(); + await PageObjects.visualize.saveVisualizationExpectSuccess(title, { + saveAsNew: true, + redirectToOrigin: true, + }); + }; + + const editMarkdownVis = async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visEditor.setMarkdownTxt(modifiedMarkdownText); + await PageObjects.visEditor.clickGo(); + }; + + describe('edit visualizations from dashboard', () => { + before(async () => { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + }); + + it('save button returns to dashboard after editing visualization with changes saved', async () => { + const title = 'test save'; + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(title); + + await editMarkdownVis(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(modifiedMarkdownText); + }); + + it('cancel button returns to dashboard after editing visualization without saving', async () => { + const title = 'test cancel'; + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(title); + + await editMarkdownVis(); + await PageObjects.visualize.cancelAndReturn(true); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); + }); + + it('cancel button returns to dashboard with no modal if there are no changes to apply', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.visualize.cancelAndReturn(false); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); + }); + }); +} diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index de4b3df9c40e..f722e91dc0ae 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -53,6 +53,7 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); loadTestFile(require.resolve('./edit_embeddable_redirects')); + loadTestFile(require.resolve('./edit_visualizations')); loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); loadTestFile(require.resolve('./data_shared_attributes')); diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 6d94c3e581d6..9619c81370cd 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -363,6 +363,20 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide await header.waitUntilLoadingHasFinished(); await testSubjects.missingOrFail('visualizesaveAndReturnButton'); } + + public async cancelAndReturn(showConfirmModal: boolean) { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('visualizeCancelAndReturnButton'); + await testSubjects.click('visualizeCancelAndReturnButton'); + if (showConfirmModal) { + await retry.waitFor( + 'confirm modal to show', + async () => await testSubjects.exists('appLeaveConfirmModal') + ); + await testSubjects.exists('confirmModalConfirmButton'); + await testSubjects.click('confirmModalConfirmButton'); + } + } } return new VisualizePage(); From 4c65b6dda4fb309dde68cf76bd7ad093ac1d3023 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 6 Oct 2020 14:37:47 -0400 Subject: [PATCH 08/12] [ML] DF Analytics results view: ensure boolean values in charts shown without formatting (#78888) * add functional test for searchBar filters. remove boolean schema from datagrid * always use string version of key for charts * add schema back in for histogram label * use ?? instead of || for undefined check --- .../application/components/data_grid/column_chart.tsx | 2 +- .../application/components/data_grid/use_column_chart.tsx | 4 +++- .../exploration_query_bar/exploration_query_bar.tsx | 6 ++++-- .../apps/ml/data_frame_analytics/classification_creation.ts | 1 + .../apps/ml/data_frame_analytics/regression_creation.ts | 1 + .../functional/services/ml/data_frame_analytics_results.ts | 6 ++++++ 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx index a3a67fbb8bb7..97fac052df1f 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx @@ -51,7 +51,7 @@ export const ColumnChart: FC = ({ chartData, columnType, dataTestSubj }) name="count" xScaleType={xScaleType} yScaleType="linear" - xAccessor="key" + xAccessor={'key_as_string'} yAccessors={['doc_count']} styleAccessor={(d) => d.datum.color} data={data} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx index 6b5fbbb22120..a3169dc14a3a 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -67,7 +67,7 @@ export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYP interface NumericDataItem { key: number; - key_as_string?: string; + key_as_string?: string | number; doc_count: number; } @@ -231,11 +231,13 @@ export const useColumnChart = ( if (isOrdinalChartData(chartData)) { data = chartData.data.map((d: OrdinalDataItem) => ({ ...d, + key_as_string: d.key_as_string ?? d.key, color: getColor(d), })); } else if (isNumericChartData(chartData)) { data = chartData.data.map((d: NumericDataItem) => ({ ...d, + key_as_string: d.key_as_string || d.key, color: getColor(d), })); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx index c2f3e71e2e49..06bcdfd364d6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -163,7 +163,10 @@ export const ExplorationQueryBar: FC = ({ /> {filters && filters.options && ( - + = ({ } )} name="analyticsQueryBarFilterButtons" - data-test-subj="mlDFAnalyticsExplorationQueryBarFilterButtons" options={filters.options} type="multi" idToSelectedMap={idToSelectedMap} diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index e12c853a0be8..5ff2039ac80e 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -205,6 +205,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertClassificationEvaluatePanelElementsExists(); await ml.dataFrameAnalyticsResults.assertClassificationTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); }); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index c58220f2d1ad..dad3d990cfca 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -205,6 +205,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertRegressionEvaluatePanelElementsExists(); await ml.dataFrameAnalyticsResults.assertRegressionTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); }); }); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index a4d8fa8e692b..8781a2cd216f 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -43,6 +43,12 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ await testSubjects.existOrFail('mlExplorationDataGrid loaded', { timeout: 5000 }); }, + async assertResultsTableTrainingFiltersExist() { + await testSubjects.existOrFail('mlDFAnalyticsExplorationQueryBarFilterButtons', { + timeout: 5000, + }); + }, + async getResultTableRows() { return await testSubjects.findAll('mlExplorationDataGrid loaded > dataGridRow'); }, From e31ec7eb547b9743c106a9801ebe2c88086f59b9 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 6 Oct 2020 20:40:28 +0200 Subject: [PATCH 09/12] Give user the option to log out if they encounter a 403 (#75538) --- .../core/server/kibana-plugin-core-server.md | 1 + ...in-core-server.onpreresponserender.body.md | 13 ++ ...core-server.onpreresponserender.headers.md | 13 ++ ...-plugin-core-server.onpreresponserender.md | 21 +++ ...plugin-core-server.onpreresponsetoolkit.md | 1 + ...core-server.onpreresponsetoolkit.render.md | 13 ++ src/core/server/http/http_server.mocks.ts | 1 + src/core/server/http/http_service.mock.ts | 1 + src/core/server/http/index.ts | 1 + .../http/integration_tests/lifecycle.test.ts | 61 +++++++++ .../server/http/lifecycle/on_pre_response.ts | 79 ++++++++--- src/core/server/index.ts | 1 + src/core/server/server.api.md | 7 + test/functional/page_objects/common_page.ts | 7 +- test/functional/page_objects/error_page.ts | 10 +- .../authentication/can_redirect_request.ts | 6 +- .../reset_session_page.test.tsx.snap | 3 + .../authorization/api_authorization.test.ts | 4 +- .../server/authorization/api_authorization.ts | 4 +- .../authorization/app_authorization.test.ts | 2 +- .../server/authorization/app_authorization.ts | 2 +- .../authorization_service.test.ts | 3 + ...n_service.ts => authorization_service.tsx} | 40 ++++++ .../authorization/reset_session_page.test.tsx | 27 ++++ .../authorization/reset_session_page.tsx | 128 ++++++++++++++++++ x-pack/plugins/security/server/plugin.ts | 1 + .../server/routes/authorization/index.ts | 2 + .../authorization/reset_session_page.ts | 28 ++++ .../on_post_auth_interceptor.ts | 30 ++-- .../apis/console/feature_controls.ts | 4 +- .../apis/features/features/features.ts | 4 +- .../apis/metrics_ui/feature_controls.ts | 10 +- .../apis/ml/annotations/create_annotations.ts | 6 +- .../apis/ml/annotations/delete_annotations.ts | 6 +- .../apis/ml/annotations/get_annotations.ts | 6 +- .../apis/ml/annotations/update_annotations.ts | 6 +- .../apis/ml/anomaly_detectors/create.ts | 8 +- .../apis/ml/anomaly_detectors/get.ts | 24 ++-- .../apis/ml/calendars/create_calendars.ts | 12 +- .../apis/ml/calendars/delete_calendars.ts | 8 +- .../apis/ml/calendars/get_calendars.ts | 8 +- .../apis/ml/calendars/update_calendars.ts | 10 +- .../apis/ml/data_frame_analytics/delete.ts | 12 +- .../apis/ml/data_frame_analytics/get.ts | 24 ++-- .../apis/ml/data_frame_analytics/update.ts | 12 +- .../apis/ml/filters/create_filters.ts | 12 +- .../apis/ml/filters/delete_filters.ts | 8 +- .../apis/ml/filters/get_filters.ts | 12 +- .../apis/ml/filters/update_filters.ts | 8 +- .../apis/ml/job_validation/cardinality.ts | 6 +- .../apis/ml/job_validation/validate.ts | 6 +- .../apis/ml/jobs/close_jobs.ts | 8 +- .../apis/ml/jobs/delete_jobs.ts | 8 +- .../apis/ml/jobs/jobs_exist.ts | 4 +- .../apis/ml/jobs/jobs_summary.ts | 4 +- .../apis/ml/modules/setup_module.ts | 6 +- .../ml/results/get_anomalies_table_data.ts | 6 +- .../apis/ml/results/get_categorizer_stats.ts | 12 +- .../apis/ml/results/get_stopped_partitions.ts | 6 +- .../security_solution/feature_controls.ts | 10 +- .../apis/uptime/feature_controls.ts | 10 +- .../basic/tests/feature_controls.ts | 38 +++--- .../tests/settings/agent_configuration.ts | 6 +- .../anomaly_detection/no_access_user.ts | 8 +- .../settings/anomaly_detection/read_user.ts | 4 +- .../anomaly_detection/no_access_user.ts | 8 +- .../settings/anomaly_detection/read_user.ts | 4 +- .../tests/encrypted_saved_objects_api.ts | 2 +- .../apps/apm/feature_controls/apm_security.ts | 4 +- .../feature_controls/canvas_security.ts | 24 +--- .../canvas/feature_controls/canvas_spaces.ts | 4 +- .../feature_controls/dashboard_security.ts | 16 +-- .../feature_controls/dev_tools_security.ts | 12 +- .../feature_controls/discover_security.ts | 4 +- .../graph/feature_controls/graph_security.ts | 4 +- .../infrastructure_security.ts | 13 +- .../feature_controls/infrastructure_spaces.ts | 2 +- .../infra/feature_controls/logs_security.ts | 13 +- .../infra/feature_controls/logs_spaces.ts | 2 +- .../maps/feature_controls/maps_security.ts | 13 +- .../apps/maps/feature_controls/maps_spaces.ts | 2 +- .../apps/ml/permissions/no_ml_access.ts | 2 +- .../feature_controls/timelion_security.ts | 20 +-- .../feature_controls/timelion_spaces.ts | 6 +- .../feature_controls/uptime_security.ts | 4 +- .../feature_controls/visualize_security.ts | 8 +- .../functional/page_objects/security_page.ts | 4 +- .../apis/fleet/agents/delete.ts | 4 +- .../apis/fleet/agents/list.ts | 2 +- .../common/suites/bulk_create.ts | 6 +- .../common/suites/bulk_get.ts | 6 +- .../common/suites/bulk_update.ts | 6 +- .../common/suites/create.ts | 4 +- .../common/suites/delete.ts | 4 +- .../common/suites/export.ts | 4 +- .../common/suites/get.ts | 4 +- .../common/suites/import.ts | 6 +- .../common/suites/resolve_import_errors.ts | 6 +- .../common/suites/update.ts | 4 +- .../security_and_spaces/apis/bulk_create.ts | 12 +- .../security_and_spaces/apis/bulk_get.ts | 4 +- .../security_and_spaces/apis/bulk_update.ts | 11 +- .../security_and_spaces/apis/import.ts | 10 +- .../apis/resolve_import_errors.ts | 18 ++- .../security_only/apis/bulk_create.ts | 12 +- .../security_only/apis/bulk_get.ts | 4 +- .../security_only/apis/bulk_update.ts | 11 +- .../security_only/apis/import.ts | 10 +- .../apis/resolve_import_errors.ts | 18 ++- .../common/suites/copy_to_space.ts | 33 +++-- .../suites/resolve_copy_to_space_conflicts.ts | 41 ++++-- .../security_and_spaces/apis/copy_to_space.ts | 21 +-- .../apis/resolve_copy_to_space_conflicts.ts | 22 +-- 113 files changed, 862 insertions(+), 444 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md create mode 100644 x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap rename x-pack/plugins/security/server/authorization/{authorization_service.ts => authorization_service.tsx} (80%) create mode 100644 x-pack/plugins/security/server/authorization/reset_session_page.test.tsx create mode 100644 x-pack/plugins/security/server/authorization/reset_session_page.tsx create mode 100644 x-pack/plugins/security/server/routes/authorization/reset_session_page.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index be8b7c27495a..a484c856ec01 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -121,6 +121,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | +| [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) | Additional data to extend a response when rendering a new body | | [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreResponse interceptor for incoming request. | | [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md new file mode 100644 index 000000000000..ab5b5e7a4f27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.body.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [body](./kibana-plugin-core-server.onpreresponserender.body.md) + +## OnPreResponseRender.body property + +the body to use in the response + +Signature: + +```typescript +body: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md new file mode 100644 index 000000000000..100d12f63d16 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) > [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) + +## OnPreResponseRender.headers property + +additional headers to attach to the response + +Signature: + +```typescript +headers?: ResponseHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md new file mode 100644 index 000000000000..0a7ce2d54670 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponserender.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseRender](./kibana-plugin-core-server.onpreresponserender.md) + +## OnPreResponseRender interface + +Additional data to extend a response when rendering a new body + +Signature: + +```typescript +export interface OnPreResponseRender +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [body](./kibana-plugin-core-server.onpreresponserender.body.md) | string | the body to use in the response | +| [headers](./kibana-plugin-core-server.onpreresponserender.headers.md) | ResponseHeaders | additional headers to attach to the response | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 44da09d0cc68..14070038132d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -17,4 +17,5 @@ export interface OnPreResponseToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreresponsetoolkit.next.md) | (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult | To pass request to the next handler | +| [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) | (responseRender: OnPreResponseRender) => OnPreResponseResult | To override the response with a different body | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md new file mode 100644 index 000000000000..7dced7fe8dee --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.render.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) > [render](./kibana-plugin-core-server.onpreresponsetoolkit.render.md) + +## OnPreResponseToolkit.render property + +To override the response with a different body + +Signature: + +```typescript +render: (responseRender: OnPreResponseRender) => OnPreResponseResult; +``` diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 9deaa73d8aac..6aad232cf42b 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -175,6 +175,7 @@ type ToolkitMock = jest.Mocked { return { + render: jest.fn(), next: jest.fn(), rewriteUrl: jest.fn(), }; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index f81708145edc..df837dc35505 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -198,6 +198,7 @@ const createAuthToolkitMock = (): jest.Mocked => ({ }); const createOnPreResponseToolkitMock = (): jest.Mocked => ({ + render: jest.fn(), next: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 7513e6096608..cb842b2f6026 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -83,6 +83,7 @@ export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, } from './lifecycle/on_pre_response'; diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index b9548bf7a8d7..59090d101acb 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -1286,6 +1286,67 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); + + it('supports rendering a different response body', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ body: 'overridden' }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('A'); + }); + + it('supports rendering a different response body + headers', async () => { + const { registerOnPreResponse, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ + headers: { + 'Original-Header-A': 'A', + 'Original-Header-B': 'B', + }, + body: 'original', + }); + }); + + registerOnPreResponse((req, res, t) => { + return t.render({ + headers: { + 'Original-Header-A': 'AA', + 'New-Header-C': 'C', + }, + body: 'overridden', + }); + }); + + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(200, 'overridden'); + + expect(result.header['original-header-a']).toBe('AA'); + expect(result.header['original-header-b']).toBe('B'); + expect(result.header['new-header-c']).toBe('C'); + }); }); describe('run interceptors in the right order', () => { diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 4d1b53313a51..37dddf4dd476 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -17,16 +17,23 @@ * under the License. */ -import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Lifecycle, Request, ResponseObject, ResponseToolkit as HapiResponseToolkit } from 'hapi'; import Boom from 'boom'; import { Logger } from '../../logging'; import { HapiResponseAdapter, KibanaRequest, ResponseHeaders } from '../router'; enum ResultType { + render = 'render', next = 'next', } +interface Render { + type: ResultType.render; + body: string; + headers?: ResponseHeaders; +} + interface Next { type: ResultType.next; headers?: ResponseHeaders; @@ -35,7 +42,18 @@ interface Next { /** * @internal */ -type OnPreResponseResult = Next; +type OnPreResponseResult = Render | Next; + +/** + * Additional data to extend a response when rendering a new body + * @public + */ +export interface OnPreResponseRender { + /** additional headers to attach to the response */ + headers?: ResponseHeaders; + /** the body to use in the response */ + body: string; +} /** * Additional data to extend a response. @@ -55,6 +73,12 @@ export interface OnPreResponseInfo { } const preResponseResult = { + render(responseRender: OnPreResponseRender): OnPreResponseResult { + return { type: ResultType.render, body: responseRender.body, headers: responseRender?.headers }; + }, + isRender(result: OnPreResponseResult): result is Render { + return result && result.type === ResultType.render; + }, next(responseExtensions?: OnPreResponseExtensions): OnPreResponseResult { return { type: ResultType.next, headers: responseExtensions?.headers }; }, @@ -68,11 +92,14 @@ const preResponseResult = { * @public */ export interface OnPreResponseToolkit { + /** To override the response with a different body */ + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; /** To pass request to the next handler */ next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } const toolkit: OnPreResponseToolkit = { + render: preResponseResult.render, next: preResponseResult.next, }; @@ -106,26 +133,36 @@ export function adoptToHapiOnPreResponseFormat(fn: OnPreResponseHandler, log: Lo : response.statusCode; const result = await fn(KibanaRequest.from(request), { statusCode }, toolkit); - if (!preResponseResult.isNext(result)) { + + if (preResponseResult.isNext(result)) { + if (result.headers) { + if (isBoom(response)) { + findHeadersIntersection(response.output.headers, result.headers, log); + // hapi wraps all error response in Boom object internally + response.output.headers = { + ...response.output.headers, + ...(result.headers as any), // hapi types don't specify string[] as valid value + }; + } else { + findHeadersIntersection(response.headers, result.headers, log); + setHeaders(response, result.headers); + } + } + } else if (preResponseResult.isRender(result)) { + const overriddenResponse = responseToolkit.response(result.body).code(statusCode); + + const originalHeaders = isBoom(response) ? response.output.headers : response.headers; + setHeaders(overriddenResponse, originalHeaders); + if (result.headers) { + setHeaders(overriddenResponse, result.headers); + } + + return overriddenResponse; + } else { throw new Error( `Unexpected result from OnPreResponse. Expected OnPreResponseResult, but given: ${result}.` ); } - if (result.headers) { - if (isBoom(response)) { - findHeadersIntersection(response.output.headers, result.headers, log); - // hapi wraps all error response in Boom object internally - response.output.headers = { - ...response.output.headers, - ...(result.headers as any), // hapi types don't specify string[] as valid value - }; - } else { - findHeadersIntersection(response.headers, result.headers, log); - for (const [headerName, headerValue] of Object.entries(result.headers)) { - response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value - } - } - } } } catch (error) { log.error(error); @@ -140,6 +177,12 @@ function isBoom(response: any): response is Boom { return response instanceof Boom; } +function setHeaders(response: ResponseObject, headers: ResponseHeaders) { + for (const [headerName, headerValue] of Object.entries(headers)) { + response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value + } +} + // NOTE: responseHeaders contains not a full list of response headers, but only explicitly set on a response object. // any headers added by hapi internally, like `content-type`, `content-length`, etc. are not present here. function findHeadersIntersection( diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 887dc50d5f78..fc091bd17bdf 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -173,6 +173,7 @@ export { OnPostAuthToolkit, OnPreResponseHandler, OnPreResponseToolkit, + OnPreResponseRender, OnPreResponseExtensions, OnPreResponseInfo, RedirectResponseOptions, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index a718ae8a6ff1..a877700a48bc 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1530,9 +1530,16 @@ export interface OnPreResponseInfo { statusCode: number; } +// @public +export interface OnPreResponseRender { + body: string; + headers?: ResponseHeaders; +} + // @public export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; + render: (responseRender: OnPreResponseRender) => OnPreResponseResult; } // Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 459f596b3025..41667e1f26c8 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -434,7 +434,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } - async getBodyText() { + async getJsonBodyText() { if (await find.existsByCssSelector('a[id=rawdata-tab]', defaultFindTimeout)) { // Firefox has 3 tabs and requires navigation to see Raw output await find.clickByCssSelector('a[id=rawdata-tab]'); @@ -449,6 +449,11 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } } + async getBodyText() { + const body = await find.byCssSelector('body'); + return await body.getVisibleText(); + } + /** * Helper to detect an OSS licensed Kibana * Useful for functional testing in cloud environment diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 332ce835d0b1..bc256f55155d 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -26,17 +26,11 @@ export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { class ErrorPage { public async expectForbidden() { const messageText = await common.getBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 403, - error: 'Forbidden', - message: 'Forbidden', - }) - ); + expect(messageText).to.contain('You do not have permission to access the requested page'); } public async expectNotFound() { - const messageText = await common.getBodyText(); + const messageText = await common.getJsonBodyText(); expect(messageText).to.eql( JSON.stringify({ statusCode: 404, diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index 7e2b2e5eaf9f..4dd89a4990d6 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -17,10 +17,14 @@ const KIBANA_VERSION_HEADER = 'kbn-version'; */ export function canRedirectRequest(request: KibanaRequest) { const headers = request.headers; + const route = request.route; const hasVersionHeader = headers.hasOwnProperty(KIBANA_VERSION_HEADER); const hasXsrfHeader = headers.hasOwnProperty(KIBANA_XSRF_HEADER); - const isApiRoute = request.route.options.tags.includes(ROUTE_TAG_API); + const isApiRoute = + route.options.tags.includes(ROUTE_TAG_API) || + (route.path.startsWith('/api/') && route.path !== '/api/security/logout') || + route.path.startsWith('/internal/'); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; return !isApiRoute && !isAjaxRequest; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap new file mode 100644 index 000000000000..7ff1dbfcb1a4 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index d4ec9a0e0db5..22336a7db9a3 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -100,7 +100,7 @@ describe('initAPIAuthorization', () => { expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); - test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { + test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 403`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); @@ -129,7 +129,7 @@ describe('initAPIAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: [mockAuthz.actions.api.get('foo')], diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 9129330ec947..813ed8d064d9 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -37,7 +37,7 @@ export function initAPIAuthorization( return toolkit.next(); } - logger.warn(`User not authorized for "${request.url.path}": responding with 404`); - return response.notFound(); + logger.warn(`User not authorized for "${request.url.path}": responding with 403`); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index f40d502a9cd7..f035e6eaa365 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -170,7 +170,7 @@ describe('initAppAuthorization', () => { await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); - expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockResponse.forbidden).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index 4170fd2cdb38..713266fc3b5c 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -73,6 +73,6 @@ export function initAppAuthorization( } logger.debug(`not authorized for "${appId}"`); - return response.notFound(); + return response.forbidden(); }); } diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index c00127f7d122..33abc22fdf09 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -72,6 +72,7 @@ it(`#setup returns exposed services`, () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: mockFeaturesSetup, getSpacesService: mockGetSpacesService, getCurrentUser: jest.fn(), @@ -130,6 +131,7 @@ describe('#start', () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() @@ -201,6 +203,7 @@ it('#stop unsubscribes from license and ES updates.', async () => { loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', + buildNumber: 42, features: featuresPluginMock.createSetup(), getSpacesService: jest .fn() diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.tsx similarity index 80% rename from x-pack/plugins/security/server/authorization/authorization_service.ts rename to x-pack/plugins/security/server/authorization/authorization_service.tsx index fd3a60fb4d90..9547295af4df 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import querystring from 'querystring'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { Subscription, Observable } from 'rxjs'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; + import type { Capabilities as UICapabilities } from '../../../../../src/core/types'; + import { LoggerFactory, KibanaRequest, @@ -43,6 +50,8 @@ import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; import { CheckPrivilegesWithRequest } from './types'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { canRedirectRequest } from '../authentication'; +import { ResetSessionPage } from './reset_session_page'; import { AuthenticatedUser } from '..'; export { Actions } from './actions'; @@ -51,6 +60,7 @@ export { featurePrivilegeIterator } from './privileges'; interface AuthorizationServiceSetupParams { packageVersion: string; + buildNumber: number; http: HttpServiceSetup; capabilities: CapabilitiesSetup; clusterClient: ILegacyClusterClient; @@ -89,6 +99,7 @@ export class AuthorizationService { http, capabilities, packageVersion, + buildNumber, clusterClient, license, loggers, @@ -154,6 +165,35 @@ export class AuthorizationService { initAPIAuthorization(http, authz, loggers.get('api-authorization')); initAppAuthorization(http, authz, loggers.get('app-authorization'), features); + http.registerOnPreResponse((request, preResponse, toolkit) => { + if (preResponse.statusCode === 403 && canRedirectRequest(request)) { + const basePath = http.basePath.get(request); + const next = `${basePath}${request.url.path}`; + const regularBundlePath = `${basePath}/${buildNumber}/bundles`; + + const logoutUrl = http.basePath.prepend( + `/api/security/logout?${querystring.stringify({ next })}` + ); + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath}/ui/legacy_light_theme.css`, + ]; + + const body = renderToStaticMarkup( + + ); + + return toolkit.render({ body, headers: { 'Content-Security-Policy': http.csp.header } }); + } + return toolkit.next(); + }); + return authz; } diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx new file mode 100644 index 000000000000..5a15f4603cfc --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ResetSessionPage } from './reset_session_page'; + +jest.mock('../../../../../src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('ResetSessionPage', () => { + it('renders as expected', async () => { + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx new file mode 100644 index 000000000000..5ab6fe941ae1 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +// @ts-expect-error no definitions in component folder +import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; + +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +export function ResetSessionPage({ + logoutUrl, + styleSheetPaths, + basePath, +}: { + logoutUrl: string; + styleSheetPaths: string[]; + basePath: string; +}) { + const uiPublicUrl = `${basePath}/ui`; + return ( + + + {styleSheetPaths.map((path) => ( + + ))} + + + + + + + +