diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f053c6da9c29b..2ad82ded6cb38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,29 +24,20 @@ /src/plugins/vis_type_xy/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app -# Core UI -# Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon -/src/plugins/home/public @elastic/kibana-core-ui -/src/plugins/home/server/*.ts @elastic/kibana-core-ui -/src/plugins/home/server/services/ @elastic/kibana-core-ui -# Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon -/src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/*.scss @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui - # App Architecture +/examples/bfetch_explorer/ @elastic/kibana-app-arch +/examples/dashboard_embeddable_examples/ @elastic/kibana-app-arch +/examples/demo_search/ @elastic/kibana-app-arch /examples/developer_examples/ @elastic/kibana-app-arch +/examples/embeddable_examples/ @elastic/kibana-app-arch +/examples/embeddable_explorer/ @elastic/kibana-app-arch +/examples/state_container_examples/ @elastic/kibana-app-arch +/examples/ui_actions_examples/ @elastic/kibana-app-arch +/examples/ui_actions_explorer/ @elastic/kibana-app-arch /examples/url_generators_examples/ @elastic/kibana-app-arch /examples/url_generators_explorer/ @elastic/kibana-app-arch -/packages/kbn-interpreter/ @elastic/kibana-app-arch /packages/elastic-datemath/ @elastic/kibana-app-arch -/src/legacy/core_plugins/embeddable_api/ @elastic/kibana-app-arch -/src/legacy/core_plugins/interpreter/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana_react/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana/public/management/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana/server/routes/api/management/ @elastic/kibana-app-arch -/src/legacy/core_plugins/visualizations/ @elastic/kibana-app-arch -/src/legacy/server/index_patterns/ @elastic/kibana-app-arch +/packages/kbn-interpreter/ @elastic/kibana-app-arch /src/plugins/advanced_settings/ @elastic/kibana-app-arch /src/plugins/bfetch/ @elastic/kibana-app-arch /src/plugins/data/ @elastic/kibana-app-arch @@ -61,9 +52,10 @@ /src/plugins/share/ @elastic/kibana-app-arch /src/plugins/ui_actions/ @elastic/kibana-app-arch /src/plugins/visualizations/ @elastic/kibana-app-arch -/x-pack/plugins/advanced_ui_actions/ @elastic/kibana-app-arch +/x-pack/examples/ui_actions_enhanced_examples/ @elastic/kibana-app-arch /x-pack/plugins/data_enhanced/ @elastic/kibana-app-arch -/x-pack/plugins/drilldowns/ @elastic/kibana-app-arch +/x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-arch +/x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-arch # APM /x-pack/plugins/apm/ @elastic/apm-ui @@ -79,6 +71,16 @@ /x-pack/plugins/canvas/ @elastic/kibana-canvas /x-pack/test/functional/apps/canvas/ @elastic/kibana-canvas +# Core UI +# Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon +/src/plugins/home/public @elastic/kibana-core-ui +/src/plugins/home/server/*.ts @elastic/kibana-core-ui +/src/plugins/home/server/services/ @elastic/kibana-core-ui +# Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon +/src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui +/src/legacy/core_plugins/kibana/public/home/*.scss @elastic/kibana-core-ui +/src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui + # Observability UIs /x-pack/legacy/plugins/infra/ @elastic/logs-metrics-ui /x-pack/plugins/infra/ @elastic/logs-metrics-ui diff --git a/packages/kbn-optimizer/src/report_optimizer_stats.ts b/packages/kbn-optimizer/src/report_optimizer_stats.ts index 2f92f3d648ab7..5057c717efcc3 100644 --- a/packages/kbn-optimizer/src/report_optimizer_stats.ts +++ b/packages/kbn-optimizer/src/report_optimizer_stats.ts @@ -35,6 +35,8 @@ interface Entry { stats: Fs.Stats; } +const IGNORED_EXTNAME = ['.map', '.br', '.gz']; + const getFiles = (dir: string, parent?: string) => flatten( Fs.readdirSync(dir).map((name): Entry | Entry[] => { @@ -51,7 +53,19 @@ const getFiles = (dir: string, parent?: string) => stats, }; }) - ); + ).filter((file) => { + const filename = Path.basename(file.relPath); + if (filename.startsWith('.')) { + return false; + } + + const ext = Path.extname(filename); + if (IGNORED_EXTNAME.includes(ext)) { + return false; + } + + return true; + }); export function reportOptimizerStats(reporter: CiStatsReporter, config: OptimizerConfig) { return pipeClosure((update$: OptimizerUpdate$) => { @@ -70,10 +84,7 @@ export function reportOptimizerStats(reporter: CiStatsReporter, config: Optimize // make the cache read from the cache file since it was likely updated by the worker bundle.cache.refresh(); - const outputFiles = getFiles(bundle.outputDir).filter( - (file) => !(file.relPath.startsWith('.') || file.relPath.endsWith('.map')) - ); - + const outputFiles = getFiles(bundle.outputDir); const entryName = `${bundle.id}.${bundle.type}.js`; const entry = outputFiles.find((f) => f.relPath === entryName); if (!entry) { diff --git a/src/plugins/data/common/field_formats/converters/url.test.ts b/src/plugins/data/common/field_formats/converters/url.test.ts index 5ee195f8c7752..771bde85626d0 100644 --- a/src/plugins/data/common/field_formats/converters/url.test.ts +++ b/src/plugins/data/common/field_formats/converters/url.test.ts @@ -167,8 +167,8 @@ describe('UrlFormat', () => { }); }); - describe('whitelist', () => { - test('should assume a relative url if the value is not in the whitelist without a base path', () => { + describe('allow-list', () => { + test('should assume a relative url if the value is not in the allow-list without a base path', () => { const parsedUrl = { origin: 'http://kibana', basePath: '', @@ -193,7 +193,7 @@ describe('UrlFormat', () => { ); }); - test('should assume a relative url if the value is not in the whitelist with a basepath', () => { + test('should assume a relative url if the value is not in the allow-list with a basepath', () => { const parsedUrl = { origin: 'http://kibana', basePath: '/xyz', diff --git a/src/plugins/data/common/field_formats/converters/url.ts b/src/plugins/data/common/field_formats/converters/url.ts index b797159b53486..2630c97b0821b 100644 --- a/src/plugins/data/common/field_formats/converters/url.ts +++ b/src/plugins/data/common/field_formats/converters/url.ts @@ -161,8 +161,8 @@ export class UrlFormat extends FieldFormat { return this.generateImgHtml(url, imageLabel); default: - const inWhitelist = allowedUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); - if (!inWhitelist && !parsedUrl) { + const allowed = allowedUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); + if (!allowed && !parsedUrl) { return url; } @@ -178,7 +178,7 @@ export class UrlFormat extends FieldFormat { * UNSUPPORTED * - app/kibana */ - if (!inWhitelist) { + if (!allowed) { // Handles urls like: `#/discover` if (url[0] === '#') { prefix = `${origin}${pathname}`; diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 3ee44aaa0816e..45fa3634bc87e 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -61,7 +61,7 @@ export class DevToolsPlugin implements Plugin { }), updater$: this.appStateUpdater, euiIconType: 'devToolsApp', - order: 9001, + order: 9010, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { const { element, history } = params; diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 17d8cb4adc701..8be130b4028c6 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -65,7 +65,7 @@ export class ManagementPlugin implements Plugin) => { @@ -348,23 +347,7 @@ export function getCloneAction(createAnalyticsForm: CreateAnalyticsFormProps) { }; } -interface CloneButtonProps { - item: DataFrameAnalyticsListRow; - createAnalyticsForm: CreateAnalyticsFormProps; -} - -/** - * Temp component to have Clone job button with the same look as the other actions. - * Replace with {@link getCloneAction} as soon as all the actions are refactored - * to support EuiContext with a valid DOM structure without nested buttons. - */ -export const CloneButton: FC = ({ createAnalyticsForm, item }) => { - const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); - - const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { - defaultMessage: 'Clone job', - }); - +export const useNavigateToWizardWithClonedJob = () => { const { services: { application: { navigateToUrl }, @@ -375,7 +358,7 @@ export const CloneButton: FC = ({ createAnalyticsForm, item }) const savedObjectsClient = savedObjects.client; - const onClick = async () => { + return async (item: DataFrameAnalyticsListRow) => { const sourceIndex = Array.isArray(item.config.source.index) ? item.config.source.index[0] : item.config.source.index; @@ -419,18 +402,46 @@ export const CloneButton: FC = ({ createAnalyticsForm, item }) ); } }; +}; - return ( +interface CloneButtonProps { + isDisabled: boolean; + onClick: () => void; +} + +/** + * Temp component to have Clone job button with the same look as the other actions. + * Replace with {@link getCloneAction} as soon as all the actions are refactored + * to support EuiContext with a valid DOM structure without nested buttons. + */ +export const CloneButton: FC = ({ isDisabled, onClick }) => { + const button = ( {buttonText} ); + + if (isDisabled) { + return ( + + {button} + + ); + } + + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts index b3d7189ff8cda..4e6357c4ea454 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts @@ -7,6 +7,7 @@ export { extractCloningConfig, isAdvancedConfig, + useNavigateToWizardWithClonedJob, CloneButton, CloneDataFrameAnalyticsConfig, } from './clone_button'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx index 6b745a2c5ff3b..99455a33cf084 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx @@ -49,37 +49,20 @@ describe('DeleteAction', () => { jest.clearAllMocks(); }); - test('When canDeleteDataFrameAnalytics permission is false, button should be disabled.', () => { + test('When isDisabled prop is true, inner button should be disabled.', () => { const { getByTestId } = render( - {}} /> + {}} /> ); + expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled'); }); - test('When canDeleteDataFrameAnalytics permission is true, button should not be disabled.', () => { - const mock = jest.spyOn(CheckPrivilige, 'checkPermission'); - mock.mockImplementation((p) => p === 'canDeleteDataFrameAnalytics'); + test('When isDisabled prop is true, inner button should not be disabled.', () => { const { getByTestId } = render( - {}} /> + {}} /> ); expect(getByTestId('mlAnalyticsJobDeleteButton')).not.toHaveAttribute('disabled'); - - mock.mockRestore(); - }); - - test('When job is running, delete button should be disabled.', () => { - const { getByTestId } = render( - {}} - /> - ); - - expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled'); }); describe('When delete model is open', () => { @@ -93,7 +76,11 @@ describe('DeleteAction', () => { return ( <> {deleteAction.isModalVisible && } - + deleteAction.openModal(mockAnalyticsListItem)} + /> ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx index 7da3bced48576..c83fb6cbac387 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx @@ -6,46 +6,42 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from '../analytics_list/common'; +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', { + defaultMessage: 'Delete', +}); + interface DeleteButtonProps { + isDisabled: boolean; item: DataFrameAnalyticsListRow; - onClick: (item: DataFrameAnalyticsListRow) => void; + onClick: () => void; } -export const DeleteButton: FC = ({ item, onClick }) => { - const disabled = isDataFrameAnalyticsRunning(item.stats.state); - const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); - - const buttonDeleteText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', { - defaultMessage: 'Delete', - }); - - const buttonDisabled = disabled || !canDeleteDataFrameAnalytics; - let deleteButton = ( - = ({ isDisabled, item, onClick }) => { + const button = ( + onClick(item)} - aria-label={buttonDeleteText} - style={{ padding: 0 }} + flush="left" + iconType="trash" + isDisabled={isDisabled} + onClick={onClick} + size="s" > - {buttonDeleteText} - + {buttonText} + ); - if (disabled || !canDeleteDataFrameAnalytics) { - deleteButton = ( + if (isDisabled) { + return ( = ({ item, onClick }) => { : createPermissionFailureMessage('canStartStopDataFrameAnalytics') } > - {deleteButton} + {button} ); } - return deleteButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx index 0acb215336faf..764b421821ad0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx @@ -8,36 +8,34 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { checkPermission } from '../../../../../capabilities/check_capabilities'; +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', { + defaultMessage: 'Edit', +}); interface EditButtonProps { + isDisabled: boolean; onClick: () => void; } -export const EditButton: FC = ({ onClick }) => { - const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); - - const buttonEditText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', { - defaultMessage: 'Edit', - }); - - const buttonDisabled = !canCreateDataFrameAnalytics; - const editButton = ( - = ({ isDisabled, onClick }) => { + const button = ( + - {buttonEditText} - + {buttonText} + ); - if (!canCreateDataFrameAnalytics) { + if (isDisabled) { return ( = ({ onClick }) => { defaultMessage: 'You do not have permission to edit analytics jobs.', })} > - {editButton} + {button} ); } - return editButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx index 279a335de8f42..3192a30f8312e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx @@ -6,45 +6,46 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; -import { DataFrameAnalyticsListRow, isCompletedAnalyticsJob } from '../analytics_list/common'; +import { DataFrameAnalyticsListRow } from '../analytics_list/common'; + +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.startActionName', { + defaultMessage: 'Start', +}); interface StartButtonProps { + canStartStopDataFrameAnalytics: boolean; + isDisabled: boolean; item: DataFrameAnalyticsListRow; - onClick: (item: DataFrameAnalyticsListRow) => void; + onClick: () => void; } -export const StartButton: FC = ({ item, onClick }) => { - const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); - - const buttonStartText = i18n.translate('xpack.ml.dataframe.analyticsList.startActionName', { - defaultMessage: 'Start', - }); - - // Disable start for analytics jobs which have completed. - const completeAnalytics = isCompletedAnalyticsJob(item.stats); - - const disabled = !canStartStopDataFrameAnalytics || completeAnalytics; - - let startButton = ( - onClick(item)} - aria-label={buttonStartText} +export const StartButton: FC = ({ + canStartStopDataFrameAnalytics, + isDisabled, + item, + onClick, +}) => { + const button = ( + - {buttonStartText} - + {buttonText} + ); - if (!canStartStopDataFrameAnalytics || completeAnalytics) { - startButton = ( + if (isDisabled) { + return ( = ({ item, onClick }) => { }) } > - {startButton} + {button} ); } - return startButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx index b8395f2f7c2a0..a3e8f16daf5ef 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx @@ -9,49 +9,46 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; - -import { stopAnalytics } from '../../services/analytics_service'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; import { DataFrameAnalyticsListRow } from '../analytics_list/common'; -const buttonStopText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', { +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', { defaultMessage: 'Stop', }); interface StopButtonProps { + isDisabled: boolean; item: DataFrameAnalyticsListRow; + onClick: () => void; } -export const StopButton: FC = ({ item }) => { - const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); - - const stopButton = ( +export const StopButton: FC = ({ isDisabled, item, onClick }) => { + const button = ( stopAnalytics(item)} - aria-label={buttonStopText} data-test-subj="mlAnalyticsJobStopButton" + flush="left" + iconType="stop" + isDisabled={isDisabled} + onClick={onClick} + size="s" > - {buttonStopText} + {buttonText} ); - if (!canStartStopDataFrameAnalytics) { + + if (isDisabled) { return ( - {stopButton} + {button} ); } - return stopButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx index 17a18c374dfa6..52b2513d13e39 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx @@ -6,7 +6,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { getAnalysisType, @@ -31,7 +31,7 @@ export const ViewButton: FC = ({ item, isManagementTable }) => } = useMlKibana(); const analysisType = getAnalysisType(item.config.analysis); - const isDisabled = + const buttonDisabled = !isRegressionAnalysis(item.config.analysis) && !isOutlierAnalysis(item.config.analysis) && !isClassificationAnalysis(item.config.analysis); @@ -41,21 +41,38 @@ export const ViewButton: FC = ({ item, isManagementTable }) => ? () => navigateToApp('ml', { path: url }) : () => navigateToUrl(url); - return ( + const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { + defaultMessage: 'View', + }); + + const button = ( - {i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { - defaultMessage: 'View', - })} + {buttonText} ); + + if (buttonDisabled) { + return ( + + {button} + + ); + } + + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 4080f6cd7a77e..e2298108ddc4b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -49,7 +49,6 @@ import { } from '../../../../../components/ml_in_memory_table'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { getSelectedJobIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; @@ -72,13 +71,11 @@ interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; blockRefresh?: boolean; - createAnalyticsForm?: CreateAnalyticsFormProps; } export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, blockRefresh = false, - createAnalyticsForm, }) => { const [isInitialized, setIsInitialized] = useState(false); const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); @@ -228,8 +225,7 @@ export const DataFrameAnalyticsList: FC = ({ expandedRowItemIds, setExpandedRowItemIds, isManagementTable, - isMlEnabledInSpace, - createAnalyticsForm + isMlEnabledInSpace ); // Before the analytics have been loaded for the first time, display the loading indicator only. @@ -268,7 +264,7 @@ export const DataFrameAnalyticsList: FC = ({ } actions={ - !isManagementTable && createAnalyticsForm + !isManagementTable ? [ setIsSourceIndexModalVisible(true)} @@ -370,10 +366,10 @@ export const DataFrameAnalyticsList: FC = ({ - {!isManagementTable && createAnalyticsForm && ( + {!isManagementTable && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index cb46a88fa3b21..bc02c81bac0f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -8,8 +8,11 @@ import React from 'react'; import { EuiTableActionsColumnType } from '@elastic/eui'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CloneButton } from '../action_clone'; +import { checkPermission } from '../../../../../capabilities/check_capabilities'; + +import { stopAnalytics } from '../../services/analytics_service'; + +import { useNavigateToWizardWithClonedJob, CloneButton } from '../action_clone'; import { useDeleteAction, DeleteButton, DeleteButtonModal } from '../action_delete'; import { isEditActionFlyoutVisible, @@ -21,15 +24,22 @@ import { useStartAction, StartButton, StartButtonModal } from '../action_start'; import { StopButton } from '../action_stop'; import { getViewAction } from '../action_view'; -import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; +import { + isCompletedAnalyticsJob, + isDataFrameAnalyticsRunning, + DataFrameAnalyticsListRow, +} from './common'; export const useActions = ( - createAnalyticsForm: CreateAnalyticsFormProps, isManagementTable: boolean ): { actions: EuiTableActionsColumnType['actions']; modals: JSX.Element | null; } => { + const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); + const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); + const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); + let modals: JSX.Element | null = null; const actions: EuiTableActionsColumnType['actions'] = [ @@ -52,29 +62,93 @@ export const useActions = ( {isEditActionFlyoutVisible(editAction) && } ); + + const startButtonEnabled = (item: DataFrameAnalyticsListRow) => { + if (!isDataFrameAnalyticsRunning(item.stats.state)) { + // Disable start for analytics jobs which have completed. + const completeAnalytics = isCompletedAnalyticsJob(item.stats); + return canStartStopDataFrameAnalytics && !completeAnalytics; + } + return canStartStopDataFrameAnalytics; + }; + + const navigateToWizardWithClonedJob = useNavigateToWizardWithClonedJob(); + actions.push( ...[ { render: (item: DataFrameAnalyticsListRow) => { if (!isDataFrameAnalyticsRunning(item.stats.state)) { - return ; + return ( + { + if (startButtonEnabled(item)) { + startAction.openModal(item); + } + }} + /> + ); } - return ; + + return ( + { + if (canStartStopDataFrameAnalytics) { + stopAnalytics(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return editAction.openFlyout(item)} />; + return ( + { + if (canStartStopDataFrameAnalytics) { + editAction.openFlyout(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return ; + return ( + { + if (canStartStopDataFrameAnalytics) { + deleteAction.openModal(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return ; + return ( + { + if (canCreateDataFrameAnalytics) { + navigateToWizardWithClonedJob(item); + } + }} + /> + ); }, }, ] diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index fa88396461cd7..123fdada44866 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -22,7 +22,6 @@ import { import { getJobIdUrl } from '../../../../../util/get_job_id_url'; import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { getDataFrameAnalyticsProgress, getDataFrameAnalyticsProgressPhase, @@ -145,10 +144,9 @@ export const useColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, - isMlEnabledInSpace: boolean = true, - createAnalyticsForm?: CreateAnalyticsFormProps + isMlEnabledInSpace: boolean = true ) => { - const { actions, modals } = useActions(createAnalyticsForm!, isManagementTable); + const { actions, modals } = useActions(isManagementTable); function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx index b438a3f006c6c..0597f377d2710 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx @@ -6,37 +6,15 @@ import { mount } from 'enzyme'; import React from 'react'; -import { mountHook } from 'test_utils/enzyme_helpers'; import { CreateAnalyticsButton } from './create_analytics_button'; -import { MlContext } from '../../../../../contexts/ml'; -import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; - -import { useCreateAnalyticsForm } from '../../hooks/use_create_analytics_form'; - -const getMountedHook = () => - mountHook( - () => useCreateAnalyticsForm(), - ({ children }) => ( - {children} - ) - ); - -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - jest.mock('../../../../../../../shared_imports'); describe('Data Frame Analytics: ', () => { test('Minimal initialization', () => { - const { getLastHookValue } = getMountedHook(); - const props = getLastHookValue(); const wrapper = mount( - + ); expect(wrapper.find('EuiButton').text()).toBe('Create job'); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx index c7c33787c37bc..e05684b23167c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx @@ -8,26 +8,20 @@ import React, { FC } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -interface Props extends CreateAnalyticsFormProps { +interface Props { + isDisabled: boolean; setIsSourceIndexModalVisible: React.Dispatch>; } -export const CreateAnalyticsButton: FC = ({ - state, - actions, - setIsSourceIndexModalVisible, -}) => { - const { disabled } = state; - +export const CreateAnalyticsButton: FC = ({ isDisabled, setIsSourceIndexModalVisible }) => { const handleClick = () => { setIsSourceIndexModalVisible(true); }; const button = ( = ({ ); - if (disabled) { + if (isDisabled) { return ( { useRefreshInterval(setBlockRefresh); - const createAnalyticsForm = useCreateAnalyticsForm(); - return ( @@ -84,10 +81,7 @@ export const Page: FC = () => { - + diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts new file mode 100644 index 0000000000000..b7c29abc96fb8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts @@ -0,0 +1,209 @@ +/* + * 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 { IUiSettingsClient } from 'kibana/public'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; +import { SavedSearchSavedObject } from '../../../../../common/types/kibana'; +import { createSearchItems } from './new_job_utils'; + +describe('createSearchItems', () => { + const kibanaConfig = {} as IUiSettingsClient; + const indexPattern = ({ + fields: [], + } as unknown) as IIndexPattern; + + let savedSearch = ({} as unknown) as SavedSearchSavedObject; + beforeEach(() => { + savedSearch = ({ + client: { + http: { + basePath: { + basePath: '/abc', + serverBasePath: '/abc', + }, + anonymousPaths: {}, + }, + batchQueue: [], + }, + attributes: { + title: 'not test', + description: '', + hits: 0, + columns: ['_source'], + sort: [], + version: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: '', + }, + }, + _version: 'WzI0OSw0XQ==', + id: '4b9b1010-c678-11ea-b6e6-e942978da29c', + type: 'search', + migrationVersion: { + search: '7.4.0', + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '7e252840-bd27-11ea-8a6c-75d1a0bd08ab', + }, + ], + } as unknown) as SavedSearchSavedObject; + }); + + test('should match index pattern', () => { + const resp = createSearchItems(kibanaConfig, indexPattern, null); + expect(resp).toStrictEqual({ + combinedQuery: { bool: { must: [{ match_all: {} }] } }, + query: { query: '', language: 'lucene' }, + }); + }); + + test('should match saved search with kuery and condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'airline : "AAL" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + should: [{ match_phrase: { airline: 'AAL' } }], + minimum_should_match: 1, + filter: [], + must_not: [], + }, + }, + query: { + language: 'kuery', + query: 'airline : "AAL" ', + }, + }); + }); + + test('should match saved search with kuery and not condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'NOT airline : "AAL" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + filter: [], + must_not: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + airline: 'AAL', + }, + }, + ], + }, + }, + ], + }, + }, + query: { + language: 'kuery', + query: 'NOT airline : "AAL" ', + }, + }); + }); + + test('should match saved search with kuery and condition and not condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'airline : "AAL" and NOT airline : "AWE" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + filter: [ + { bool: { should: [{ match_phrase: { airline: 'AAL' } }], minimum_should_match: 1 } }, + { + bool: { + must_not: { + bool: { should: [{ match_phrase: { airline: 'AWE' } }], minimum_should_match: 1 }, + }, + }, + }, + ], + must_not: [], + }, + }, + query: { query: 'airline : "AAL" and NOT airline : "AWE" ', language: 'kuery' }, + }); + }); + + test('should match saved search with kuery and filter', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { + language: 'kuery', + query: '', + }, + filter: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'airline', + params: { + query: 'AAL', + }, + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + }, + query: { + match_phrase: { + airline: 'AAL', + }, + }, + $state: { + store: 'appState', + }, + }, + ], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + must: [{ match_all: {} }], + filter: [{ match_phrase: { airline: 'AAL' } }], + must_not: [], + }, + }, + query: { language: 'kuery', query: '' }, + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 9ba10dc21000e..5fa6c817ec4c1 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -52,12 +52,16 @@ export function createSearchItems( } const filterQuery = esQuery.buildQueryFromFilters(filters, indexPattern); - if (combinedQuery.bool.filter === undefined) { - combinedQuery.bool.filter = []; + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; } - if (combinedQuery.bool.must_not === undefined) { - combinedQuery.bool.must_not = []; + + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; } + combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; } else { diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index 1b9ae75a0968e..cfac5e195a127 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -74,7 +74,7 @@ export class MonitoringPlugin const app: App = { id, title, - order: 9002, + order: 9030, euiIconType: icon, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index f8a6807196557..f53da8fb1f096 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -32,9 +32,9 @@ export function eventName(event: ResolverEvent): string { } } -export function eventId(event: ResolverEvent): string { +export function eventId(event: ResolverEvent): number | undefined | string { if (isLegacyEvent(event)) { - return event.endgame.serial_event_id ? String(event.endgame.serial_event_id) : ''; + return event.endgame.serial_event_id; } return event.event.id; } diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts index 00ddc85a73650..17f905b091e08 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts @@ -67,7 +67,7 @@ describe('Alerts rules, prebuilt rules', () => { }); }); -// https://github.com/elastic/kibana/issues/71814 +// FLAKY: https://github.com/elastic/kibana/issues/71814 describe.skip('Deleting prebuilt rules', () => { beforeEach(() => { const expectedNumberOfRules = totalNumberOfPrebuiltRules; diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index 74efb41c4c595..6f26bfe063c05 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -2,6 +2,7 @@ exports[`resolver graph layout when rendering no nodes renders right 1`] = ` Object { + "ariaLevels": Map {}, "edgeLineSegments": Array [], "processNodePositions": Map {}, } @@ -9,6 +10,22 @@ Object { exports[`resolver graph layout when rendering one node renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + }, "edgeLineSegments": Array [], "processNodePositions": Map { Object { @@ -34,6 +51,134 @@ Object { exports[`resolver graph layout when rendering two forks, and one fork has an extra long tine renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "already_running", + "event_type_full": "process_event", + "unique_pid": 1, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 2, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "termination_event", + "event_type_full": "process_event", + "unique_pid": 8, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 3, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 4, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 5, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 6, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 7, + "unique_ppid": 6, + }, + } => 4, + }, "edgeLineSegments": Array [ Object { "metadata": Object { @@ -406,6 +551,36 @@ Object { exports[`resolver graph layout when rendering two nodes, one being the parent of the other renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "already_running", + "event_type_full": "process_event", + "unique_pid": 1, + "unique_ppid": 0, + }, + } => 2, + }, "edgeLineSegments": Array [ Object { "metadata": Object { diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index b322de0f34526..35a32d91d8a02 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -4,99 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniquePidForProcess, uniqueParentPidForProcess } from '../process_event'; -import { IndexedProcessTree, AdjacentProcessMap } from '../../types'; +import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event'; +import { IndexedProcessTree } from '../../types'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; /** - * Create a new IndexedProcessTree from an array of ProcessEvents + * Create a new IndexedProcessTree from an array of ProcessEvents. + * siblings will be ordered by timestamp */ -export function factory(processes: ResolverEvent[]): IndexedProcessTree { +export function factory( + // Array of processes to index as a tree + processes: ResolverEvent[] +): IndexedProcessTree { const idToChildren = new Map(); const idToValue = new Map(); - const idToAdjacent = new Map(); - - function emptyAdjacencyMap(id: string): AdjacentProcessMap { - return { - self: id, - parent: null, - firstChild: null, - previousSibling: null, - nextSibling: null, - level: 1, - }; - } - - const roots: ResolverEvent[] = []; for (const process of processes) { const uniqueProcessPid = uniquePidForProcess(process); idToValue.set(uniqueProcessPid, process); - const currentProcessAdjacencyMap: AdjacentProcessMap = - idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid); - idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap); - const uniqueParentPid = uniqueParentPidForProcess(process); - const currentProcessSiblings = idToChildren.get(uniqueParentPid); - - if (currentProcessSiblings) { - const previousProcessId = uniquePidForProcess( - currentProcessSiblings[currentProcessSiblings.length - 1] - ); - currentProcessSiblings.push(process); - /** - * Update adjacency maps for current and previous entries - */ - idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid; - currentProcessAdjacencyMap.previousSibling = previousProcessId; - if (uniqueParentPid) { - currentProcessAdjacencyMap.parent = uniqueParentPid; + // if its defined and not '' + if (uniqueParentPid) { + let siblings = idToChildren.get(uniqueParentPid); + if (!siblings) { + siblings = []; + idToChildren.set(uniqueParentPid, siblings); } - } else { - if (uniqueParentPid) { - idToChildren.set(uniqueParentPid, [process]); - /** - * Get the parent's map, otherwise set an empty one - */ - const parentAdjacencyMap = - idToAdjacent.get(uniqueParentPid) || - (idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)), - idToAdjacent.get(uniqueParentPid))!; - // set firstChild for parent - parentAdjacencyMap.firstChild = uniqueProcessPid; - // set parent for current - currentProcessAdjacencyMap.parent = uniqueParentPid || null; - } else { - // In this case (no unique parent id), it must be a root - roots.push(process); - } - } - } - - /** - * Scan adjacency maps from the top down and assign levels - */ - function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void { - const nextLevel = level + 1; - if (currentProcessMap.nextSibling) { - traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level); - } - if (currentProcessMap.firstChild) { - traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel); + siblings.push(process); } - currentProcessMap.level = level; } - for (const treeRoot of roots) { - traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!); + // sort the children of each node + for (const siblings of idToChildren.values()) { + siblings.sort(orderByTime); } return { idToChildren, idToProcess: idToValue, - idToAdjacent, }; } @@ -109,6 +56,13 @@ export function children(tree: IndexedProcessTree, process: ResolverEvent): Reso return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } +/** + * Get the indexed process event for the ID + */ +export function processEvent(tree: IndexedProcessTree, entityID: string): ResolverEvent | null { + return tree.idToProcess.get(entityID) ?? null; +} + /** * Returns the parent ProcessEvent, if any, for the passed in `childProcess` */ @@ -124,6 +78,31 @@ export function parent( } } +/** + * Returns the following sibling + */ +export function nextSibling( + tree: IndexedProcessTree, + sibling: ResolverEvent +): ResolverEvent | undefined { + const parentNode = parent(tree, sibling); + if (parentNode) { + // The siblings of `sibling` are the children of its parent. + const siblings = children(tree, parentNode); + + // Find the sibling + const index = siblings.indexOf(sibling); + + // if the sibling wasn't found, or if it was the last element in the array, return undefined + if (index === -1 || index === siblings.length - 1) { + return undefined; + } + + // return the next sibling + return siblings[index + 1]; + } +} + /** * Number of processes in the tree */ @@ -138,7 +117,10 @@ export function root(tree: IndexedProcessTree) { if (size(tree) === 0) { return null; } + // any node will do let current: ResolverEvent = tree.idToProcess.values().next().value; + + // iteratively swap current w/ its parent while (parent(tree, current) !== undefined) { current = parent(tree, current)!; } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts index 72d8e878465f7..bd534dcb989e3 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts @@ -148,5 +148,24 @@ describe('resolver graph layout', () => { it('renders right', () => { expect(layout()).toMatchSnapshot(); }); + it('should have node a at level 1', () => { + expect(layout().ariaLevels.get(processA)).toBe(1); + }); + it('should have nodes b and c at level 2', () => { + expect(layout().ariaLevels.get(processB)).toBe(2); + expect(layout().ariaLevels.get(processC)).toBe(2); + }); + it('should have nodes d, e, f, and g at level 3', () => { + expect(layout().ariaLevels.get(processD)).toBe(3); + expect(layout().ariaLevels.get(processE)).toBe(3); + expect(layout().ariaLevels.get(processF)).toBe(3); + expect(layout().ariaLevels.get(processG)).toBe(3); + }); + it('should have node h at level 4', () => { + expect(layout().ariaLevels.get(processH)).toBe(4); + }); + it('should have 9 items in the map of aria levels', () => { + expect(layout().ariaLevels.size).toBe(9); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 61363ffa05d94..6058a40037ad2 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -73,9 +73,34 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso return { processNodePositions: transformedPositions, edgeLineSegments: transformedEdgeLineSegments, + ariaLevels: ariaLevels(indexedProcessTree), }; } +/** + * Calculate a level (starting at 1) for each node. + */ +function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { + const map: Map = new Map(); + for (const node of model.levelOrder(indexedProcessTree)) { + const parentNode = model.parent(indexedProcessTree, node); + if (parentNode === undefined) { + // nodes at the root have a level of 1 + map.set(node, 1); + } else { + const parentLevel: number | undefined = map.get(parentNode); + + // because we're iterating in level order, we should have processed the parent of any node that has one. + if (parentLevel === undefined) { + throw new Error('failed to calculate aria levels'); + } + + map.set(node, parentLevel + 1); + } + } + return map; +} + /** * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its * descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts index 04a3f9ccff772..7eb692851bc9b 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts @@ -3,10 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { eventType } from './process_event'; +import { eventType, orderByTime } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { LegacyEndpointEvent } from '../../../common/endpoint/types'; +import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -24,4 +24,86 @@ describe('process event', () => { expect(eventType(event)).toEqual('processCreated'); }); }); + describe('orderByTime', () => { + let mock: (time: number, eventID: string) => ResolverEvent; + let events: ResolverEvent[]; + beforeEach(() => { + mock = (time, eventID) => { + return { + '@timestamp': time, + event: { + id: eventID, + }, + } as ResolverEvent; + }; + // 2 events each for numbers -1, 0, 1, and NaN + // each event has a unique id, a through h + // order is arbitrary + events = [ + mock(-1, 'a'), + mock(0, 'c'), + mock(1, 'e'), + mock(NaN, 'g'), + mock(-1, 'b'), + mock(0, 'd'), + mock(1, 'f'), + mock(NaN, 'h'), + ]; + }); + it('sorts events as expected', () => { + events.sort(orderByTime); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "@timestamp": -1, + "event": Object { + "id": "a", + }, + }, + Object { + "@timestamp": -1, + "event": Object { + "id": "b", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "c", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "d", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "e", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "f", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "g", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "h", + }, + }, + ] + `); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 0286cca93b43f..4f8df87b3ac0b 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -28,6 +28,19 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { return eventType(passedEvent) === 'processTerminated'; } +/** + * ms since unix epoc, based on timestamp. + * may return NaN if the timestamp wasn't present or was invalid. + */ +export function datetime(passedEvent: ResolverEvent): number | null { + const timestamp = event.eventTimestamp(passedEvent); + + const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); + + // if the date could not be parsed, return null + return isNaN(time) ? null : time; +} + /** * Returns a custom event type for a process event based on the event's metadata. */ @@ -161,3 +174,22 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { } return passedEvent?.process?.args; } + +/** + * used to sort events + */ +export function orderByTime(first: ResolverEvent, second: ResolverEvent): number { + const firstDatetime: number | null = datetime(first); + const secondDatetime: number | null = datetime(second); + + if (firstDatetime === secondDatetime) { + // break ties using an arbitrary (stable) comparison of `eventId` (which should be unique) + return String(event.eventId(first)).localeCompare(String(event.eventId(second))); + } else if (firstDatetime === null || secondDatetime === null) { + // sort `null`'s as higher than numbers + return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0); + } else { + // sort in ascending order. + return firstDatetime - secondDatetime; + } +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 475546cfc3966..dc17fc70ef8af 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -8,7 +8,6 @@ import rbush from 'rbush'; import { createSelector, defaultMemoize } from 'reselect'; import { DataState, - AdjacentProcessMap, Vector2, IndexedEntity, IndexedEdgeLineSegment, @@ -21,7 +20,7 @@ import { isTerminatedProcess, uniquePidForProcess, } from '../../models/process_event'; -import { factory as indexedProcessTreeFactory } from '../../models/indexed_process_tree'; +import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; import { isEqual } from '../../models/aabb'; import { @@ -107,7 +106,7 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in graphableProcesses /* eslint-enable no-shadow */ ) { - return indexedProcessTreeFactory(graphableProcesses); + return indexedProcessTreeModel.factory(graphableProcesses); }); /** @@ -170,27 +169,6 @@ export function relatedEventsReady(data: DataState): Map { return data.relatedEventsReady; } -export const processAdjacencies = createSelector( - indexedProcessTree, - graphableProcesses, - function selectProcessAdjacencies( - /* eslint-disable no-shadow */ - indexedProcessTree, - graphableProcesses - /* eslint-enable no-shadow */ - ) { - const processToAdjacencyMap = new Map(); - const { idToAdjacent } = indexedProcessTree; - - for (const graphableProcess of graphableProcesses) { - const processPid = uniquePidForProcess(graphableProcess); - const adjacencyMap = idToAdjacent.get(processPid)!; - processToAdjacencyMap.set(graphableProcess, adjacencyMap); - } - return { processToAdjacencyMap }; - } -); - /** * `true` if there were more children than we got in the last request. */ @@ -230,7 +208,7 @@ export const relatedEventInfoByEntityId: ( ) { if (!relatedEventsStats) { // If there are no related event stats, there are no related event info objects - return (entityId: string) => null; + return () => null; } return (entityId) => { const stats = relatedEventsStats.get(entityId); @@ -334,7 +312,8 @@ export function databaseDocumentIDToFetch(state: DataState): string | null { return null; } } -export const processNodePositionsAndEdgeLineSegments = createSelector( + +export const layout = createSelector( indexedProcessTree, function processNodePositionsAndEdgeLineSegments( /* eslint-disable no-shadow */ @@ -345,9 +324,62 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( } ); -const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( - processNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments({ +/** + * Given a nodeID (aka entity_id) get the indexed process event. + * Legacy functions take process events instead of nodeID, use this to get + * process events for them. + */ +export const processEventForID: ( + state: DataState +) => (nodeID: string) => ResolverEvent | null = createSelector( + indexedProcessTree, + (tree) => (nodeID: string) => indexedProcessTreeModel.processEvent(tree, nodeID) +); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: (state: DataState) => (nodeID: string) => number | null = createSelector( + layout, + processEventForID, + ({ ariaLevels }, processEventGetter) => (nodeID: string) => { + const node = processEventGetter(nodeID); + return node ? ariaLevels.get(node) ?? null : null; + } +); + +/** + * Returns the following sibling if there is one, or `null`. + */ +export const followingSibling: ( + state: DataState +) => (nodeID: string) => string | null = createSelector( + indexedProcessTree, + processEventForID, + (tree, eventGetter) => { + return (nodeID: string) => { + const event = eventGetter(nodeID); + + // event not found + if (event === null) { + return null; + } + const nextSibling = indexedProcessTreeModel.nextSibling(tree, event); + + // next sibling not found + if (nextSibling === undefined) { + return null; + } + + // return the node ID + return uniquePidForProcess(nextSibling); + }; + } +); + +const spatiallyIndexedLayout: (state: DataState) => rbush = createSelector( + layout, + function ({ /* eslint-disable no-shadow */ processNodePositions, edgeLineSegments, @@ -394,47 +426,46 @@ const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( } ); -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments(tree) { - // memoize the results of this call to avoid unnecessarily rerunning - let lastBoundingBox: AABB | null = null; - let currentlyVisible: VisibleEntites = { - processNodePositions: new Map(), - connectingEdgeLineSegments: [], - }; - return (boundingBox: AABB) => { - if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { - return currentlyVisible; - } else { - const { - minimum: [minX, minY], - maximum: [maxX, maxY], - } = boundingBox; - const entities = tree.search({ - minX, - minY, - maxX, - maxY, - }); - const visibleProcessNodePositions = new Map( - entities - .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') - .map((node) => [node.entity, node.position]) - ); - const connectingEdgeLineSegments = entities - .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') - .map((node) => node.entity); - currentlyVisible = { - processNodePositions: visibleProcessNodePositions, - connectingEdgeLineSegments, - }; - lastBoundingBox = boundingBox; - return currentlyVisible; - } - }; - } -); +export const nodesAndEdgelines: ( + state: DataState +) => (query: AABB) => VisibleEntites = createSelector(spatiallyIndexedLayout, function (tree) { + // memoize the results of this call to avoid unnecessarily rerunning + let lastBoundingBox: AABB | null = null; + let currentlyVisible: VisibleEntites = { + processNodePositions: new Map(), + connectingEdgeLineSegments: [], + }; + return (boundingBox: AABB) => { + if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { + return currentlyVisible; + } else { + const { + minimum: [minX, minY], + maximum: [maxX, maxY], + } = boundingBox; + const entities = tree.search({ + minX, + minY, + maxX, + maxY, + }); + const visibleProcessNodePositions = new Map( + entities + .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') + .map((node) => [node.entity, node.position]) + ); + const connectingEdgeLineSegments = entities + .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') + .map((node) => node.entity); + currentlyVisible = { + processNodePositions: visibleProcessNodePositions, + connectingEdgeLineSegments, + }; + lastBoundingBox = boundingBox; + return currentlyVisible; + } + }; +}); /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index eb2b402a694a5..e91c455c9445f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -9,7 +9,7 @@ import { ResolverAction } from '../actions'; import { resolverReducer } from '../reducer'; import { ResolverState } from '../../types'; import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; -import { visibleProcessNodePositionsAndEdgeLineSegments } from '../selectors'; +import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; @@ -119,15 +119,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should only include 2 nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(2); }); it('the visibleEdgeLineSegments list should only include one edge line', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(1); }); }); @@ -151,15 +147,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should include all process nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(5); }); it('the visibleEdgeLineSegments list include all lines', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(4); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/methods.ts b/x-pack/plugins/security_solution/public/resolver/store/methods.ts index 3890770259156..ad06ddf36161a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/methods.ts @@ -5,7 +5,7 @@ */ import { animatePanning } from './camera/methods'; -import { processNodePositionsAndEdgeLineSegments } from './selectors'; +import { layout } from './selectors'; import { ResolverState } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; @@ -19,7 +19,7 @@ export function animateProcessIntoView( startTime: number, process: ResolverEvent ): ResolverState { - const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); + const { processNodePositions } = layout(state); const position = processNodePositions.get(process); if (position) { return { diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts new file mode 100644 index 0000000000000..ba4a5a169c549 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -0,0 +1,259 @@ +/* + * 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 { ResolverState } from '../types'; +import { createStore } from 'redux'; +import { ResolverAction } from './actions'; +import { resolverReducer } from './reducer'; +import * as selectors from './selectors'; +import { EndpointEvent, ResolverEvent, ResolverTree } from '../../../common/endpoint/types'; + +describe('resolver selectors', () => { + const actions: ResolverAction[] = []; + + /** + * Get state, given an ordered collection of actions. + */ + const state: () => ResolverState = () => { + const store = createStore(resolverReducer); + for (const action of actions) { + store.dispatch(action); + } + return store.getState(); + }; + describe('ariaFlowtoNodeID', () => { + describe('with a tree with no descendants and 2 ancestors', () => { + const originID = 'c'; + const firstAncestorID = 'b'; + const secondAncestorID = 'a'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, + }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const size = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [size, size], + }); + }); + it('should return no flowto for the second ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null); + }); + it('should return no flowto for the first ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + }); + }); + describe('with a tree with 2 children and no ancestors', () => { + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + it('should return the second child as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID); + }); + it('should return no flowto for second child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null); + }); + }); + describe('when only the origin and first child are in view', () => { + beforeEach(() => { + // set the raster size + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + + // get the layout + const layout = selectors.layout(state()); + + // find the position of the second child + const secondChild = selectors.processEventForID(state())(secondChildID); + const positionOfSecondChild = layout.processNodePositions.get(secondChild!)!; + + // the child is indexed by an AABB that extends -720/2 to the left + const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2; + + // adjust the camera so that it doesn't quite see the second child + actions.push({ + // set the position of the camera so that the left edge of the second child is at the right edge + // of the viewable area + type: 'userSetPositionOfCamera', + payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0], + }); + }); + it('the origin should be in view', () => { + const origin = selectors.processEventForID(state())(originID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(origin) + ).toBe(true); + }); + it('the first child should be in view', () => { + const firstChild = selectors.processEventForID(state())(firstChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(firstChild) + ).toBe(true); + }); + it('the second child should not be in view', () => { + const secondChild = selectors.processEventForID(state())(secondChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(secondChild) + ).toBe(false); + }); + it('should return nothing as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null); + }); + }); + }); + }); +}); +/** + * Simple mock endpoint event that works for tree layouts. + */ +function mockEndpointEvent({ + entityID, + name, + parentEntityId, + timestamp, +}: { + entityID: string; + name: string; + parentEntityId: string; + timestamp: number; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + type: 'start', + category: 'process', + }, + process: { + entity_id: entityID, + name, + parent: { + entity_id: parentEntityId, + }, + }, + } as EndpointEvent; +} + +function treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, +}: { + secondAncestorID: string; + firstAncestorID: string; + originID: string; +}): ResolverTree { + const secondAncestor: ResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + name: 'a', + parentEntityId: 'none', + timestamp: 0, + }); + const firstAncestor: ResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + name: 'b', + parentEntityId: secondAncestorID, + timestamp: 1, + }); + const originEvent: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: firstAncestorID, + timestamp: 2, + }); + return ({ + entityID: originID, + children: { + childNodes: [], + }, + ancestry: { + ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], + }, + lifecycle: [originEvent], + } as unknown) as ResolverTree; +} + +function treeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, +}: { + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: 'none', + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: 'd', + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: 'e', + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], + }, + ancestry: { + ancestors: [], + }, + lifecycle: [origin], + } as unknown) as ResolverTree; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 945b2bfed3cfb..ff2179dc3a2ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createSelector } from 'reselect'; +import { createSelector, defaultMemoize } from 'reselect'; import * as cameraSelectors from './camera/selectors'; import * as dataSelectors from './data/selectors'; import * as uiSelectors from './ui/selectors'; -import { ResolverState } from '../types'; +import { ResolverState, IsometricTaxiLayout } from '../types'; +import { uniquePidForProcess } from '../models/process_event'; +import { ResolverEvent } from '../../../common/endpoint/types'; /** * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. @@ -51,9 +53,24 @@ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelecto */ export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating); -export const processNodePositionsAndEdgeLineSegments = composeSelectors( +/** + * Given a nodeID (aka entity_id) get the indexed process event. + * Legacy functions take process events instead of nodeID, use this to get + * process events for them. + */ +export const processEventForID: ( + state: ResolverState +) => (nodeID: string) => ResolverEvent | null = composeSelectors( dataStateSelector, - dataSelectors.processNodePositionsAndEdgeLineSegments + dataSelectors.processEventForID +); + +/** + * The position of nodes and edges. + */ +export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSelectors( + dataStateSelector, + dataSelectors.layout ); /** @@ -74,11 +91,6 @@ export const resolverComponentInstanceID = composeSelectors( dataSelectors.resolverComponentInstanceID ); -export const processAdjacencies = composeSelectors( - dataStateSelector, - dataSelectors.processAdjacencies -); - export const terminatedProcesses = composeSelectors( dataStateSelector, dataSelectors.terminatedProcesses @@ -212,10 +224,8 @@ function composeSelectors( } const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox); -const indexedProcessNodesAndEdgeLineSegments = composeSelectors( - dataStateSelector, - dataSelectors.visibleProcessNodePositionsAndEdgeLineSegments -); + +const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines); /** * Total count of related events for a process. @@ -230,15 +240,50 @@ export const relatedEventTotalForProcess = composeSelectors( * The bounding box represents what the camera can see. The camera position is a function of time because it can be * animated. So in order to get the currently visible entities, we need to pass in time. */ -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodesAndEdgeLineSegments, - boundingBox, - function ( - /* eslint-disable no-shadow */ - indexedProcessNodesAndEdgeLineSegments, - boundingBox - /* eslint-enable no-shadow */ - ) { - return (time: number) => indexedProcessNodesAndEdgeLineSegments(boundingBox(time)); +export const visibleNodesAndEdgeLines = createSelector(nodesAndEdgelines, boundingBox, function ( + /* eslint-disable no-shadow */ + nodesAndEdgelines, + boundingBox + /* eslint-enable no-shadow */ +) { + return (time: number) => nodesAndEdgelines(boundingBox(time)); +}); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: ( + state: ResolverState +) => (nodeID: string) => number | null = composeSelectors( + dataStateSelector, + dataSelectors.ariaLevel +); + +/** + * Takes a nodeID (aka entity_id) and returns the node ID of the node that aria should 'flowto' or null + * If the node has a following sibling that is currently visible, that will be returned, otherwise null. + */ +export const ariaFlowtoNodeID: ( + state: ResolverState +) => (time: number) => (nodeID: string) => string | null = createSelector( + visibleNodesAndEdgeLines, + composeSelectors(dataStateSelector, dataSelectors.followingSibling), + (visibleNodesAndEdgeLinesAtTime, followingSibling) => { + return defaultMemoize((time: number) => { + // get the visible nodes at `time` + const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time); + + // get a `Set` containing their node IDs + const nodesVisibleAtTime: Set = new Set( + [...processNodePositions.keys()].map(uniquePidForProcess) + ); + + // return the ID of `nodeID`'s following sibling, if it is visible + return (nodeID: string): string | null => { + const sibling: string | null = followingSibling(nodeID); + + return sibling === null || nodesVisibleAtTime.has(sibling) === false ? null : sibling; + }; + }); } ); diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 064634472bbbe..0272de0d8fd2a 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -269,38 +269,18 @@ export interface ProcessEvent { }; } -/** - * A map of Process Ids that indicate which processes are adjacent to a given process along - * directions in two axes: up/down and previous/next. - */ -export interface AdjacentProcessMap { - readonly self: string; - parent: string | null; - firstChild: string | null; - previousSibling: string | null; - nextSibling: string | null; - /** - * To support aria-level, this must be >= 1 - */ - level: number; -} - /** * A represention of a process tree with indices for O(1) access to children and values by id. */ export interface IndexedProcessTree { /** - * Map of ID to a process's children + * Map of ID to a process's ordered children */ idToChildren: Map; /** * Map of ID to process */ idToProcess: Map; - /** - * Map of ID to adjacent processes - */ - idToAdjacent: Map; } /** @@ -454,4 +434,9 @@ export interface IsometricTaxiLayout { * A map of edgline segments, which graphically connect nodes. */ edgeLineSegments: EdgeLineSegment[]; + + /** + * defines the aria levels for nodes. + */ + ariaLevels: Map; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 000bf23c5f49d..b366e2f220652 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -53,10 +53,13 @@ export const ResolverMap = React.memo(function ({ useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); + + // use this for the entire render in order to keep things in sync + const timeAtRender = timestamp(); + const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleProcessNodePositionsAndEdgeLineSegments - )(timestamp()); - const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + selectors.visibleNodesAndEdgeLines + )(timeAtRender); const relatedEventsStats = useSelector(selectors.relatedEventsStats); const terminatedProcesses = useSelector(selectors.terminatedProcesses); const { projectionMatrix, ref, onMouseDown } = useCamera(); @@ -100,24 +103,19 @@ export const ResolverMap = React.memo(function ({ /> ))} {[...processNodePositions].map(([processEvent, position]) => { - const adjacentNodeMap = processToAdjacencyMap.get(processEvent); const processEntityId = entityId(processEvent); - if (!adjacentNodeMap) { - // This should never happen - throw new Error('Issue calculating adjacency node map.'); - } return ( ); })} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index 0ed677885775f..6f9bfad8c08c2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -146,7 +146,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ [pushToQueryParams, handleBringIntoViewClick, isProcessOrigin, isProcessTerminated] ); - const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); + const { processNodePositions } = useSelector(selectors.layout); const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map((processEvent) => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx index 0878ead72b2a4..9a477fd998bb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -38,7 +38,6 @@ interface MatchingEventEntry { eventType: string; eventCategory: string; name: { subject: string; descriptor?: string }; - entityId: string; setQueryParams: () => void; } @@ -202,9 +201,11 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr eventCategory: `${eventType}`, eventType: `${event.ecsEventType(resolverEvent)}`, name: event.descriptiveName(resolverEvent), - entityId, setQueryParams: () => { - pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId }); + pushToQueryParams({ + crumbId: entityId === undefined ? '' : String(entityId), + crumbEvent: processEntityId, + }); }, }; }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index e20f06ccf0f72..7666d1ac7c88a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -12,11 +12,12 @@ import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap } from '../types'; +import { Vector2, Matrix3 } from '../types'; import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; +import * as processEventModel from '../models/process_event'; import * as selectors from '../store/selectors'; import { useResolverQueryParams } from './use_resolver_query_params'; @@ -70,10 +71,10 @@ const UnstyledProcessEventDot = React.memo( position, event, projectionMatrix, - adjacentNodeMap, isProcessTerminated, isProcessOrigin, relatedEventsStatsForProcess, + timeAtRender, }: { /** * A `className` string provided by `styled` @@ -91,10 +92,6 @@ const UnstyledProcessEventDot = React.memo( * projectionMatrix which can be used to convert `position` to screen coordinates. */ projectionMatrix: Matrix3; - /** - * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions - */ - adjacentNodeMap: AdjacentProcessMap; /** * Whether or not to show the process as terminated. */ @@ -109,7 +106,16 @@ const UnstyledProcessEventDot = React.memo( * Statistics for the number of related events and alerts for this process node */ relatedEventsStatsForProcess?: ResolverNodeStats; + + /** + * The time (unix epoch) at render. + */ + timeAtRender: number; }) => { + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + // This should be unique to each instance of Resolver + const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`; + /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ @@ -118,12 +124,22 @@ const UnstyledProcessEventDot = React.memo( const [xScale] = projectionMatrix; // Node (html id=) IDs - const selfId = adjacentNodeMap.self; const activeDescendantId = useSelector(selectors.uiActiveDescendantId); const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); + const nodeID = processEventModel.uniquePidForProcess(event); - // Entity ID of self - const selfEntityId = eventModel.entityId(event); + // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. + // this is used to link nodes via aria attributes + const nodeHTMLID = useCallback((id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`), [ + htmlIDPrefix, + ]); + + const ariaLevel: number | null = useSelector(selectors.ariaLevel)(nodeID); + + // the node ID to 'flowto' + const ariaFlowtoNodeID: string | null = useSelector(selectors.ariaFlowtoNodeID)(timeAtRender)( + nodeID + ); const isShowingEventActions = xScale > 0.8; const isShowingDescriptionText = xScale >= 0.55; @@ -204,16 +220,10 @@ const UnstyledProcessEventDot = React.memo( strokeColor, } = cubeAssetsForNode(isProcessTerminated, isProcessOrigin); - const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []); + const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`); - const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [ - resolverNodeIdGenerator, - selfId, - ]); - const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const isActiveDescendant = nodeId === activeDescendantId; - const isSelectedDescendant = nodeId === selectedDescendantId; + const isAriaCurrent = nodeID === activeDescendantId; + const isAriaSelected = nodeID === selectedDescendantId; const dispatch = useResolverDispatch(); @@ -221,34 +231,35 @@ const UnstyledProcessEventDot = React.memo( dispatch({ type: 'userFocusedOnResolverNode', payload: { - nodeId, + nodeId: nodeHTMLID(nodeID), }, }); - }, [dispatch, nodeId]); + }, [dispatch, nodeHTMLID, nodeID]); const handleRelatedEventRequest = useCallback(() => { dispatch({ type: 'userRequestedRelatedEventData', - payload: selfId, + payload: nodeID, }); - }, [dispatch, selfId]); + }, [dispatch, nodeID]); const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { + // This works but the types are missing in the typescript DOM lib // eslint-disable-next-line @typescript-eslint/no-explicit-any (animationTarget.current as any).beginElement(); } dispatch({ type: 'userSelectedResolverNode', payload: { - nodeId, - selectedProcessId: selfId, + nodeId: nodeHTMLID(nodeID), + selectedProcessId: nodeID, }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' }); - }, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]); + pushToQueryParams({ crumbId: nodeID, crumbEvent: 'all' }); + }, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]); /** * Enumerates the stats for related events to display with the node as options, @@ -280,12 +291,12 @@ const UnstyledProcessEventDot = React.memo( }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); + pushToQueryParams({ crumbId: nodeID, crumbEvent: category }); }, }); } return relatedStatsList; - }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, selfEntityId]); + }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, nodeID]); const relatedEventStatusOrOptions = !relatedEventsStatsForProcess ? subMenuAssets.initialMenuStatus @@ -302,15 +313,14 @@ const UnstyledProcessEventDot = React.memo( data-test-subj={'resolverNode'} className={`${className} kbn-resetFocusState`} role="treeitem" - aria-level={adjacentNodeMap.level} - aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling} - aria-labelledby={labelId} - aria-describedby={descriptionId} - aria-haspopup={'true'} - aria-current={isActiveDescendant ? 'true' : undefined} - aria-selected={isSelectedDescendant ? 'true' : undefined} + aria-level={ariaLevel === null ? undefined : ariaLevel} + aria-flowto={ariaFlowtoNodeID === null ? undefined : nodeHTMLID(ariaFlowtoNodeID)} + aria-labelledby={labelHTMLID} + aria-haspopup="true" + aria-current={isAriaCurrent ? 'true' : undefined} + aria-selected={isAriaSelected ? 'true' : undefined} style={nodeViewportStyle} - id={nodeId} + id={nodeHTMLID(nodeID)} tabIndex={-1} >
= 2 ? 'euiButton' : 'euiButton euiButton--small'} - data-test-subject="nodeLabel" - id={labelId} + id={labelHTMLID} onClick={handleClick} onFocus={handleFocus} tabIndex={-1} @@ -386,9 +395,7 @@ const UnstyledProcessEventDot = React.memo( > { throw new Error('failed to create tree'); } const processes: ResolverEvent[] = [ - ...selectors - .processNodePositionsAndEdgeLineSegments(store.getState()) - .processNodePositions.keys(), + ...selectors.layout(store.getState()).processNodePositions.keys(), ]; process = processes[processes.length - 1]; if (!process) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 70baef5fa88ea..3c342ae575aa0 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -20,8 +20,8 @@ export function useResolverQueryParams() { const history = useHistory(); const urlSearch = useLocation().search; const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; - const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`; + const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`; const pushToQueryParams = useCallback( (newCrumbs: CrumbInfo) => { // Construct a new set of params from the current set (minus empty params) @@ -51,9 +51,15 @@ export function useResolverQueryParams() { const parsed = querystring.parse(urlSearch.slice(1)); const crumbEvent = parsed[uniqueCrumbEventKey]; const crumbId = parsed[uniqueCrumbIdKey]; + function valueForParam(param: string | string[]): string { + if (Array.isArray(param)) { + return param[0] || ''; + } + return param || ''; + } return { - crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, - crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + crumbEvent: valueForParam(crumbEvent), + crumbId: valueForParam(crumbId), }; }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts index 2b107ab1b6db4..150f56cbd70cc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts @@ -61,7 +61,7 @@ export class PaginationBuilder { const lastResult = results[results.length - 1]; const cursor = { timestamp: lastResult['@timestamp'], - eventID: eventId(lastResult), + eventID: eventId(lastResult) === undefined ? '' : String(eventId(lastResult)), }; return PaginationBuilder.urlEncodeCursor(cursor); } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx index 4686ede7bc2c2..2b5ffa27e0f82 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx @@ -7,7 +7,7 @@ import React, { FC, useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { createCapabilityFailureMessage, @@ -25,7 +25,7 @@ export const CloneButton: FC = ({ itemId }) => { const { canCreateTransform } = useContext(AuthorizationContext).capabilities; - const buttonCloneText = i18n.translate('xpack.transform.transformList.cloneActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.cloneActionName', { defaultMessage: 'Clone', }); @@ -33,27 +33,30 @@ export const CloneButton: FC = ({ itemId }) => { history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${itemId}`); } - const cloneButton = ( - - {buttonCloneText} - + {buttonText} + ); - if (!canCreateTransform) { - const content = createCapabilityFailureMessage('canStartStopTransform'); - + if (buttonDisabled) { return ( - - {cloneButton} + + {button} ); } - return <>{cloneButton}; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap index 3980cc5d5a1ae..7e98fc90cfad4 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap @@ -6,17 +6,17 @@ exports[`Transform: Transform List Actions Minimal initializati delay="regular" position="top" > - - - Delete - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx index b81c3ebc34ca0..2ca48ed734c7f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; import { AuthorizationContext, @@ -29,7 +29,7 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli const disabled = items.some(transformCanNotBeDeleted); const { canDeleteTransform } = useContext(AuthorizationContext).capabilities; - const buttonDeleteText = i18n.translate('xpack.transform.transformList.deleteActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.deleteActionName', { defaultMessage: 'Delete', }); const bulkDeleteButtonDisabledText = i18n.translate( @@ -46,16 +46,20 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli ); const buttonDisabled = forceDisable === true || disabled || !canDeleteTransform; - let deleteButton = ( - onClick(items)} - aria-label={buttonDeleteText} + flush="left" + iconType="trash" + isDisabled={buttonDisabled} + onClick={() => onClick(items)} + size="s" > - {buttonDeleteText} - + {buttonText} + ); if (disabled || !canDeleteTransform) { @@ -66,12 +70,12 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli content = createCapabilityFailureMessage('canDeleteTransform'); } - deleteButton = ( + return ( - {deleteButton} + {button} ); } - return deleteButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx index 6ba8e7aeba20f..40c27cff1e398 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx @@ -8,7 +8,7 @@ import React, { useContext, FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { createCapabilityFailureMessage, @@ -21,31 +21,34 @@ interface EditButtonProps { export const EditButton: FC = ({ onClick }) => { const { canCreateTransform } = useContext(AuthorizationContext).capabilities; - const buttonEditText = i18n.translate('xpack.transform.transformList.editActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.editActionName', { defaultMessage: 'Edit', }); - const editButton = ( - - {buttonEditText} - + {buttonText} + ); if (!canCreateTransform) { - const content = createCapabilityFailureMessage('canStartStopTransform'); - return ( - - {editButton} + + {button} ); } - return editButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap index 231a1f30f2c31..d8184773e16b5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap @@ -6,15 +6,17 @@ exports[`Transform: Transform List Actions Minimal initializatio delay="regular" position="top" > - - - Start - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx index a0fe1bfbb9544..60f899adc5fb2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; @@ -25,7 +25,7 @@ export const StartButton: FC = ({ items, forceDisable, onClick const { canStartStopTransform } = useContext(AuthorizationContext).capabilities; const isBulkAction = items.length > 1; - const buttonStartText = i18n.translate('xpack.transform.transformList.startActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.startActionName', { defaultMessage: 'Start', }); @@ -84,23 +84,30 @@ export const StartButton: FC = ({ items, forceDisable, onClick } } - const disabled = forceDisable === true || actionIsDisabled; + const buttonDisabled = forceDisable === true || actionIsDisabled; - const startButton = ( - onClick(items)} + flush="left" + iconType="play" + isDisabled={buttonDisabled} + onClick={() => onClick(items)} + size="s" > - {buttonStartText} - + {buttonText} + ); - if (disabled && content !== undefined) { + + if (buttonDisabled && content !== undefined) { return ( - {startButton} + {button} ); } - return startButton; + + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap index dd81bf34bf582..0052dc6254789 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap @@ -6,17 +6,17 @@ exports[`Transform: Transform List Actions Minimal initialization delay="regular" position="top" > - - - Stop - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx index 2c67ea3e83ecc..3c5e4323cc69a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; @@ -25,7 +25,7 @@ export const StopButton: FC = ({ items, forceDisable }) => { const isBulkAction = items.length > 1; const { canStartStopTransform } = useContext(AuthorizationContext).capabilities; const stopTransforms = useStopTransforms(); - const buttonStopText = i18n.translate('xpack.transform.transformList.stopActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.stopActionName', { defaultMessage: 'Stop', }); @@ -56,18 +56,24 @@ export const StopButton: FC = ({ items, forceDisable }) => { stopTransforms(items); }; - const disabled = forceDisable === true || !canStartStopTransform || stoppedTransform === true; + const buttonDisabled = + forceDisable === true || !canStartStopTransform || stoppedTransform === true; - const stopButton = ( - - {buttonStopText} - + {buttonText} + ); + if (!canStartStopTransform || stoppedTransform) { return ( = ({ items, forceDisable }) => { : stoppedTransformMessage } > - {stopButton} + {button} ); } - return stopButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index 9df4113fa9a8b..a31251943061a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -292,7 +292,7 @@ export const TransformList: FC = ({ button={buttonIcon} isOpen={isActionsMenuOpen} closePopover={() => setIsActionsMenuOpen(false)} - panelPaddingSize="none" + panelPaddingSize="s" anchorPosition="rightUp" > {bulkActionMenuItems} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a4100ae914b25..71fe63e0dbf6b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9588,7 +9588,6 @@ "xpack.ml.dataframe.analyticsList.title": "分析ジョブ", "xpack.ml.dataframe.analyticsList.type": "タイプ", "xpack.ml.dataframe.analyticsList.viewActionName": "表示", - "xpack.ml.dataframe.analyticsList.viewAriaLabel": "表示", "xpack.ml.dataframe.stepCreateForm.createDataFrameAnalyticsSuccessMessage": "データフレーム分析 {jobId} の作成リクエストが受け付けられました。", "xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink": "インデックス名の制限に関する詳細。", "xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel": "データフレーム分析", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 69e37f3f9f9f0..35f3e35bdeae8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9593,7 +9593,6 @@ "xpack.ml.dataframe.analyticsList.title": "分析作业", "xpack.ml.dataframe.analyticsList.type": "类型", "xpack.ml.dataframe.analyticsList.viewActionName": "查看", - "xpack.ml.dataframe.analyticsList.viewAriaLabel": "查看", "xpack.ml.dataframe.stepCreateForm.createDataFrameAnalyticsSuccessMessage": "数据帧分析 {jobId} 创建请求已确认。", "xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink": "详细了解索引名称限制。", "xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel": "数据帧分析", diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts index 242f906d0d197..3340ac49b2d2d 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts @@ -20,7 +20,8 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('add_prepackaged_rules', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71867 + describe.skip('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { const { body } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index 5e0ce0b824323..7671b1bd49744 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -20,7 +20,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // https://github.com/elastic/kibana/issues/71814 + // FLAKY: https://github.com/elastic/kibana/issues/71814 describe.skip('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 23d4cc972675b..bc086a3171640 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -64,7 +64,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.getActions().move({ x: 5, y: 5, origin: el._webElement }).click().perform(); } - describe('lens smokescreen tests', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71304 + describe.skip('lens smokescreen tests', () => { it('should allow editing saved visualizations', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('Artistpreviouslyknownaslens'); diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts index 497078c4fd273..d65dfe20e5cd8 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts @@ -274,7 +274,8 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; - describe('advanced job', function () { + // FLAKY: https://github.com/elastic/kibana/issues/71971 + describe.skip('advanced job', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/ecommerce'); diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts index f73ba56c172c4..b6807b2fd3414 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts @@ -21,7 +21,8 @@ export default function ({ getService }: FtrProviderContext) { const mappingsPackage = 'overrides-0.1.0'; const server = dockerServers.get('registry'); - describe('installs packages that include settings and mappings overrides', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/71939 + describe.skip('installs packages that include settings and mappings overrides', async () => { after(async () => { if (server.enabled) { // remove the package just in case it being installed will affect other tests diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 57321ab4cd911..b91f0647487ff 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -19,7 +19,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const policyTestResources = getService('policyTestResources'); const RELATIVE_DATE_FORMAT = /\d (?:seconds|minutes) ago/i; - describe('When on the Endpoint Policy List', function () { + // FLAKY: https://github.com/elastic/kibana/issues/71951 + describe.skip('When on the Endpoint Policy List', function () { this.tags(['ciGroup7']); before(async () => { await pageObjects.policy.navigateToPolicyList();